From 57dcea219dcd6a775caec77ab485344d878e1d39 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 Aug 2025 05:03:16 +0000 Subject: [PATCH 001/121] 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] --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 51125f9..82f9d9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1135,9 +1135,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.1" +version = "1.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" dependencies = [ "aho-corasick", "memchr", diff --git a/Cargo.toml b/Cargo.toml index 50276c9..61fb323 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ smol = "2.0.2" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.143" base64 = "0.22.1" -regex = "1.11.1" +regex = "1.11.2" ratatui = "0.29.0" crossterm = "0.29.0" unicode-segmentation = "1.12.0" From d65d85676f58706aad12950c00049e23fd0f4b60 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Sep 2025 02:26:23 +0000 Subject: [PATCH 002/121] 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] --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 82f9d9d..e8cf34a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -267,9 +267,9 @@ checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" [[package]] name = "clap" -version = "4.5.45" +version = "4.5.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc0e74a703892159f5ae7d3aac52c8e6c392f5ae5f359c70b5881d60aaac318" +checksum = "2c5e4fcf9c21d2e544ca1ee9d8552de13019a42aa7dbf32747fa7aaf1df76e57" dependencies = [ "clap_builder", "clap_derive", @@ -287,9 +287,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.44" +version = "4.5.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8" +checksum = "fecb53a0e6fcfb055f686001bc2e2592fa527efaf38dbe81a6a9563562e57d41" dependencies = [ "anstream", "anstyle", diff --git a/Cargo.toml b/Cargo.toml index 61fb323..5c562a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ repository = "https://github.com/notashelf/stash" rust-version = "1.85" [dependencies] -clap = { version = "4.5.45", features = ["derive"] } +clap = { version = "4.5.46", features = ["derive"] } clap-verbosity-flag = "3.0.4" dirs = "6.0.0" imagesize = "0.14.0" From 3e9aa6b2a39a3e314726cb4f412bbb0134bbbbbd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 14:38:54 +0000 Subject: [PATCH 003/121] 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] --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e8cf34a..4ee5f0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -866,9 +866,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "lru" diff --git a/Cargo.toml b/Cargo.toml index 5c562a1..204d747 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ imagesize = "0.14.0" inquire = { default-features = false, version = "0.7.5", features = [ "crossterm", ] } -log = "0.4.27" +log = "0.4.28" env_logger = "0.11.8" thiserror = "2.0.16" wl-clipboard-rs = "0.9.2" From e82f2911d049f5a819ddf0ae90be950a2526e98a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Sep 2025 15:08:56 +0000 Subject: [PATCH 004/121] 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] --- Cargo.lock | 166 ++++++----------------------------------------------- Cargo.toml | 2 +- 2 files changed, 20 insertions(+), 148 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4ee5f0c..ceda941 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -204,12 +204,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.9.1" @@ -229,12 +223,6 @@ dependencies = [ "piper", ] -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "cassowary" version = "0.3.0" @@ -359,31 +347,15 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crossterm" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" -dependencies = [ - "bitflags 1.3.2", - "crossterm_winapi", - "libc", - "mio 0.8.11", - "parking_lot", - "signal-hook", - "signal-hook-mio", - "winapi", -] - [[package]] name = "crossterm" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.9.1", + "bitflags", "crossterm_winapi", - "mio 1.0.4", + "mio", "parking_lot", "rustix 0.38.44", "signal-hook", @@ -397,11 +369,11 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.9.1", + "bitflags", "crossterm_winapi", "derive_more", "document-features", - "mio 1.0.4", + "mio", "parking_lot", "rustix 1.0.8", "signal-hook", @@ -643,15 +615,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - [[package]] name = "getrandom" version = "0.2.16" @@ -737,16 +700,13 @@ checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" [[package]] name = "inquire" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" +checksum = "95b8b5b4fd6d0ef1235f11c2e8ce9734be5736c21230ff585c3bae2e940abced" dependencies = [ - "bitflags 2.9.1", - "crossterm 0.25.0", + "bitflags", + "crossterm 0.28.1", "dyn-clone", - "fxhash", - "newline-converter", - "once_cell", "unicode-segmentation", "unicode-width 0.1.14", ] @@ -821,7 +781,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ - "bitflags 2.9.1", + "bitflags", "libc", ] @@ -891,18 +851,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "mio" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" -dependencies = [ - "libc", - "log", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.48.0", -] - [[package]] name = "mio" version = "1.0.4" @@ -915,15 +863,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "newline-converter" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "nom" version = "7.1.3" @@ -1098,7 +1037,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags 2.9.1", + "bitflags", "cassowary", "compact_str", "crossterm 0.28.1", @@ -1119,7 +1058,7 @@ version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.9.1", + "bitflags", ] [[package]] @@ -1168,7 +1107,7 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" dependencies = [ - "bitflags 2.9.1", + "bitflags", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -1182,7 +1121,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.1", + "bitflags", "errno", "libc", "linux-raw-sys 0.4.15", @@ -1195,7 +1134,7 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags 2.9.1", + "bitflags", "errno", "libc", "linux-raw-sys 0.9.4", @@ -1275,8 +1214,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", - "mio 0.8.11", - "mio 1.0.4", + "mio", "signal-hook", ] @@ -1514,7 +1452,7 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ - "bitflags 2.9.1", + "bitflags", "rustix 1.0.8", "wayland-backend", "wayland-scanner", @@ -1526,7 +1464,7 @@ version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" dependencies = [ - "bitflags 2.9.1", + "bitflags", "wayland-backend", "wayland-client", "wayland-scanner", @@ -1538,7 +1476,7 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" dependencies = [ - "bitflags 2.9.1", + "bitflags", "wayland-backend", "wayland-client", "wayland-protocols", @@ -1593,15 +1531,6 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.59.0" @@ -1620,21 +1549,6 @@ dependencies = [ "windows-targets 0.53.3", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -1668,12 +1582,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -1686,12 +1594,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -1704,12 +1606,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -1734,12 +1630,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -1752,12 +1642,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -1770,12 +1654,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -1788,12 +1666,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -1812,7 +1684,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.9.1", + "bitflags", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 204d747..1d171b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ clap = { version = "4.5.46", features = ["derive"] } clap-verbosity-flag = "3.0.4" dirs = "6.0.0" imagesize = "0.14.0" -inquire = { default-features = false, version = "0.7.5", features = [ +inquire = { default-features = false, version = "0.8.0", features = [ "crossterm", ] } log = "0.4.28" From d1e348df9e28f548d0e611caa8dbfb55ef190359 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 15 Sep 2025 19:54:21 +0300 Subject: [PATCH 005/121] chore: bump dependencies Signed-off-by: NotAShelf Change-Id: I6a6a6964707aec82d862a37d28113667d3cc6162 --- Cargo.lock | 35 +++++++++++++++++++++++------------ Cargo.toml | 6 +++--- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ceda941..979e01c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -255,9 +255,9 @@ checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" [[package]] name = "clap" -version = "4.5.46" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c5e4fcf9c21d2e544ca1ee9d8552de13019a42aa7dbf32747fa7aaf1df76e57" +checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" dependencies = [ "clap_builder", "clap_derive", @@ -275,9 +275,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.46" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecb53a0e6fcfb055f686001bc2e2592fa527efaf38dbe81a6a9563562e57d41" +checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" dependencies = [ "anstream", "anstyle", @@ -287,9 +287,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.45" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" dependencies = [ "heck", "proc-macro2", @@ -1161,18 +1161,28 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.224" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "6aaeb1e94f53b16384af593c71e20b095e958dab1d26939c1b70645c5cfbcc0b" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.224" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f39390fa6346e24defbcdd3d9544ba8a19985d0af74df8501fbfe9a64341ab" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.224" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "87ff78ab5e8561c9a675bfc1785cb07ae721f0ee53329a595cefd8c04c2ac4e0" dependencies = [ "proc-macro2", "quote", @@ -1181,14 +1191,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1d171b5..39c8d7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ repository = "https://github.com/notashelf/stash" rust-version = "1.85" [dependencies] -clap = { version = "4.5.46", features = ["derive"] } +clap = { version = "4.5.47", features = ["derive"] } clap-verbosity-flag = "3.0.4" dirs = "6.0.0" imagesize = "0.14.0" @@ -22,8 +22,8 @@ thiserror = "2.0.16" wl-clipboard-rs = "0.9.2" rusqlite = { version = "0.37.0", features = ["bundled"] } smol = "2.0.2" -serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0.143" +serde = { version = "1.0.224", features = ["derive"] } +serde_json = "1.0.145" base64 = "0.22.1" regex = "1.11.2" ratatui = "0.29.0" From e5204c4a3ab8f2a61802c668bbc44d96f3c6e5e9 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 19 Sep 2025 09:37:36 +0300 Subject: [PATCH 006/121] meta: gitignore everything by default Signed-off-by: NotAShelf Change-Id: I6a6a6964f4f01faeb5551718574c19cc2fa12c57 --- .gitignore | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index c5ee468..92ecf03 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,33 @@ -target/ -.direnv/ -result/ +# Ignore everything by default +/* +!/ + +!/nix +!/src +!/vendor + +# Rust/Cargo +!/Cargo.lock +!/Cargo.toml + +# 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 From 36c183742de82304ba155d8a3d08b6a207d73a97 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 19 Sep 2025 11:18:57 +0300 Subject: [PATCH 007/121] stash: blocking persistent entries by window class Signed-off-by: NotAShelf Change-Id: I6a6a6964061bd97b4ffc4e84d835072331a966c6 --- Cargo.lock | 11 +- Cargo.toml | 11 +- src/commands/store.rs | 9 +- src/commands/watch.rs | 42 ++++++-- src/db/mod.rs | 227 +++++++++++++++++++++++++++++++++++++++++- src/main.rs | 26 ++++- src/wayland/mod.rs | 176 ++++++++++++++++++++++++++++++++ 7 files changed, 483 insertions(+), 19 deletions(-) create mode 100644 src/wayland/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 979e01c..cc4be64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -700,15 +700,15 @@ checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" [[package]] name = "inquire" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95b8b5b4fd6d0ef1235f11c2e8ce9734be5736c21230ff585c3bae2e940abced" +checksum = "2628910d0114e9139056161d8644a2026be7b117f8498943f9437748b04c9e0a" dependencies = [ "bitflags", - "crossterm 0.28.1", + "crossterm 0.29.0", "dyn-clone", "unicode-segmentation", - "unicode-width 0.1.14", + "unicode-width 0.2.0", ] [[package]] @@ -1289,6 +1289,9 @@ dependencies = [ "thiserror", "unicode-segmentation", "unicode-width 0.2.0", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", "wl-clipboard-rs", ] diff --git a/Cargo.toml b/Cargo.toml index 39c8d7c..673f3a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,12 +8,16 @@ readme = true repository = "https://github.com/notashelf/stash" rust-version = "1.85" +[features] +default = ["use-toplevel"] +use-toplevel = ["dep:wayland-client", "dep:wayland-protocols", "dep:wayland-protocols-wlr"] + [dependencies] -clap = { version = "4.5.47", features = ["derive"] } +clap = { version = "4.5.47", features = ["derive", "env"] } clap-verbosity-flag = "3.0.4" dirs = "6.0.0" imagesize = "0.14.0" -inquire = { default-features = false, version = "0.8.0", features = [ +inquire = { default-features = false, version = "0.9.1", features = [ "crossterm", ] } log = "0.4.28" @@ -30,6 +34,9 @@ ratatui = "0.29.0" crossterm = "0.29.0" unicode-segmentation = "1.12.0" unicode-width = "0.2.0" +wayland-client = { version = "0.31.11", optional = true } +wayland-protocols = { version = "0.32.0", optional = true } +wayland-protocols-wlr = { version = "0.3.9", optional = true } [profile.release] lto = true diff --git a/src/commands/store.rs b/src/commands/store.rs index 6ddfb60..9e5a6c6 100644 --- a/src/commands/store.rs +++ b/src/commands/store.rs @@ -9,6 +9,7 @@ pub trait StoreCommand { max_dedupe_search: u64, max_items: u64, state: Option, + excluded_apps: &[String], ) -> Result<(), crate::db::StashError>; } @@ -19,12 +20,18 @@ impl StoreCommand for SqliteClipboardDb { max_dedupe_search: u64, max_items: u64, state: Option, + excluded_apps: &[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)?; + self.store_entry( + input, + max_dedupe_search, + max_items, + Some(excluded_apps), + )?; log::info!("Entry stored"); } Ok(()) diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 01e922e..a3d863d 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -6,11 +6,21 @@ use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents}; use crate::db::{ClipboardDb, Entry, SqliteClipboardDb}; pub trait WatchCommand { - fn watch(&self, max_dedupe_search: u64, max_items: u64); + fn watch( + &self, + max_dedupe_search: u64, + max_items: u64, + excluded_apps: &[String], + ); } impl WatchCommand for SqliteClipboardDb { - fn watch(&self, max_dedupe_search: u64, max_items: u64) { + fn watch( + &self, + max_dedupe_search: u64, + max_items: u64, + excluded_apps: &[String], + ) { smol::block_on(async { log::info!("Starting clipboard watch daemon"); @@ -46,10 +56,10 @@ impl WatchCommand for SqliteClipboardDb { // 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 new_contents = std::mem::take(&mut buf); let mime = Some(mime_type.to_string()); let entry = Entry { - contents: last_contents.as_ref().unwrap().clone(), + contents: new_contents.clone(), mime, }; let id = self.next_sequence(); @@ -57,13 +67,27 @@ impl WatchCommand for SqliteClipboardDb { &entry.contents[..], max_dedupe_search, max_items, + Some(excluded_apps), ) { - Ok(_) => log::info!("Stored new clipboard entry (id: {id})"), - Err(e) => log::error!("Failed to store clipboard entry: {e}"), + Ok(_) => { + log::info!("Stored new clipboard entry (id: {id})"); + last_contents = Some(new_contents); + }, + Err(crate::db::StashError::ExcludedByApp(_)) => { + log::info!("Clipboard entry excluded by app filter"); + last_contents = Some(new_contents); + }, + Err(crate::db::StashError::Store(ref msg)) + if msg.contains("Excluded by app filter") => + { + log::info!("Clipboard entry excluded by app filter"); + last_contents = Some(new_contents); + }, + Err(e) => { + log::error!("Failed to store clipboard entry: {e}"); + last_contents = Some(new_contents); + }, } - - // Drop clipboard contents after storing - last_contents = None; } }, Err(e) => { diff --git a/src/db/mod.rs b/src/db/mod.rs index efbbd0c..02984a8 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -8,7 +8,7 @@ use std::{ use base64::{Engine, engine::general_purpose::STANDARD}; use imagesize::{ImageSize, ImageType}; -use log::{error, info, warn}; +use log::{debug, error, info, warn}; use regex::Regex; use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; @@ -24,6 +24,8 @@ pub enum StashError { #[error("Failed to store entry: {0}")] Store(String), + #[error("Entry excluded by app filter: {0}")] + ExcludedByApp(String), #[error("Error reading entry during deduplication: {0}")] DeduplicationRead(String), #[error("Error decoding entry during deduplication: {0}")] @@ -61,6 +63,7 @@ pub trait ClipboardDb { input: impl Read, max_dedupe_search: u64, max_items: u64, + excluded_apps: Option<&[String]>, ) -> Result; fn deduplicate(&self, buf: &[u8], max: u64) -> Result; fn trim_db(&self, max: u64) -> Result<(), StashError>; @@ -110,6 +113,9 @@ impl SqliteClipboardDb { );", ) .map_err(|e| StashError::Store(e.to_string()))?; + // Initialize Wayland state in background thread + #[cfg(feature = "use-toplevel")] + crate::wayland::init_wayland_state(); Ok(Self { conn }) } } @@ -163,6 +169,7 @@ impl ClipboardDb for SqliteClipboardDb { mut input: impl Read, max_dedupe_search: u64, max_items: u64, + excluded_apps: Option<&[String]>, ) -> Result { let mut buf = Vec::new(); if input.read_to_end(&mut buf).is_err() @@ -201,6 +208,14 @@ impl ClipboardDb for SqliteClipboardDb { } } + // Check if clipboard should be excluded based on running apps + if should_exclude_by_app(excluded_apps) { + warn!("Clipboard entry excluded by app filter"); + return Err(StashError::ExcludedByApp( + "Clipboard entry from excluded app".to_string(), + )); + } + self.deduplicate(&buf, max_dedupe_search)?; self @@ -540,3 +555,213 @@ pub fn size_str(size: usize) -> String { } format!("{:.0} {}", fsize, units[i]) } + +/// Check if clipboard should be excluded based on excluded apps configuration. +/// Uses timing correlation and focused window detection to identify source app. +fn should_exclude_by_app(excluded_apps: Option<&[String]>) -> bool { + let excluded = match excluded_apps { + Some(apps) if !apps.is_empty() => apps, + _ => return false, + }; + + // Try multiple detection strategies + if detect_excluded_app_activity(excluded) { + return true; + } + + false +} + +/// Detect if clipboard likely came from an excluded app using multiple +/// strategies. +fn detect_excluded_app_activity(excluded_apps: &[String]) -> bool { + debug!("Checking clipboard exclusion against: {excluded_apps:?}"); + + // Strategy 1: Check focused window (compositor-dependent) + if let Some(focused_app) = get_focused_window_app() { + debug!("Focused window detected: {focused_app}"); + if app_matches_exclusion(&focused_app, excluded_apps) { + debug!("Clipboard excluded: focused window matches {focused_app}"); + return true; + } + } else { + debug!("No focused window detected"); + } + + // Strategy 2: Check recently active processes (timing correlation) + if let Some(active_app) = get_recently_active_excluded_app(excluded_apps) { + debug!("Clipboard excluded: recent activity from {active_app}"); + return true; + } + debug!("No recently active excluded apps found"); + + debug!("Clipboard not excluded"); + false +} + +/// Try to get the currently focused window application name. +fn get_focused_window_app() -> Option { + // Try Wayland protocol first + #[cfg(feature = "use-toplevel")] + if let Some(app) = crate::wayland::get_focused_window_app() { + return Some(app); + } + + // Fallback: Check WAYLAND_CLIENT_NAME environment variable + if let Ok(client) = env::var("WAYLAND_CLIENT_NAME") { + if !client.is_empty() { + debug!("Found WAYLAND_CLIENT_NAME: {client}"); + return Some(client); + } + } + + debug!("No focused window detection method worked"); + None +} + +/// Check for recently active excluded apps using CPU and I/O activity. +fn get_recently_active_excluded_app( + excluded_apps: &[String], +) -> Option { + let proc_dir = std::path::Path::new("/proc"); + if !proc_dir.exists() { + return None; + } + + let mut candidates = Vec::new(); + + if let Ok(entries) = fs::read_dir(proc_dir) { + for entry in entries.flatten() { + if let Ok(pid) = entry.file_name().to_string_lossy().parse::() { + if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) { + let process_name = comm.trim(); + + // Check process name against exclusion list + if app_matches_exclusion(process_name, excluded_apps) + && has_recent_activity(pid) + { + candidates.push(( + process_name.to_string(), + get_process_activity_score(pid), + )); + } + } + } + } + } + + // Return the most active excluded app + candidates + .into_iter() + .max_by_key(|(_, score)| *score) + .map(|(name, _)| name) +} + +/// Check if a process has had recent activity (simple heuristic). +fn has_recent_activity(pid: u32) -> bool { + // Check /proc/PID/stat for recent CPU usage + if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { + let fields: Vec<&str> = stat.split_whitespace().collect(); + if fields.len() > 14 { + // Fields 14 and 15 are utime and stime + if let (Ok(utime), Ok(stime)) = + (fields[13].parse::(), fields[14].parse::()) + { + let total_time = utime + stime; + // Simple heuristic: if process has any significant CPU time, consider + // it active + return total_time > 100; // arbitrary threshold + } + } + } + + // Check /proc/PID/io for recent I/O activity + if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) { + for line in io_stats.lines() { + if line.starts_with("write_bytes:") || line.starts_with("read_bytes:") { + if let Some(value_str) = line.split(':').nth(1) { + if let Ok(value) = value_str.trim().parse::() { + if value > 1024 * 1024 { + // 1MB threshold + return true; + } + } + } + } + } + } + + false +} + +/// Get a simple activity score for process prioritization. +fn get_process_activity_score(pid: u32) -> u64 { + let mut score = 0; + + // Add CPU time to score + if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { + let fields: Vec<&str> = stat.split_whitespace().collect(); + if fields.len() > 14 { + if let (Ok(utime), Ok(stime)) = + (fields[13].parse::(), fields[14].parse::()) + { + score += utime + stime; + } + } + } + + // Add I/O activity to score + if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) { + for line in io_stats.lines() { + if line.starts_with("write_bytes:") || line.starts_with("read_bytes:") { + if let Some(value_str) = line.split(':').nth(1) { + if let Ok(value) = value_str.trim().parse::() { + score += value / 1024; // convert to KB + } + } + } + } + } + + score +} + +/// Check if an app name matches any in the exclusion list. +/// Supports basic string matching and simple regex patterns. +fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool { + debug!( + "Checking if '{app_name}' matches exclusion list: {excluded_apps:?}" + ); + + for excluded in excluded_apps { + // Basic string matching (case-insensitive) + if app_name.to_lowercase() == excluded.to_lowercase() { + debug!("Matched exact string: {app_name} == {excluded}"); + return true; + } + + // Simple pattern matching for common cases + if excluded.starts_with('^') && excluded.ends_with('$') { + // Exact match pattern like ^AppName$ + let pattern = &excluded[1..excluded.len() - 1]; + if app_name == pattern { + debug!("Matched exact pattern: {app_name} == {pattern}"); + return true; + } + } else if excluded.contains('*') { + // Simple wildcard matching + let pattern = excluded.replace('*', ".*"); + if let Ok(regex) = regex::Regex::new(&pattern) { + if regex.is_match(app_name) { + debug!( + "Matched wildcard pattern: {app_name} matches {excluded}" + ); + return true; + } + } + } + } + + debug!("No match found for '{app_name}'"); + false +} diff --git a/src/main.rs b/src/main.rs index 0b40450..2c12a80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ use inquire::Confirm; mod commands; mod db; +#[cfg(feature = "use-toplevel")] mod wayland; use crate::commands::{ decode::DecodeCommand, @@ -47,6 +48,11 @@ struct Cli { #[arg(long)] db_path: Option, + /// Application names to exclude from clipboard history + #[cfg(feature = "use-toplevel")] + #[arg(long, value_delimiter = ',', env = "STASH_EXCLUDED_APPS")] + excluded_apps: Vec, + /// Ask for confirmation before destructive operations #[arg(long)] ask: bool, @@ -160,7 +166,16 @@ fn main() { 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), + db.store( + io::stdin(), + cli.max_dedupe_search, + cli.max_items, + state, + #[cfg(feature = "use-toplevel")] + &cli.excluded_apps, + #[cfg(not(feature = "use-toplevel"))] + &[], + ), "Failed to store entry", ); }, @@ -313,7 +328,14 @@ fn main() { } }, Some(Command::Watch) => { - db.watch(cli.max_dedupe_search, cli.max_items); + db.watch( + cli.max_dedupe_search, + cli.max_items, + #[cfg(feature = "use-toplevel")] + &cli.excluded_apps, + #[cfg(not(feature = "use-toplevel"))] + &[], + ); }, None => { if let Err(e) = Cli::command().print_help() { diff --git a/src/wayland/mod.rs b/src/wayland/mod.rs new file mode 100644 index 0000000..acc449c --- /dev/null +++ b/src/wayland/mod.rs @@ -0,0 +1,176 @@ +use std::{ + collections::HashMap, + sync::{LazyLock, Mutex}, +}; + +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: Mutex> = Mutex::new(None); +static TOPLEVEL_APPS: LazyLock>> = + 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 { + // Try Wayland protocol first + if let Ok(focused) = FOCUSED_APP.lock() { + if let Some(ref app) = *focused { + debug!("Found focused app via Wayland protocol: {}", app); + return Some(app.clone()); + } + } + + debug!("No focused window detection method worked"); + None +} + +/// Run the Wayland event loop +fn run_wayland_event_loop() -> Result<(), Box> { + 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 for AppState { + fn event( + _state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _data: &(), + _conn: &WaylandConnection, + qh: &QueueHandle, + ) { + if let wl_registry::Event::Global { + name, + interface, + version: _, + } = event + { + if interface == "zwlr_foreign_toplevel_manager_v1" { + let _manager: ZwlrForeignToplevelManagerV1 = + registry.bind(name, 1, qh, ()); + } + } + } + + fn event_created_child( + _opcode: u16, + qhandle: &QueueHandle, + ) -> std::sync::Arc { + qhandle.make_data::(()) + } +} + +impl Dispatch for AppState { + fn event( + _state: &mut Self, + _manager: &ZwlrForeignToplevelManagerV1, + event: zwlr_foreign_toplevel_manager_v1::Event, + _data: &(), + _conn: &WaylandConnection, + _qh: &QueueHandle, + ) { + if let zwlr_foreign_toplevel_manager_v1::Event::Toplevel { toplevel } = + event + { + // New toplevel created + // We'll track it for focus events + let _handle: ZwlrForeignToplevelHandleV1 = toplevel; + } + } + + fn event_created_child( + _opcode: u16, + qhandle: &QueueHandle, + ) -> std::sync::Arc { + qhandle.make_data::(()) + } +} + +impl Dispatch for AppState { + fn event( + _state: &mut Self, + handle: &ZwlrForeignToplevelHandleV1, + event: zwlr_foreign_toplevel_handle_v1::Event, + _data: &(), + _conn: &WaylandConnection, + _qh: &QueueHandle, + ) { + 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 = 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), Ok(mut focused)) = + (TOPLEVEL_APPS.lock(), FOCUSED_APP.lock()) + { + if let Some(app_id) = apps.get(&handle_id) { + debug!("Setting focused app to: {}", app_id); + *focused = Some(app_id.clone()); + } + } + } + }, + _ => {}, + } + } + + fn event_created_child( + _opcode: u16, + qhandle: &QueueHandle, + ) -> std::sync::Arc { + qhandle.make_data::(()) + } +} From 2bbd8d11c2b221aee080c5277c6ec5e6af86b35c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 19 Sep 2025 11:22:38 +0300 Subject: [PATCH 008/121] docs: describe new app exclusion feature Signed-off-by: NotAShelf Change-Id: I6a6a696479a0c3a1e302b3abb1cfab4d95ae5b11 --- README.md | 59 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 240c749..66354c8 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ features such as but not limited to: - Text previews with customizable width - Automatic clipboard monitoring with `stash watch` - Sensitive clipboard filtering via regex (see below) +- Sensitive clipboard flitering by application (see below) See [usage section](#usage) for more details. @@ -179,35 +180,65 @@ commands `--help` text for more details. The following are generally standard: - `--preview-width `: Text preview max width for `list` - `--version`: Print the current version and exit -#### Sensitive Clipboard Filtering +### 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 two ways: +The filter can be configured in one of three ways, as part of two separate +features. -- **Environment variable**: Set `STASH_SENSITIVE_REGEX` to a valid regex - pattern. If clipboard text matches, it will not be stored. -- **Systemd LoadCredential**: If running as a service, you can provide a regex - pattern via a credential file. For example, add to your `stash.service`: +#### Clipboard Filtering by Entry Regex - ```ini - LoadCredential=clipboard_filter:/etc/stash/clipboard_filter - ``` +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. - The file `/etc/stash/clipboard_filter` should contain your regex pattern (no - quotes). This is done automatically in the vendored Systemd service. Remember - to set the appropriate file permissions if using this option. +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. 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. -**Example regex to block common password patterns**: +> [!TIP] +> **Example regex to block common password patterns**: +> +> `(password|secret|api[_-]?key|token)[=: ]+[^\s]+` -- `(password|secret|api[_-]?key|token)[=: ]+[^\s]+` +#### 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` ## Tips & Tricks From 7857dc2d2d4609ee4991d5561b60af0aa95e9911 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 19 Sep 2025 11:25:48 +0300 Subject: [PATCH 009/121] ci: tag releases automatically Signed-off-by: NotAShelf Change-Id: I6a6a6964dc061848bfbda520a4e311c3f9558557 --- .github/workflows/nix-cache.yaml | 5 +++-- .github/workflows/release.yaml | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nix-cache.yaml b/.github/workflows/nix-cache.yaml index 7ece038..2ddd98a 100644 --- a/.github/workflows/nix-cache.yaml +++ b/.github/workflows/nix-cache.yaml @@ -13,7 +13,7 @@ jobs: populate-cache: runs-on: ubuntu-latest steps: - - name: "CHeckout" + - name: "Checkout" uses: actions/checkout@v5 - uses: cachix/install-nix-action@v31 @@ -25,4 +25,5 @@ jobs: name: nyx authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' - - run: nix build + - name: "Build with Nix" + run: nix build diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4842934..b101cb1 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -9,7 +9,26 @@ permissions: contents: write jobs: + tag-release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - 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 }} From e92cdc444d969aad9442ab3a4216a151fd30c3da Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 19 Sep 2025 11:26:18 +0300 Subject: [PATCH 010/121] chore: release v0.3.0 Signed-off-by: NotAShelf Change-Id: I6a6a69648f92f658da9ff14bd5e7d864b6cc6584 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cc4be64..f097c20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1269,7 +1269,7 @@ dependencies = [ [[package]] name = "stash" -version = "0.2.4" +version = "0.3.0" dependencies = [ "base64", "clap", diff --git a/Cargo.toml b/Cargo.toml index 673f3a2..a5695a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stash" -version = "0.2.4" +version = "0.3.0" edition = "2024" authors = ["NotAShelf "] license = "MPL-2.0" From f40e11195c7076285178765938f2e2e2698bf3d3 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 19 Sep 2025 11:32:25 +0300 Subject: [PATCH 011/121] chore: add missing description field to crate manifest Signed-off-by: NotAShelf Change-Id: I6a6a696432a624912508f46fae70a66783e0f7fe --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index a5695a2..4fc3b6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [package] name = "stash" +description = "Wayland clipboard manager with fast persistent history and multi-media support" version = "0.3.0" edition = "2024" authors = ["NotAShelf "] From acb6657e73156a21e37605cd29de4b9dee0e615c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 19 Sep 2025 11:52:53 +0300 Subject: [PATCH 012/121] chore: release crate to "stash-clipboard" Signed-off-by: NotAShelf Change-Id: I6a6a69640a8b0ccc2d8bb11181c9fdeb1397c329 --- Cargo.lock | 2 +- Cargo.toml | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f097c20..4a7d1ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1268,7 +1268,7 @@ dependencies = [ ] [[package]] -name = "stash" +name = "stash-clipboard" version = "0.3.0" dependencies = [ "base64", diff --git a/Cargo.toml b/Cargo.toml index 4fc3b6c..9db55e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "stash" +name = "stash-clipboard" description = "Wayland clipboard manager with fast persistent history and multi-media support" version = "0.3.0" edition = "2024" @@ -9,6 +9,10 @@ readme = true repository = "https://github.com/notashelf/stash" rust-version = "1.85" +[[bin]] +name = "stash" # actual binary name for Nix, Cargo, etc. +path = "src/main.rs" + [features] default = ["use-toplevel"] use-toplevel = ["dep:wayland-client", "dep:wayland-protocols", "dep:wayland-protocols-wlr"] From d05ad311a9564d6abe22f869809fd8704691563a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 19 Sep 2025 12:41:57 +0300 Subject: [PATCH 013/121] wayland: remove closed toplevels on event Signed-off-by: NotAShelf Change-Id: I6a6a69644f5e067b3533a7f62c42d4a6d01be00b --- src/wayland/mod.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/wayland/mod.rs b/src/wayland/mod.rs index acc449c..425d5ab 100644 --- a/src/wayland/mod.rs +++ b/src/wayland/mod.rs @@ -163,6 +163,12 @@ impl Dispatch for AppState { } } }, + 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); + } + }, _ => {}, } } From a41d72fb6b381f2a0a09d783b99c7d9efc5ae412 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 19 Sep 2025 13:46:09 +0300 Subject: [PATCH 014/121] 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` 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 Change-Id: I6a6a6964d0a33392da61372214ca3088551564ac --- src/commands/decode.rs | 28 +-- src/commands/import.rs | 20 +- src/commands/list.rs | 27 +-- src/commands/watch.rs | 89 +++++---- src/db/mod.rs | 430 ++++++++++++++++++++++++----------------- src/main.rs | 2 +- src/wayland/mod.rs | 12 +- 7 files changed, 349 insertions(+), 259 deletions(-) diff --git a/src/commands/decode.rs b/src/commands/decode.rs index 9dc9116..8f414a1 100644 --- a/src/commands/decode.rs +++ b/src/commands/decode.rs @@ -26,7 +26,7 @@ impl DecodeCommand for SqliteClipboardDb { let mut buf = String::new(); in_ .read_to_string(&mut buf) - .map_err(|e| StashError::DecodeRead(e.to_string()))?; + .map_err(|e| StashError::DecodeRead(e.to_string().into()))?; buf }; @@ -38,18 +38,18 @@ impl DecodeCommand for SqliteClipboardDb { { let mut buf = Vec::new(); reader.read_to_end(&mut buf).map_err(|e| { - StashError::DecodeRead(format!( - "Failed to read clipboard for relay: {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}" - )) + StashError::DecodeWrite( + format!("Failed to write clipboard relay: {e}").into(), + ) })?; } else { return Err(StashError::DecodeGet( - "Failed to get clipboard contents for relay".to_string(), + "Failed to get clipboard contents for relay".into(), )); } return Ok(()); @@ -69,14 +69,14 @@ impl DecodeCommand for SqliteClipboardDb { { let mut buf = Vec::new(); reader.read_to_end(&mut buf).map_err(|err| { - StashError::DecodeRead(format!( - "Failed to read clipboard for relay: {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}" - )) + StashError::DecodeWrite( + format!("Failed to write clipboard relay: {err}").into(), + ) })?; Ok(()) } else { diff --git a/src/commands/import.rs b/src/commands/import.rs index 05833d7..a5b4e55 100644 --- a/src/commands/import.rs +++ b/src/commands/import.rs @@ -27,19 +27,19 @@ impl ImportCommand for SqliteClipboardDb { 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}")) + 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:?}" - ))); + return Err(StashError::Store( + format!("Malformed TSV line {lineno}: {line:?}").into(), + )); }; let Ok(_id) = id_str.parse::() else { - return Err(StashError::Store(format!( - "Failed to parse id from line {lineno}: {id_str}" - ))); + return Err(StashError::Store( + format!("Failed to parse id from line {lineno}: {id_str}").into(), + )); }; let entry = Entry { @@ -54,9 +54,9 @@ impl ImportCommand for SqliteClipboardDb { rusqlite::params![entry.contents, entry.mime], ) .map_err(|e| { - StashError::Store(format!( - "Failed to insert entry at line {lineno}: {e}" - )) + StashError::Store( + format!("Failed to insert entry at line {lineno}: {e}").into(), + ) })?; imported += 1; } diff --git a/src/commands/list.rs b/src/commands/list.rs index 75c1ce5..68a5b39 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -47,27 +47,27 @@ impl SqliteClipboardDb { let mut stmt = self .conn .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut entries: Vec<(u64, String, String)> = Vec::new(); let mut max_id_width = 2; let mut max_mime_width = 8; while let Some(row) = rows .next() - .map_err(|e| StashError::ListDecode(e.to_string()))? + .map_err(|e| StashError::ListDecode(e.to_string().into()))? { let id: u64 = row .get(0) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let contents: Vec = row .get(1) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mime: Option = row .get(2) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let preview = crate::db::preview_entry(&contents, mime.as_deref(), preview_width); let mime_str = mime.as_deref().unwrap_or("").to_string(); @@ -77,13 +77,14 @@ impl SqliteClipboardDb { entries.push((id, preview, mime_str)); } - enable_raw_mode().map_err(|e| StashError::ListDecode(e.to_string()))?; + 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()))?; + .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()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut state = ListState::default(); if !entries.is_empty() { @@ -225,13 +226,13 @@ impl SqliteClipboardDb { f.render_stateful_widget(list, area, &mut state); }) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; if event::poll(std::time::Duration::from_millis(250)) - .map_err(|e| StashError::ListDecode(e.to_string()))? + .map_err(|e| StashError::ListDecode(e.to_string().into()))? { - if let Event::Key(key) = - event::read().map_err(|e| StashError::ListDecode(e.to_string()))? + if let Event::Key(key) = event::read() + .map_err(|e| StashError::ListDecode(e.to_string().into()))? { match key.code { KeyCode::Char('q') | KeyCode::Esc => break, diff --git a/src/commands/watch.rs b/src/commands/watch.rs index a3d863d..ce2acf7 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -1,9 +1,14 @@ -use std::{io::Read, time::Duration}; +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + io::Read, + time::Duration, +}; use smol::Timer; use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents}; -use crate::db::{ClipboardDb, Entry, SqliteClipboardDb}; +use crate::db::{ClipboardDb, SqliteClipboardDb}; pub trait WatchCommand { fn watch( @@ -24,11 +29,18 @@ impl WatchCommand for SqliteClipboardDb { smol::block_on(async { log::info!("Starting clipboard watch daemon"); - // Preallocate buffer for clipboard contents - let mut last_contents: Option> = None; - let mut buf = Vec::with_capacity(4096); // reasonable default, hopefully + // We use hashes for comparison instead of storing full contents + let mut last_hash: Option = None; + let mut buf = Vec::with_capacity(4096); - // Initialize with current clipboard to avoid duplicating on startup + // Helper to hash clipboard contents + let hash_contents = |data: &[u8]| -> u64 { + let mut hasher = DefaultHasher::new(); + data.hash(&mut hasher); + hasher.finish() + }; + + // Initialize with current clipboard if let Ok((mut reader, _)) = get_contents( ClipboardType::Regular, Seat::Unspecified, @@ -36,7 +48,7 @@ impl WatchCommand for SqliteClipboardDb { ) { buf.clear(); if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() { - last_contents = Some(buf.clone()); + last_hash = Some(hash_contents(&buf)); } } @@ -46,7 +58,7 @@ impl WatchCommand for SqliteClipboardDb { Seat::Unspecified, wl_clipboard_rs::paste::MimeType::Any, ) { - Ok((mut reader, mime_type)) => { + 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}"); @@ -55,38 +67,35 @@ impl WatchCommand for SqliteClipboardDb { } // Only store if changed and not empty - if !buf.is_empty() && (last_contents.as_ref() != Some(&buf)) { - let new_contents = std::mem::take(&mut buf); - let mime = Some(mime_type.to_string()); - let entry = Entry { - contents: new_contents.clone(), - mime, - }; - let id = self.next_sequence(); - match self.store_entry( - &entry.contents[..], - max_dedupe_search, - max_items, - Some(excluded_apps), - ) { - Ok(_) => { - log::info!("Stored new clipboard entry (id: {id})"); - last_contents = Some(new_contents); - }, - Err(crate::db::StashError::ExcludedByApp(_)) => { - log::info!("Clipboard entry excluded by app filter"); - last_contents = Some(new_contents); - }, - Err(crate::db::StashError::Store(ref msg)) - if msg.contains("Excluded by app filter") => - { - log::info!("Clipboard entry excluded by app filter"); - last_contents = Some(new_contents); - }, - Err(e) => { - log::error!("Failed to store clipboard entry: {e}"); - last_contents = Some(new_contents); - }, + if !buf.is_empty() { + let current_hash = hash_contents(&buf); + if last_hash != Some(current_hash) { + let id = self.next_sequence(); + match self.store_entry( + &buf[..], + max_dedupe_search, + max_items, + Some(excluded_apps), + ) { + Ok(_) => { + log::info!("Stored new clipboard entry (id: {id})"); + last_hash = Some(current_hash); + }, + 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); + }, + } } } }, diff --git a/src/db/mod.rs b/src/db/mod.rs index 02984a8..fa27cce 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,18 +1,20 @@ use std::{ + collections::hash_map::DefaultHasher, env, fmt, fs, + hash::{Hash, Hasher}, io::{BufRead, BufReader, Read, Write}, str, + sync::OnceLock, }; -use base64::{Engine, engine::general_purpose::STANDARD}; -use imagesize::{ImageSize, ImageType}; -use log::{debug, error, info, warn}; +use base64::prelude::*; +use imagesize::ImageType; +use log::{debug, error, warn}; use regex::Regex; use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; -use serde_json::json; use thiserror::Error; #[derive(Error, Debug)] @@ -23,38 +25,38 @@ pub enum StashError { AllWhitespace, #[error("Failed to store entry: {0}")] - Store(String), + Store(Box), #[error("Entry excluded by app filter: {0}")] - ExcludedByApp(String), + ExcludedByApp(Box), #[error("Error reading entry during deduplication: {0}")] - DeduplicationRead(String), + DeduplicationRead(Box), #[error("Error decoding entry during deduplication: {0}")] - DeduplicationDecode(String), + DeduplicationDecode(Box), #[error("Failed to remove entry during deduplication: {0}")] - DeduplicationRemove(String), + DeduplicationRemove(Box), #[error("Failed to trim entry: {0}")] - Trim(String), + Trim(Box), #[error("No entries to delete")] NoEntriesToDelete, #[error("Failed to delete last entry: {0}")] - DeleteLast(String), + DeleteLast(Box), #[error("Failed to wipe database: {0}")] - Wipe(String), + Wipe(Box), #[error("Failed to decode entry during list: {0}")] - ListDecode(String), + ListDecode(Box), #[error("Failed to read input for decode: {0}")] - DecodeRead(String), + DecodeRead(Box), #[error("Failed to extract id for decode: {0}")] - DecodeExtractId(String), + DecodeExtractId(Box), #[error("Failed to get entry for decode: {0}")] - DecodeGet(String), + DecodeGet(Box), #[error("Failed to write decoded entry: {0}")] - DecodeWrite(String), + DecodeWrite(Box), #[error("Failed to delete entry during query delete: {0}")] - QueryDelete(String), + QueryDelete(Box), #[error("Failed to delete entry with id {0}: {1}")] - DeleteEntry(u64, String), + DeleteEntry(u64, Box), } pub trait ClipboardDb { @@ -65,8 +67,13 @@ pub trait ClipboardDb { max_items: u64, excluded_apps: Option<&[String]>, ) -> Result; - fn deduplicate(&self, buf: &[u8], max: u64) -> Result; - fn trim_db(&self, max: u64) -> Result<(), StashError>; + + fn deduplicate_by_hash( + &self, + content_hash: i64, + max: u64, + ) -> Result; + fn trim_db(&self, max_items: u64) -> Result<(), StashError>; fn delete_last(&self) -> Result<(), StashError>; fn wipe_db(&self) -> Result<(), StashError>; fn list_entries( @@ -76,12 +83,12 @@ pub trait ClipboardDb { ) -> Result; fn decode_entry( &self, - in_: impl Read, + input: impl Read, out: impl Write, - input: Option, + id_hint: Option, ) -> Result<(), StashError>; fn delete_query(&self, query: &str) -> Result; - fn delete_entries(&self, in_: impl Read) -> Result; + fn delete_entries(&self, input: impl Read) -> Result; fn next_sequence(&self) -> u64; } @@ -104,6 +111,34 @@ pub struct SqliteClipboardDb { impl SqliteClipboardDb { pub fn new(conn: Connection) -> Result { + conn + .pragma_update(None, "synchronous", "OFF") + .map_err(|e| { + StashError::Store( + format!("Failed to set synchronous pragma: {e}").into(), + ) + })?; + conn + .pragma_update(None, "journal_mode", "MEMORY") + .map_err(|e| { + StashError::Store( + format!("Failed to set journal_mode pragma: {e}").into(), + ) + })?; + conn.pragma_update(None, "cache_size", "-256") // 256KB cache + .map_err(|e| StashError::Store(format!("Failed to set cache_size pragma: {e}").into()))?; + conn + .pragma_update(None, "temp_store", "memory") + .map_err(|e| { + StashError::Store( + format!("Failed to set temp_store pragma: {e}").into(), + ) + })?; + conn.pragma_update(None, "mmap_size", "0") // disable mmap + .map_err(|e| StashError::Store(format!("Failed to set mmap_size pragma: {e}").into()))?; + conn.pragma_update(None, "page_size", "512") // small(er) pages + .map_err(|e| StashError::Store(format!("Failed to set page_size pragma: {e}").into()))?; + conn .execute_batch( "CREATE TABLE IF NOT EXISTS clipboard ( @@ -112,8 +147,21 @@ impl SqliteClipboardDb { mime TEXT );", ) - .map_err(|e| StashError::Store(e.to_string()))?; - // Initialize Wayland state in background thread + .map_err(|e| StashError::Store(e.to_string().into()))?; + + // Add content_hash column if it doesn't exist + // Migration MUST be done to avoid breaking existing installations. + let _ = + conn.execute("ALTER TABLE clipboard ADD COLUMN content_hash INTEGER", []); + + // Create index for content_hash if it doesn't exist + let _ = conn.execute( + "CREATE INDEX IF NOT EXISTS idx_content_hash ON clipboard(content_hash)", + [], + ); + + // Initialize Wayland state in background thread. This will be used to track + // focused window state. #[cfg(feature = "use-toplevel")] crate::wayland::init_wayland_state(); Ok(Self { conn }) @@ -125,33 +173,34 @@ impl SqliteClipboardDb { let mut stmt = self .conn .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut entries = Vec::new(); while let Some(row) = rows .next() - .map_err(|e| StashError::ListDecode(e.to_string()))? + .map_err(|e| StashError::ListDecode(e.to_string().into()))? { let id: u64 = row .get(0) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let contents: Vec = row .get(1) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mime: Option = row .get(2) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let contents_str = match mime.as_deref() { Some(m) if m.starts_with("text/") || m == "application/json" => { - String::from_utf8_lossy(&contents).to_string() + String::from_utf8_lossy(&contents).into_owned() }, - _ => STANDARD.encode(&contents), + _ => base64::prelude::BASE64_STANDARD.encode(&contents), }; - entries.push(json!({ + entries.push(serde_json::json!({ "id": id, "contents": contents_str, "mime": mime, @@ -159,7 +208,7 @@ impl SqliteClipboardDb { } serde_json::to_string_pretty(&entries) - .map_err(|e| StashError::ListDecode(e.to_string())) + .map_err(|e| StashError::ListDecode(e.to_string().into())) } } @@ -182,17 +231,13 @@ impl ClipboardDb for SqliteClipboardDb { return Err(StashError::AllWhitespace); } - let mime = match detect_mime(&buf) { - None => { - // If valid UTF-8, treat as text/plain - if std::str::from_utf8(&buf).is_ok() { - Some("text/plain".to_string()) - } else { - None - } - }, - other => other, - }; + // Calculate content hash for deduplication + let mut hasher = DefaultHasher::new(); + buf.hash(&mut hasher); + #[allow(clippy::cast_possible_wrap)] + let content_hash = hasher.finish() as i64; + + let mime = detect_mime_optimized(&buf); // Try to load regex from systemd credential file, then env var let regex = load_sensitive_regex(); @@ -201,9 +246,7 @@ impl ClipboardDb for SqliteClipboardDb { if let Ok(s) = std::str::from_utf8(&buf) { if re.is_match(s) { warn!("Clipboard entry matches sensitive regex, skipping store."); - return Err(StashError::Store( - "Filtered by sensitive regex".to_string(), - )); + return Err(StashError::Store("Filtered by sensitive regex".into())); } } } @@ -212,50 +255,56 @@ impl ClipboardDb for SqliteClipboardDb { if should_exclude_by_app(excluded_apps) { warn!("Clipboard entry excluded by app filter"); return Err(StashError::ExcludedByApp( - "Clipboard entry from excluded app".to_string(), + "Clipboard entry from excluded app".into(), )); } - self.deduplicate(&buf, max_dedupe_search)?; + self.deduplicate_by_hash(content_hash, max_dedupe_search)?; self .conn .execute( - "INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)", - params![buf, mime], + "INSERT INTO clipboard (contents, mime, content_hash) VALUES (?1, ?2, \ + ?3)", + params![buf, mime.map(|s| s.to_string()), content_hash], ) - .map_err(|e| StashError::Store(e.to_string()))?; + .map_err(|e| StashError::Store(e.to_string().into()))?; self.trim_db(max_items)?; Ok(self.next_sequence()) } - fn deduplicate(&self, buf: &[u8], max: u64) -> Result { + fn deduplicate_by_hash( + &self, + content_hash: i64, + max: u64, + ) -> Result { let mut stmt = self .conn - .prepare("SELECT id, contents FROM clipboard ORDER BY id DESC LIMIT ?1") - .map_err(|e| StashError::DeduplicationRead(e.to_string()))?; + .prepare( + "SELECT id FROM clipboard WHERE content_hash = ?1 ORDER BY id DESC \ + LIMIT ?2", + ) + .map_err(|e| StashError::DeduplicationRead(e.to_string().into()))?; let mut rows = stmt - .query(params![i64::try_from(max).unwrap_or(i64::MAX)]) - .map_err(|e| StashError::DeduplicationRead(e.to_string()))?; + .query(params![ + content_hash, + i64::try_from(max).unwrap_or(i64::MAX) + ]) + .map_err(|e| StashError::DeduplicationRead(e.to_string().into()))?; let mut deduped = 0; while let Some(row) = rows .next() - .map_err(|e| StashError::DeduplicationRead(e.to_string()))? + .map_err(|e| StashError::DeduplicationRead(e.to_string().into()))? { let id: u64 = row .get(0) - .map_err(|e| StashError::DeduplicationDecode(e.to_string()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::DeduplicationDecode(e.to_string()))?; - if contents == buf { - self - .conn - .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) - .map_err(|e| StashError::DeduplicationRemove(e.to_string()))?; - deduped += 1; - } + .map_err(|e| StashError::DeduplicationDecode(e.to_string().into()))?; + self + .conn + .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) + .map_err(|e| StashError::DeduplicationRemove(e.to_string().into()))?; + deduped += 1; } Ok(deduped) } @@ -264,7 +313,7 @@ impl ClipboardDb for SqliteClipboardDb { let count: u64 = self .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) - .map_err(|e| StashError::Trim(e.to_string()))?; + .map_err(|e| StashError::Trim(e.to_string().into()))?; if count > max { let to_delete = count - max; self @@ -274,7 +323,7 @@ impl ClipboardDb for SqliteClipboardDb { BY id ASC LIMIT ?1)", params![i64::try_from(to_delete).unwrap_or(i64::MAX)], ) - .map_err(|e| StashError::Trim(e.to_string()))?; + .map_err(|e| StashError::Trim(e.to_string().into()))?; } Ok(()) } @@ -288,12 +337,12 @@ impl ClipboardDb for SqliteClipboardDb { |row| row.get(0), ) .optional() - .map_err(|e| StashError::DeleteLast(e.to_string()))?; + .map_err(|e| StashError::DeleteLast(e.to_string().into()))?; if let Some(id) = id { self .conn .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) - .map_err(|e| StashError::DeleteLast(e.to_string()))?; + .map_err(|e| StashError::DeleteLast(e.to_string().into()))?; Ok(()) } else { Err(StashError::NoEntriesToDelete) @@ -304,11 +353,11 @@ impl ClipboardDb for SqliteClipboardDb { self .conn .execute("DELETE FROM clipboard", []) - .map_err(|e| StashError::Wipe(e.to_string()))?; + .map_err(|e| StashError::Wipe(e.to_string().into()))?; self .conn .execute("DELETE FROM sqlite_sequence WHERE name = 'clipboard'", []) - .map_err(|e| StashError::Wipe(e.to_string()))?; + .map_err(|e| StashError::Wipe(e.to_string().into()))?; Ok(()) } @@ -320,24 +369,26 @@ impl ClipboardDb for SqliteClipboardDb { let mut stmt = self .conn .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut listed = 0; + while let Some(row) = rows .next() - .map_err(|e| StashError::ListDecode(e.to_string()))? + .map_err(|e| StashError::ListDecode(e.to_string().into()))? { let id: u64 = row .get(0) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let contents: Vec = row .get(1) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mime: Option = row .get(2) - .map_err(|e| StashError::ListDecode(e.to_string()))?; + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let preview = preview_entry(&contents, mime.as_deref(), preview_width); if writeln!(out, "{id}\t{preview}").is_ok() { listed += 1; @@ -348,21 +399,22 @@ impl ClipboardDb for SqliteClipboardDb { fn decode_entry( &self, - mut in_: impl Read, + input: impl Read, mut out: impl Write, - input: Option, + id_hint: Option, ) -> Result<(), StashError> { - let s = if let Some(input) = input { - input + let input_str = if let Some(s) = id_hint { + s } else { + let mut input = BufReader::new(input); let mut buf = String::new(); - in_ + input .read_to_string(&mut buf) - .map_err(|e| StashError::DecodeRead(e.to_string()))?; + .map_err(|e| StashError::DecodeExtractId(e.to_string().into()))?; buf }; - let id = - extract_id(&s).map_err(|e| StashError::DecodeExtractId(e.to_string()))?; + let id = extract_id(&input_str) + .map_err(|e| StashError::DecodeExtractId(e.into()))?; let (contents, _mime): (Vec, Option) = self .conn .query_row( @@ -370,11 +422,11 @@ impl ClipboardDb for SqliteClipboardDb { params![id], |row| Ok((row.get(0)?, row.get(1)?)), ) - .map_err(|e| StashError::DecodeGet(e.to_string()))?; + .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; out .write_all(&contents) - .map_err(|e| StashError::DecodeWrite(e.to_string()))?; - info!("Decoded entry with id {id}"); + .map_err(|e| StashError::DecodeWrite(e.to_string().into()))?; + log::info!("Decoded entry with id {id}"); Ok(()) } @@ -382,26 +434,26 @@ impl ClipboardDb for SqliteClipboardDb { let mut stmt = self .conn .prepare("SELECT id, contents FROM clipboard") - .map_err(|e| StashError::QueryDelete(e.to_string()))?; + .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; let mut rows = stmt .query([]) - .map_err(|e| StashError::QueryDelete(e.to_string()))?; + .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; let mut deleted = 0; while let Some(row) = rows .next() - .map_err(|e| StashError::QueryDelete(e.to_string()))? + .map_err(|e| StashError::QueryDelete(e.to_string().into()))? { let id: u64 = row .get(0) - .map_err(|e| StashError::QueryDelete(e.to_string()))?; + .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; let contents: Vec = row .get(1) - .map_err(|e| StashError::QueryDelete(e.to_string()))?; + .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; if contents.windows(query.len()).any(|w| w == query.as_bytes()) { self .conn .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) - .map_err(|e| StashError::QueryDelete(e.to_string()))?; + .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; deleted += 1; } } @@ -416,7 +468,7 @@ impl ClipboardDb for SqliteClipboardDb { self .conn .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) - .map_err(|e| StashError::DeleteEntry(id, e.to_string()))?; + .map_err(|e| StashError::DeleteEntry(id, e.to_string().into()))?; deleted += 1; } } @@ -435,30 +487,36 @@ impl ClipboardDb for SqliteClipboardDb { } } -// Helper functions - /// Try to load a sensitive regex from systemd credential or env. /// /// # Returns +/// /// `Some(Regex)` if present and valid, `None` otherwise. fn load_sensitive_regex() -> Option { - if let Ok(regex_path) = env::var("CREDENTIALS_DIRECTORY") { - let file = format!("{regex_path}/clipboard_filter"); - if let Ok(contents) = fs::read_to_string(&file) { - if let Ok(re) = Regex::new(contents.trim()) { - return Some(re); + static REGEX_CACHE: OnceLock> = OnceLock::new(); + static CHECKED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + + if !CHECKED.load(std::sync::atomic::Ordering::Relaxed) { + CHECKED.store(true, std::sync::atomic::Ordering::Relaxed); + + let regex = if let Ok(regex_path) = env::var("CREDENTIALS_DIRECTORY") { + let file = format!("{regex_path}/clipboard_filter"); + if let Ok(contents) = fs::read_to_string(&file) { + Regex::new(contents.trim()).ok() + } else { + None } - } + } else if let Ok(pattern) = env::var("STASH_SENSITIVE_REGEX") { + Regex::new(&pattern).ok() + } else { + None + }; + + let _ = REGEX_CACHE.set(regex); } - // Fallback to an environment variable - if let Ok(pattern) = env::var("STASH_SENSITIVE_REGEX") { - if let Ok(re) = Regex::new(&pattern) { - return Some(re); - } - } - - None + REGEX_CACHE.get().and_then(std::clone::Clone::clone) } pub fn extract_id(input: &str) -> Result { @@ -466,35 +524,45 @@ pub fn extract_id(input: &str) -> Result { id_str.parse().map_err(|_| "invalid id") } +pub fn detect_mime_optimized(data: &[u8]) -> Option { + // Check if it's valid UTF-8 first, which most clipboard content are. + // This will be used to return early without unnecessary mimetype detection + // overhead. + if std::str::from_utf8(data).is_ok() { + return Some("text/plain".to_string()); + } + + // Only run image detection on binary data + detect_mime(data) +} + pub fn detect_mime(data: &[u8]) -> Option { if let Ok(img_type) = imagesize::image_type(data) { - Some( - 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(_) => "image/heif", - _ => "application/octet-stream", - } - .to_string(), - ) + let mime_str = 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(_) => "image/heif", + _ => "application/octet-stream", + }; + Some(mime_str.to_string()) } else { None } @@ -503,38 +571,54 @@ pub fn detect_mime(data: &[u8]) -> Option { pub fn preview_entry(data: &[u8], mime: Option<&str>, width: u32) -> String { if let Some(mime) = mime { if mime.starts_with("image/") { - if let Ok(ImageSize { - width: img_width, - height: img_height, - }) = imagesize::blob_size(data) - { - return format!( - "[[ binary data {} {} {}x{} ]]", - size_str(data.len()), - mime, - img_width, - img_height - ); - } + return format!("[[ binary data {} {} ]]", size_str(data.len()), mime); } else if mime == "application/json" || mime.starts_with("text/") { - let s = match str::from_utf8(data) { - Ok(s) => s, - Err(e) => { - error!("Failed to decode UTF-8 clipboard data: {e}"); - "" - }, + let Ok(s) = str::from_utf8(data) else { + return format!("[[ invalid UTF-8 {} ]]", size_str(data.len())); }; - let s = s.trim().replace(|c: char| c.is_whitespace(), " "); - return truncate(&s, width as usize, "…"); + + let trimmed = s.trim(); + if trimmed.len() <= width as usize + && !trimmed.chars().any(|c| c.is_whitespace() && c != ' ') + { + return trimmed.to_string(); + } + + // Only allocate new string if we need to replace whitespace + let mut result = String::with_capacity(width as usize + 1); + for (char_count, c) in trimmed.chars().enumerate() { + if char_count >= width as usize { + result.push('…'); + break; + } + + if c.is_whitespace() { + result.push(' '); + } else { + result.push(c); + } + } + return result; } } + + // For non-text data, use lossy conversion let s = String::from_utf8_lossy(data); truncate(s.trim(), width as usize, "…") } pub fn truncate(s: &str, max: usize, ellip: &str) -> String { - if s.chars().count() > max { - s.chars().take(max).collect::() + ellip + let char_count = s.chars().count(); + if char_count > max { + let mut result = String::with_capacity(max * 4 + ellip.len()); // UTF-8 worst case + let mut char_iter = s.chars(); + for _ in 0..max { + if let Some(c) = char_iter.next() { + result.push(c); + } + } + result.push_str(ellip); + result } else { s.to_string() } @@ -630,7 +714,7 @@ fn get_recently_active_excluded_app( let mut candidates = Vec::new(); - if let Ok(entries) = fs::read_dir(proc_dir) { + if let Ok(entries) = std::fs::read_dir(proc_dir) { for entry in entries.flatten() { if let Ok(pid) = entry.file_name().to_string_lossy().parse::() { if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) { @@ -729,9 +813,7 @@ fn get_process_activity_score(pid: u32) -> u64 { /// Check if an app name matches any in the exclusion list. /// Supports basic string matching and simple regex patterns. fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool { - debug!( - "Checking if '{app_name}' matches exclusion list: {excluded_apps:?}" - ); + debug!("Checking if '{app_name}' matches exclusion list: {excluded_apps:?}"); for excluded in excluded_apps { // Basic string matching (case-insensitive) @@ -753,9 +835,7 @@ fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool { let pattern = excluded.replace('*', ".*"); if let Ok(regex) = regex::Regex::new(&pattern) { if regex.is_match(app_name) { - debug!( - "Matched wildcard pattern: {app_name} matches {excluded}" - ); + debug!("Matched wildcard pattern: {app_name} matches {excluded}"); return true; } } diff --git a/src/main.rs b/src/main.rs index 2c12a80..cbeedd8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,7 +36,7 @@ struct Cli { /// Number of recent entries to check for duplicates when storing new /// clipboard data. - #[arg(long, default_value_t = 100)] + #[arg(long, default_value_t = 20)] max_dedupe_search: u64, /// Maximum width (in characters) for clipboard entry previews in list diff --git a/src/wayland/mod.rs b/src/wayland/mod.rs index 425d5ab..016d609 100644 --- a/src/wayland/mod.rs +++ b/src/wayland/mod.rs @@ -25,7 +25,7 @@ static TOPLEVEL_APPS: LazyLock>> = pub fn init_wayland_state() { std::thread::spawn(|| { if let Err(e) = run_wayland_event_loop() { - debug!("Wayland event loop error: {}", e); + debug!("Wayland event loop error: {e}"); } }); } @@ -35,7 +35,7 @@ pub fn get_focused_window_app() -> Option { // Try Wayland protocol first if let Ok(focused) = FOCUSED_APP.lock() { if let Some(ref app) = *focused { - debug!("Found focused app via Wayland protocol: {}", app); + debug!("Found focused app via Wayland protocol: {app}"); return Some(app.clone()); } } @@ -49,7 +49,7 @@ fn run_wayland_event_loop() -> Result<(), Box> { let conn = match WaylandConnection::connect_to_env() { Ok(conn) => conn, Err(e) => { - debug!("Failed to connect to Wayland: {}", e); + debug!("Failed to connect to Wayland: {e}"); return Ok(()); }, }; @@ -111,7 +111,7 @@ impl Dispatch for AppState { { // New toplevel created // We'll track it for focus events - let _handle: ZwlrForeignToplevelHandleV1 = toplevel; + let _: ZwlrForeignToplevelHandleV1 = toplevel; } } @@ -136,7 +136,7 @@ impl Dispatch for AppState { match event { zwlr_foreign_toplevel_handle_v1::Event::AppId { app_id } => { - debug!("Toplevel app_id: {}", 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); @@ -157,7 +157,7 @@ impl Dispatch for AppState { (TOPLEVEL_APPS.lock(), FOCUSED_APP.lock()) { if let Some(app_id) = apps.get(&handle_id) { - debug!("Setting focused app to: {}", app_id); + debug!("Setting focused app to: {app_id}"); *focused = Some(app_id.clone()); } } From 301a678f5605f3ca9aa8d374b38455fb95d82c81 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 19 Sep 2025 14:04:14 +0300 Subject: [PATCH 015/121] chore: release v0.3.1 Signed-off-by: NotAShelf Change-Id: I6a6a69646a11adf5dcf39f6caa79390d13662bfe --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4a7d1ac..ba0019a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1269,7 +1269,7 @@ dependencies = [ [[package]] name = "stash-clipboard" -version = "0.3.0" +version = "0.3.1" dependencies = [ "base64", "clap", diff --git a/Cargo.toml b/Cargo.toml index 9db55e7..553133c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stash-clipboard" description = "Wayland clipboard manager with fast persistent history and multi-media support" -version = "0.3.0" +version = "0.3.1" edition = "2024" authors = ["NotAShelf "] license = "MPL-2.0" From a70c7d7014e4757434e36622904de28b6b719567 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 19 Sep 2025 14:13:05 +0300 Subject: [PATCH 016/121] ci: add the missing nix installation step for release workflow Signed-off-by: NotAShelf Change-Id: I6a6a6964d99e447844d397624ae79d26e6e81817 --- .github/workflows/release.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index b101cb1..ab9d8a6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -15,6 +15,10 @@ jobs: - name: Checkout uses: actions/checkout@v5 + - uses: cachix/install-nix-action@v31 + with: + nix_path: nixpkgs=channel:nixos-unstable + - name: Read version run: | echo -n "stash_version=v" >> "$GITHUB_ENV" From 556e7d2ba11a85ab55131dc275c1734e0ffbe005 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 14:44:46 +0000 Subject: [PATCH 017/121] 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] --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ba0019a..9184795 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -255,9 +255,9 @@ checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" [[package]] name = "clap" -version = "4.5.47" +version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" +checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" dependencies = [ "clap_builder", "clap_derive", @@ -275,9 +275,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.47" +version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" +checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" dependencies = [ "anstream", "anstyle", diff --git a/Cargo.toml b/Cargo.toml index 553133c..a8c4be2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ default = ["use-toplevel"] use-toplevel = ["dep:wayland-client", "dep:wayland-protocols", "dep:wayland-protocols-wlr"] [dependencies] -clap = { version = "4.5.47", features = ["derive", "env"] } +clap = { version = "4.5.48", features = ["derive", "env"] } clap-verbosity-flag = "3.0.4" dirs = "6.0.0" imagesize = "0.14.0" From 8c95ec6051c6a41444531198e9fa79e8e5a493fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 14:44:58 +0000 Subject: [PATCH 018/121] 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] --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ba0019a..199d26d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1161,9 +1161,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.224" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aaeb1e94f53b16384af593c71e20b095e958dab1d26939c1b70645c5cfbcc0b" +checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" dependencies = [ "serde_core", "serde_derive", @@ -1171,18 +1171,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.224" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f39390fa6346e24defbcdd3d9544ba8a19985d0af74df8501fbfe9a64341ab" +checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.224" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ff78ab5e8561c9a675bfc1785cb07ae721f0ee53329a595cefd8c04c2ac4e0" +checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 553133c..4df90b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ thiserror = "2.0.16" wl-clipboard-rs = "0.9.2" rusqlite = { version = "0.37.0", features = ["bundled"] } smol = "2.0.2" -serde = { version = "1.0.224", features = ["derive"] } +serde = { version = "1.0.226", features = ["derive"] } serde_json = "1.0.145" base64 = "0.22.1" regex = "1.11.2" From b847460b3cc047b4b5600242b56d1ba3d11b764b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:39:49 +0000 Subject: [PATCH 019/121] 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] --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b8a248e..123b9b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1355,18 +1355,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.16" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.16" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 9e4d2b4..ae81fff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ inquire = { default-features = false, version = "0.9.1", features = [ ] } log = "0.4.28" env_logger = "0.11.8" -thiserror = "2.0.16" +thiserror = "2.0.17" wl-clipboard-rs = "0.9.2" rusqlite = { version = "0.37.0", features = ["bundled"] } smol = "2.0.2" From 23d585a34c22012a9bcbe94bba3eb2ae1a6f85be Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:41:27 +0000 Subject: [PATCH 020/121] 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] --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b8a248e..265c80b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1074,9 +1074,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.2" +version = "1.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" dependencies = [ "aho-corasick", "memchr", @@ -1086,9 +1086,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" dependencies = [ "aho-corasick", "memchr", diff --git a/Cargo.toml b/Cargo.toml index 9e4d2b4..3c15dec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ smol = "2.0.2" serde = { version = "1.0.226", features = ["derive"] } serde_json = "1.0.145" base64 = "0.22.1" -regex = "1.11.2" +regex = "1.11.3" ratatui = "0.29.0" crossterm = "0.29.0" unicode-segmentation = "1.12.0" From 3d0810c82406ef00c601a2aeaa53f161dc6b49e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 18:59:20 +0000 Subject: [PATCH 021/121] 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] --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b8a248e..c872b43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1161,9 +1161,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -1171,18 +1171,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 9e4d2b4..2209d41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ thiserror = "2.0.16" wl-clipboard-rs = "0.9.2" rusqlite = { version = "0.37.0", features = ["bundled"] } smol = "2.0.2" -serde = { version = "1.0.226", features = ["derive"] } +serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" base64 = "0.22.1" regex = "1.11.2" From 514572b8044817a0c48a9eb9c463e82ba07edc2a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 9 Oct 2025 09:18:44 +0300 Subject: [PATCH 022/121] nix: add a 'stash' package alias Signed-off-by: NotAShelf Change-Id: I6a6a69644d284206d2502da6f21997293fecd784 --- flake.nix | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/flake.nix b/flake.nix index 9be3b14..d7078d3 100644 --- a/flake.nix +++ b/flake.nix @@ -1,6 +1,8 @@ { - inputs.nixpkgs.url = "github:NixOS/nixpkgs?ref=nixos-unstable"; - inputs.crane.url = "github:ipetkov/crane"; + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs?ref=nixos-unstable"; + crane.url = "github:ipetkov/crane"; + }; outputs = { self, @@ -11,10 +13,11 @@ forEachSystem = nixpkgs.lib.genAttrs systems; pkgsForEach = nixpkgs.legacyPackages; in { - packages = forEachSystem (system: { - default = pkgsForEach.${system}.callPackage ./nix/package.nix { - craneLib = crane.mkLib pkgsForEach.${system}; - }; + 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: { From 4c0782f80e5e727d89dc75858522df0fcf41b6f8 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 9 Oct 2025 10:28:14 +0300 Subject: [PATCH 023/121] 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 Change-Id: I6a6a69642d0c13a1359b3b51125cc4b691cd5679 --- Cargo.lock | 569 ++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 4 +- src/commands/list.rs | 87 ++++++- src/main.rs | 2 +- 4 files changed, 651 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e474e9..cb0ea93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,6 +67,18 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-channel" version = "2.5.0" @@ -162,6 +174,17 @@ dependencies = [ "rustix 1.0.8", ] +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-signal" version = "0.2.12" @@ -186,6 +209,17 @@ version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -210,6 +244,15 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "blocking" version = "1.6.2" @@ -253,6 +296,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" version = "4.5.48" @@ -425,6 +474,15 @@ dependencies = [ "syn", ] +[[package]] +name = "deranged" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_more" version = "2.0.1" @@ -467,6 +525,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "document-features" version = "0.2.11" @@ -494,6 +562,33 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "endi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "env_filter" version = "0.1.3" @@ -670,6 +765,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "ident_case" version = "1.0.1" @@ -684,9 +785,9 @@ checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] name = "indexmap" -version = "2.10.0" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", "hashbrown", @@ -839,12 +940,33 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "mac-notification-sys" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119c8490084af61b44c9eda9d626475847a186737c0378c85e32d77c33a01cd4" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + [[package]] name = "memchr" version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -863,6 +985,19 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "7.1.3" @@ -873,6 +1008,65 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notify-rust" +version = "4.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -891,6 +1085,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_pipe" version = "1.2.2" @@ -998,6 +1202,21 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.97" @@ -1202,6 +1421,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1280,6 +1510,7 @@ dependencies = [ "imagesize", "inquire", "log", + "notify-rust", "ratatui", "regex", "rusqlite", @@ -1340,6 +1571,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml", + "thiserror", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.20.0" @@ -1373,6 +1616,86 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "toml_datetime" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3effe7c0e86fdff4f69cdd2ccc1b96f933e24811c5441d44904e8683e27184b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + [[package]] name = "tree_magic_mini" version = "3.2.0" @@ -1385,6 +1708,17 @@ dependencies = [ "petgraph", ] +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + [[package]] name = "unicode-ident" version = "1.0.18" @@ -1539,12 +1873,114 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -1585,7 +2021,7 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -1596,6 +2032,24 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -1692,6 +2146,15 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" @@ -1719,3 +2182,103 @@ dependencies = [ "wayland-protocols", "wayland-protocols-wlr", ] + +[[package]] +name = "zbus" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "nix", + "ordered-stream", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "windows-sys 0.60.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +dependencies = [ + "serde", + "static_assertions", + "winnow", + "zvariant", +] + +[[package]] +name = "zvariant" +version = "5.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "999dd3be73c52b1fccd109a4a81e4fcd20fab1d3599c8121b38d04e1419498db" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6643fd0b26a46d226bd90d3f07c1b5321fe9bb7f04673cb37ac6d6883885b68e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index 11fd7f2..bae6579 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,8 +14,9 @@ name = "stash" # actual binary name for Nix, Cargo, etc. path = "src/main.rs" [features] -default = ["use-toplevel"] +default = ["use-toplevel", "notifications"] use-toplevel = ["dep:wayland-client", "dep:wayland-protocols", "dep:wayland-protocols-wlr"] +notifications = ["dep:notify-rust"] [dependencies] clap = { version = "4.5.48", features = ["derive", "env"] } @@ -42,6 +43,7 @@ unicode-width = "0.2.0" wayland-client = { version = "0.31.11", optional = true } wayland-protocols = { version = "0.32.0", optional = true } wayland-protocols-wlr = { version = "0.3.9", optional = true } +notify-rust = { version = "4.11.7", optional = true } [profile.release] lto = true diff --git a/src/commands/list.rs b/src/commands/list.rs index 68a5b39..c35d870 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -26,7 +26,14 @@ impl SqliteClipboardDb { use std::io::stdout; use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, + event::{ + self, + DisableMouseCapture, + EnableMouseCapture, + Event, + KeyCode, + KeyModifiers, + }, execute, terminal::{ EnterAlternateScreen, @@ -35,6 +42,7 @@ impl SqliteClipboardDb { enable_raw_mode, }, }; + use notify_rust::Notification; use ratatui::{ Terminal, backend::CrosstermBackend, @@ -42,6 +50,7 @@ impl SqliteClipboardDb { text::{Line, Span}, widgets::{Block, Borders, List, ListItem, ListState}, }; + use wl_clipboard_rs::copy::{MimeType, Options, Source}; // Query entries from DB let mut stmt = self @@ -97,7 +106,10 @@ impl SqliteClipboardDb { .draw(|f| { let area = f.area(); let block = Block::default() - .title("Clipboard Entries (j/k/↑/↓ to move, q/ESC to quit)") + .title( + "Clipboard Entries (j/k/↑/↓ to move, Enter to copy, Shift+D \ + to delete, q/ESC to quit)", + ) .borders(Borders::ALL); let border_width = 2; @@ -234,9 +246,9 @@ impl SqliteClipboardDb { if let Event::Key(key) = event::read() .map_err(|e| StashError::ListDecode(e.to_string().into()))? { - match key.code { - KeyCode::Char('q') | KeyCode::Esc => break, - KeyCode::Down | KeyCode::Char('j') => { + match (key.code, key.modifiers) { + (KeyCode::Char('q') | KeyCode::Esc, _) => break, + (KeyCode::Down | KeyCode::Char('j'), _) => { let i = match state.selected() { Some(i) => { if i >= entries.len() - 1 { @@ -249,7 +261,7 @@ impl SqliteClipboardDb { }; state.select(Some(i)); }, - KeyCode::Up | KeyCode::Char('k') => { + (KeyCode::Up | KeyCode::Char('k'), _) => { let i = match state.selected() { Some(i) => { if i == 0 { @@ -262,6 +274,69 @@ impl SqliteClipboardDb { }; state.select(Some(i)); }, + (KeyCode::Enter, _) => { + if let Some(idx) = state.selected() { + if let Some((id, ..)) = entries.get(idx) { + // Fetch full contents for the selected entry + let (contents, mime): (Vec, Option) = self + .conn + .query_row( + "SELECT contents, mime FROM clipboard WHERE id = ?1", + rusqlite::params![id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|e| { + StashError::ListDecode(e.to_string().into()) + })?; + // Copy to clipboard + let opts = Options::new(); + // Default clipboard is regular, seat is default + let mime_type = match mime { + Some(ref m) if m == "text/plain" => MimeType::Text, + Some(ref m) => MimeType::Specific(m.clone()), + None => MimeType::Text, + }; + let _ = opts + .copy(Source::Bytes(contents.clone().into()), mime_type); + // Show notification + let _ = Notification::new() + .summary("Stash") + .body("Copied entry to clipboard") + .show(); + } + } + }, + (KeyCode::Char('D'), KeyModifiers::SHIFT) => { + if let Some(idx) = state.selected() { + if let Some((id, ..)) = entries.get(idx) { + // Delete entry from DB + self + .conn + .execute( + "DELETE FROM clipboard WHERE id = ?1", + rusqlite::params![id], + ) + .map_err(|e| { + StashError::DeleteEntry(*id, e.to_string().into()) + })?; + // Remove from entries and update selection + entries.remove(idx); + let new_len = entries.len(); + if new_len == 0 { + state.select(None); + } else if idx >= new_len { + state.select(Some(new_len - 1)); + } else { + state.select(Some(idx)); + } + // Show notification + let _ = Notification::new() + .summary("Stash") + .body("Deleted entry") + .show(); + } + } + }, _ => {}, } } diff --git a/src/main.rs b/src/main.rs index cbeedd8..7a99a40 100644 --- a/src/main.rs +++ b/src/main.rs @@ -110,7 +110,7 @@ enum Command { ask: bool, }, - /// Watch clipboard for changes and store automatically + /// Start a process to watch clipboard for changes and store automatically. Watch, } From d8b1ac1f37163d97fd8f5a03d9377a111382770c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 9 Oct 2025 11:29:34 +0300 Subject: [PATCH 024/121] list: properly error notification if clipboard copy fails Signed-off-by: NotAShelf Change-Id: I6a6a696459d7fbc344545daeead6164cad5cde6f --- src/commands/list.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index c35d870..c20b0a0 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -296,13 +296,22 @@ impl SqliteClipboardDb { Some(ref m) => MimeType::Specific(m.clone()), None => MimeType::Text, }; - let _ = opts + let copy_result = opts .copy(Source::Bytes(contents.clone().into()), mime_type); - // Show notification - let _ = Notification::new() - .summary("Stash") - .body("Copied entry to clipboard") - .show(); + match copy_result { + Ok(()) => { + let _ = Notification::new() + .summary("Stash") + .body("Copied entry to clipboard") + .show(); + }, + Err(e) => { + let _ = Notification::new() + .summary("Stash") + .body(&format!("Failed to copy entry: {e}")) + .show(); + }, + } } } }, From f8440926b1f1349d626e499ccb7c462f8a35bfbb Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 9 Oct 2025 11:45:22 +0300 Subject: [PATCH 025/121] list: log clipboard copy errors and update notification message Signed-off-by: NotAShelf Change-Id: I6a6a69649bdd37a44f254d520e33a54634958ada --- src/commands/list.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index c20b0a0..c2be9c1 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -306,9 +306,10 @@ impl SqliteClipboardDb { .show(); }, Err(e) => { + log::error!("Failed to copy entry to clipboard: {e}"); let _ = Notification::new() .summary("Stash") - .body(&format!("Failed to copy entry: {e}")) + .body(&format!("Failed to copy to clipboard: {e}")) .show(); }, } From 6496d3963d62b05abc1b77a993dcc903f2f7d29b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 9 Oct 2025 12:32:37 +0300 Subject: [PATCH 026/121] 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 Change-Id: I6a6a6964463b35427cb720fbab68b252944cc90c --- .gitignore | 1 + build.rs | 56 +++++++++ src/main.rs | 8 ++ src/multicall.rs | 288 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 353 insertions(+) create mode 100644 build.rs create mode 100644 src/multicall.rs diff --git a/.gitignore b/.gitignore index 92ecf03..ad467a2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ # Rust/Cargo !/Cargo.lock !/Cargo.toml +!/build.rs # Configuration files !/.config/ diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..533368c --- /dev/null +++ b/build.rs @@ -0,0 +1,56 @@ +use std::{env, fs, path::Path}; + +/// List of multicall symlinks to create (name, target) +const MULTICALL_LINKS: &[&str] = + &["stash-copy", "stash-paste", "wl-copy", "wl-paste"]; + +fn main() { + // Only run on Unix-like systems + #[cfg(not(unix))] + { + println!( + "cargo:warning=Multicall symlinks are only supported on Unix-like \ + systems." + ); + return; + } + + // OUT_DIR is something like .../target/debug/build//out + // We want .../target/debug or .../target/release + let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set"); + let bin_dir = Path::new(&out_dir) + .ancestors() + .nth(3) + .expect("Failed to find binary dir"); + + // Path to the main stash binary + let stash_bin = bin_dir.join("stash"); + + // Create symlinks for each multicall binary + for link in MULTICALL_LINKS { + let link_path = bin_dir.join(link); + // Remove existing symlink or file if present + let _ = fs::remove_file(&link_path); + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + match symlink(&stash_bin, &link_path) { + Ok(()) => { + println!( + "cargo:warning=Created symlink: {} -> {}", + link_path.display(), + stash_bin.display() + ); + }, + Err(e) => { + println!( + "cargo:warning=Failed to create symlink {} -> {}: {}", + link_path.display(), + stash_bin.display(), + e + ); + }, + } + } + } +} diff --git a/src/main.rs b/src/main.rs index 7a99a40..a81540a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ use inquire::Confirm; mod commands; mod db; +mod multicall; #[cfg(feature = "use-toplevel")] mod wayland; use crate::commands::{ @@ -129,6 +130,13 @@ fn report_error( #[allow(clippy::too_many_lines)] // whatever fn main() { + // Multicall dispatch: stash-copy, stash-paste, wl-copy, wl-paste + if crate::multicall::multicall_dispatch() { + // If handled, exit immediately + std::process::exit(0); + } + + // If not multicall, proceed with normal CLI handling smol::block_on(async { let cli = Cli::parse(); env_logger::Builder::new() diff --git a/src/multicall.rs b/src/multicall.rs new file mode 100644 index 0000000..a7185ec --- /dev/null +++ b/src/multicall.rs @@ -0,0 +1,288 @@ +use std::io::{self, Read, Write}; + +use clap::{ArgAction, Parser}; +use wl_clipboard_rs::paste::{ + ClipboardType, + Error, + MimeType, + Seat, + get_contents, +}; + +/// Dispatch multicall binary logic based on argv[0]. +/// Returns true if a multicall command was handled and the process should exit. +pub fn multicall_dispatch() -> bool { + let argv0 = std::env::args().next().unwrap_or_default(); + let base = std::path::Path::new(&argv0) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(""); + match base { + "stash-copy" | "wl-copy" => { + multicall_stash_copy(); + true + }, + "stash-paste" | "wl-paste" => { + multicall_stash_paste(); + true + }, + _ => false, + } +} + +#[allow(clippy::too_many_lines)] +fn multicall_stash_copy() { + use clap::{ArgAction, Parser}; + use wl_clipboard_rs::{ + copy::{ClipboardType, MimeType, Options, ServeRequests, Source}, + utils::{PrimarySelectionCheckError, is_primary_selection_supported}, + }; + #[derive(Parser, Debug)] + #[command( + name = "stash-copy", + about = "Copy clipboard contents on Wayland.", + version, + disable_help_subcommand = true + )] + #[allow(clippy::struct_excessive_bools)] + struct Args { + /// Serve only a single paste request and then exit + #[arg(short = 'o', long = "paste-once", action = ArgAction::SetTrue)] + paste_once: bool, + /// Stay in the foreground instead of forking + #[arg(short = 'f', long = "foreground", action = ArgAction::SetTrue)] + foreground: bool, + /// Clear the clipboard instead of copying + #[arg(short = 'c', long = "clear", action = ArgAction::SetTrue)] + clear: bool, + /// Use the \"primary\" clipboard + #[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)] + primary: bool, + /// Use the regular clipboard + #[arg(short = 'r', long = "regular", action = ArgAction::SetTrue)] + regular: bool, + /// Trim the trailing newline character before copying + #[arg(short = 'n', long = "trim-newline", action = ArgAction::SetTrue)] + trim_newline: bool, + /// Pick the seat to work with + #[arg(short = 's', long = "seat")] + seat: Option, + /// Override the inferred MIME type for the content + #[arg(short = 't', long = "type")] + mime_type: Option, + /// 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, + /// Text to copy (if not given, read from stdin) + #[arg(value_name = "TEXT TO COPY", action = ArgAction::Append)] + text: Vec, + } + + let args = Args::parse(); + + if args.check_primary { + match is_primary_selection_supported() { + Ok(true) => { + log::info!("Primary selection is supported."); + std::process::exit(0); + }, + Ok(false) => { + log::info!("Primary selection is NOT supported."); + std::process::exit(1); + }, + Err(PrimarySelectionCheckError::NoSeats) => { + log::error!("Could not determine: no seats available."); + std::process::exit(2); + }, + Err(PrimarySelectionCheckError::MissingProtocol) => { + log::error!("Data-control protocol not supported by compositor."); + std::process::exit(3); + }, + Err(e) => { + log::error!("Error checking primary selection support: {e}"); + std::process::exit(4); + }, + } + } + + let clipboard = if args.primary { + ClipboardType::Primary + } else { + ClipboardType::Regular + }; + + let mime_type = if let Some(mt) = args.mime_type.as_deref() { + if mt == "text" || mt == "text/plain" { + MimeType::Text + } else if mt == "autodetect" { + MimeType::Autodetect + } else { + MimeType::Specific(mt.to_string()) + } + } else { + MimeType::Autodetect + }; + + let mut input: Vec = Vec::new(); + if args.text.is_empty() { + if let Err(e) = std::io::stdin().read_to_end(&mut input) { + eprintln!("stash-copy: failed to read stdin: {e}"); + std::process::exit(1); + } + } else { + input = args.text.join(" ").into_bytes(); + } + + let mut opts = Options::new(); + opts.clipboard(clipboard); + + if args.trim_newline { + opts.trim_newline(true); + } + if args.foreground { + opts.foreground(true); + } + if let Some(seat) = args.seat.as_deref() { + log::debug!( + "stash-copy: --seat is not supported by stash (using default seat: \ + {seat})" + ); + } + if args.omit_additional_text_mime_types { + opts.omit_additional_text_mime_types(true); + } + // --paste-once overrides serve-requests + if args.paste_once { + opts.serve_requests(ServeRequests::Only(1)); + } else if let Some(n) = args.serve_requests { + opts.serve_requests(ServeRequests::Only(n)); + } + // --clear + if args.clear { + // Clear clipboard by setting empty contents + if let Err(e) = opts.copy(Source::Bytes(Vec::new().into()), mime_type) { + log::error!("stash-copy: failed to clear clipboard: {e}"); + std::process::exit(1); + } + return; + } + if let Err(e) = opts.copy(Source::Bytes(input.into()), mime_type) { + log::error!("stash-copy: failed to copy to clipboard: {e}"); + std::process::exit(1); + } +} + +fn multicall_stash_paste() { + #[derive(Parser, Debug)] + #[command( + name = "stash-paste", + about = "Paste clipboard contents on Wayland.", + version, + disable_help_subcommand = true + )] + struct Args { + /// List the offered MIME types instead of pasting + #[arg(short = 'l', long = "list-types", action = ArgAction::SetTrue)] + list_types: bool, + /// Use the "primary" clipboard + #[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)] + primary: bool, + /// Do not append a newline character + #[arg(short = 'n', long = "no-newline", action = ArgAction::SetTrue)] + no_newline: bool, + /// Pick the seat to work with + #[arg(short = 's', long = "seat")] + seat: Option, + /// Request the given MIME type instead of inferring the MIME type + #[arg(short = 't', long = "type")] + mime_type: Option, + /// Enable verbose logging + #[arg(short = 'v', long = "verbose", action = ArgAction::Count)] + verbose: u8, + } + + let args = Args::parse(); + + let clipboard = if args.primary { + ClipboardType::Primary + } else { + ClipboardType::Regular + }; + + if let Some(seat) = args.seat.as_deref() { + log::debug!( + "stash-paste: --seat is not supported by stash (using default seat: \ + {seat})" + ); + } + + if args.list_types { + match get_contents(clipboard, Seat::Unspecified, MimeType::Text) { + Ok((_reader, available_types)) => { + print!("{available_types}"); + std::process::exit(0); + }, + Err(e) => { + log::error!("stash-paste: failed to list types: {e}"); + std::process::exit(1); + }, + } + } + + let mime_type = match args.mime_type.as_deref() { + None | Some("text" | "autodetect") => MimeType::Text, + Some(other) => MimeType::Specific(other), + }; + + match get_contents(clipboard, Seat::Unspecified, mime_type) { + Ok((mut reader, _types)) => { + let mut out = io::stdout(); + let mut buf = Vec::new(); + match reader.read_to_end(&mut buf) { + Ok(n) => { + if n == 0 && args.no_newline { + std::process::exit(1); + } + let _ = out.write_all(&buf); + if !args.no_newline && !buf.ends_with(b"\n") { + let _ = out.write_all(b"\n"); + } + }, + Err(e) => { + log::error!("stash-paste: failed to read clipboard: {e}"); + std::process::exit(1); + }, + } + }, + Err(Error::NoSeats) => { + log::error!( + "stash-paste: no seats available (is a Wayland compositor running?)" + ); + std::process::exit(1); + }, + Err(Error::ClipboardEmpty) => { + if args.no_newline { + std::process::exit(1); + } + }, + Err(Error::NoMimeType) => { + log::error!( + "stash-paste: clipboard does not contain requested MIME type" + ); + std::process::exit(1); + }, + Err(e) => { + log::error!("stash-paste: clipboard error: {e}"); + std::process::exit(1); + }, + } +} From 78fa23a764266cb9c0eec0404f1651646a20cd3d Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 9 Oct 2025 14:44:29 +0300 Subject: [PATCH 027/121] multicall: remove program name prefixes from log and error messages Signed-off-by: NotAShelf Change-Id: I6a6a6964f65a0f1e473a50abfa985365ad8f1fa1 --- src/main.rs | 2 +- src/multicall.rs | 38 ++++++++++++++++---------------------- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/src/main.rs b/src/main.rs index a81540a..f5c6b2e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -130,7 +130,7 @@ fn report_error( #[allow(clippy::too_many_lines)] // whatever fn main() { - // Multicall dispatch: stash-copy, stash-paste, wl-copy, wl-paste + // Multicall dispatch: stash-copy, stash-paste, wl-copy, wl-paste if crate::multicall::multicall_dispatch() { // If handled, exit immediately std::process::exit(0); diff --git a/src/multicall.rs b/src/multicall.rs index a7185ec..f387df0 100644 --- a/src/multicall.rs +++ b/src/multicall.rs @@ -92,23 +92,23 @@ fn multicall_stash_copy() { if args.check_primary { match is_primary_selection_supported() { Ok(true) => { - log::info!("Primary selection is supported."); + log::info!("primary selection is supported."); std::process::exit(0); }, Ok(false) => { - log::info!("Primary selection is NOT supported."); + log::info!("primary selection is NOT supported."); std::process::exit(1); }, Err(PrimarySelectionCheckError::NoSeats) => { - log::error!("Could not determine: no seats available."); + log::error!("could not determine: no seats available."); std::process::exit(2); }, Err(PrimarySelectionCheckError::MissingProtocol) => { - log::error!("Data-control protocol not supported by compositor."); + log::error!("data-control protocol not supported by compositor."); std::process::exit(3); }, Err(e) => { - log::error!("Error checking primary selection support: {e}"); + log::error!("error checking primary selection support: {e}"); std::process::exit(4); }, } @@ -135,7 +135,7 @@ fn multicall_stash_copy() { let mut input: Vec = Vec::new(); if args.text.is_empty() { if let Err(e) = std::io::stdin().read_to_end(&mut input) { - eprintln!("stash-copy: failed to read stdin: {e}"); + eprintln!("failed to read stdin: {e}"); std::process::exit(1); } } else { @@ -153,8 +153,7 @@ fn multicall_stash_copy() { } if let Some(seat) = args.seat.as_deref() { log::debug!( - "stash-copy: --seat is not supported by stash (using default seat: \ - {seat})" + "'--seat' is not supported by stash (using default seat: {seat})" ); } if args.omit_additional_text_mime_types { @@ -170,13 +169,13 @@ fn multicall_stash_copy() { if args.clear { // Clear clipboard by setting empty contents if let Err(e) = opts.copy(Source::Bytes(Vec::new().into()), mime_type) { - log::error!("stash-copy: failed to clear clipboard: {e}"); + log::error!("failed to clear clipboard: {e}"); std::process::exit(1); } return; } if let Err(e) = opts.copy(Source::Bytes(input.into()), mime_type) { - log::error!("stash-copy: failed to copy to clipboard: {e}"); + log::error!("failed to copy to clipboard: {e}"); std::process::exit(1); } } @@ -220,19 +219,18 @@ fn multicall_stash_paste() { if let Some(seat) = args.seat.as_deref() { log::debug!( - "stash-paste: --seat is not supported by stash (using default seat: \ - {seat})" + "'--seat' is not supported by stash (using default seat: {seat})" ); } if args.list_types { match get_contents(clipboard, Seat::Unspecified, MimeType::Text) { Ok((_reader, available_types)) => { - print!("{available_types}"); + log::info!("{available_types}"); std::process::exit(0); }, Err(e) => { - log::error!("stash-paste: failed to list types: {e}"); + log::error!("failed to list types: {e}"); std::process::exit(1); }, } @@ -258,15 +256,13 @@ fn multicall_stash_paste() { } }, Err(e) => { - log::error!("stash-paste: failed to read clipboard: {e}"); + log::error!("failed to read clipboard: {e}"); std::process::exit(1); }, } }, Err(Error::NoSeats) => { - log::error!( - "stash-paste: no seats available (is a Wayland compositor running?)" - ); + log::error!("no seats available (is a Wayland compositor running?)"); std::process::exit(1); }, Err(Error::ClipboardEmpty) => { @@ -275,13 +271,11 @@ fn multicall_stash_paste() { } }, Err(Error::NoMimeType) => { - log::error!( - "stash-paste: clipboard does not contain requested MIME type" - ); + log::error!("clipboard does not contain requested MIME type"); std::process::exit(1); }, Err(e) => { - log::error!("stash-paste: clipboard error: {e}"); + log::error!("clipboard error: {e}"); std::process::exit(1); }, } From c2427c138aebcd5392edf5f0a26d6bc6e6895002 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 9 Oct 2025 14:49:37 +0300 Subject: [PATCH 028/121] docs: mention multicall exports Signed-off-by: NotAShelf Change-Id: I6a6a6964c5f2d863774a214ab54bc17caa7c9bbb --- README.md | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 66354c8..a974185 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,13 @@
- + Build Status - + Dependency Status [!TIP] +> Stash provides `wl-copy` and `wl-paste` binaries for backwards compatibility +> with the `wl-clipboard` tools. If _must_ depend on those binaries by name, you +> may simply use the `wl-copy` and `wl-paste` provided as `wl-clipboard-rs` +> wrappers on your system. In other words, you can use +> `wl-paste --watch stash store` as an alternative to `stash watch` if +> preferred. + ### Options Some commands take additional flags to modify Stash's behavior. See each @@ -263,7 +272,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 @@ -316,3 +325,9 @@ figured out something new, e.g. a neat shell trick, feel free to add it here! ```bash cliphist list --db ~/.cache/cliphist/db | stash import ``` + +## 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/). From a59e207e76d48c6d016c7b8fe13234d864da6532 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 9 Oct 2025 15:28:51 +0300 Subject: [PATCH 029/121] ci: trigger Nix cache action more often Signed-off-by: NotAShelf Change-Id: I6a6a696463b203052b421a924fb85885fc34752b --- .github/workflows/nix-cache.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nix-cache.yaml b/.github/workflows/nix-cache.yaml index 2ddd98a..2ffb411 100644 --- a/.github/workflows/nix-cache.yaml +++ b/.github/workflows/nix-cache.yaml @@ -1,10 +1,10 @@ -name: "Populate cachix cache" +name: Build and Cache with Nix on: workflow_dispatch: push: branches: [ "main" ] - paths: [ 'src/**.rs', 'Cargo.toml', 'Cargo.lock', 'nix/package.nix' ] + paths: [ 'src/**.rs', 'build.rs', 'Cargo.toml', 'Cargo.lock', 'nix/package.nix', 'flake.nix', 'flake.lock' ] permissions: contents: read From a94ef7f5b4ec49b3b0fc75f985b6820a16aeeea0 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 14 Oct 2025 08:39:35 +0300 Subject: [PATCH 030/121] nix: install multicall binaries in `postInstall` Signed-off-by: NotAShelf Change-Id: I6a6a69646b6afeb1d9dda8d16e00f6f39b8046ff --- nix/package.nix | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/nix/package.nix b/nix/package.nix index 0366838..3c32991 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -1,6 +1,7 @@ { lib, craneLib, + versionCheckHook, }: let pname = "stash"; version = (builtins.fromTOML (builtins.readFile ../Cargo.toml)).package.version; @@ -14,6 +15,7 @@ (fs.fileFilter (file: builtins.any file.hasExt ["rs"]) (s + /src)) (s + /Cargo.lock) (s + /Cargo.toml) + (s + /build.rs) ]; }; @@ -28,11 +30,32 @@ in strictDeps = true; + # Whether cargo's target directory should be copied as an output + doInstallCargoArtifacts = true; + # Install Systemd service for Stash into $out/share. # This can be used to use Stash in 'systemd.packages' postInstall = '' mkdir -p $out install -Dm755 ${../vendor/stash.service} $out/share/stash.service + + # 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*. + 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 the versions of each. + postInstallCheck = '' + for bin in stash stash-copy stash-paste wl-copy wl-paste; do + [ -x "$out/bin/$bin" ] || { echo "$bin missing"; exit 1; } + done ''; meta = { From 0a8fda66a0ff51f6b1e590f86f84db3a0f3ae10b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 14 Oct 2025 08:40:30 +0300 Subject: [PATCH 031/121] chore: bump dependencies Signed-off-by: NotAShelf Change-Id: I6a6a6964fc9d82b2ca85798ea4dda196e0a25e33 --- Cargo.lock | 301 +++++++++++++++++++++++++++++------------------------ 1 file changed, 164 insertions(+), 137 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cb0ea93..51c0bf0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,9 +19,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "anstream" -version = "0.6.20" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -34,9 +34,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.11" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" @@ -93,9 +93,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb812ffb58524bdd10860d7d974e2f01cc0950c2438a74ee5ec2e2280c6c4ffa" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" dependencies = [ "async-task", "concurrent-queue", @@ -107,9 +107,9 @@ dependencies = [ [[package]] name = "async-fs" -version = "2.1.3" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f7e37c0ed80b2a977691c47dae8625cfb21e205827106c64f7c588766b2e50" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" dependencies = [ "async-lock", "blocking", @@ -118,20 +118,20 @@ dependencies = [ [[package]] name = "async-io" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19634d6336019ef220f09fd31168ce5c184b295cbf80345437cc36094ef223ca" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" dependencies = [ - "async-lock", + "autocfg", "cfg-if", "concurrent-queue", "futures-io", "futures-lite", "parking", "polling", - "rustix 1.0.8", + "rustix 1.1.2", "slab", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -158,9 +158,9 @@ dependencies = [ [[package]] name = "async-process" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65daa13722ad51e6ab1a1b9c01299142bc75135b337923cfa10e79bbbd669f00" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" dependencies = [ "async-channel", "async-io", @@ -171,7 +171,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.0.8", + "rustix 1.1.2", ] [[package]] @@ -187,9 +187,9 @@ dependencies = [ [[package]] name = "async-signal" -version = "0.2.12" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f567af260ef69e1d52c2b560ce0ea230763e6fbb9214a85d768760a920e3e3c1" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" dependencies = [ "async-io", "async-lock", @@ -197,10 +197,10 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.0.8", + "rustix 1.1.2", "signal-hook-registry", "slab", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -240,9 +240,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" [[package]] name = "block2" @@ -283,18 +283,19 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.32" +version = "1.2.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2352e5597e9c544d5e6d9c95190d5d27738ade584fa8db0a16e130e5c2b5296e" +checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" dependencies = [ + "find-msvc-tools", "shlex", ] [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "cfg_aliases" @@ -304,9 +305,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.5.48" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" +checksum = "f4512b90fa68d3a9932cea5184017c5d200f5921df706d45e853537dea51508f" dependencies = [ "clap_builder", "clap_derive", @@ -324,9 +325,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.48" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" +checksum = "0025e98baa12e766c67ba13ff4695a887a1eba19569aad00a472546795bd6730" dependencies = [ "anstream", "anstyle", @@ -336,9 +337,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.47" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck", "proc-macro2", @@ -348,9 +349,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "colorchoice" @@ -424,7 +425,7 @@ dependencies = [ "document-features", "mio", "parking_lot", - "rustix 1.0.8", + "rustix 1.1.2", "signal-hook", "signal-hook-mio", "winapi", @@ -522,7 +523,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -591,9 +592,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" dependencies = [ "log", "regex", @@ -620,12 +621,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -667,6 +668,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + [[package]] name = "fixedbitset" version = "0.4.2" @@ -730,7 +737,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasi 0.14.7+wasi-0.2.4", ] [[package]] @@ -744,13 +751,19 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + [[package]] name = "hashlink" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown", + "hashbrown 0.15.5", ] [[package]] @@ -790,7 +803,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.0", ] [[package]] @@ -872,15 +885,15 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.175" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libredox" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ "bitflags", "libc", @@ -905,9 +918,9 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litrs" @@ -917,11 +930,10 @@ checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" [[package]] name = "lock_api" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] @@ -937,7 +949,7 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown", + "hashbrown 0.15.5", ] [[package]] @@ -954,9 +966,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.5" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memoffset" @@ -1097,12 +1109,12 @@ dependencies = [ [[package]] name = "os_pipe" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db335f4760b14ead6290116f2427bf33a14d4f0617d49f78a246de10c1831224" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1113,9 +1125,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -1123,15 +1135,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-link 0.2.1", ] [[package]] @@ -1175,16 +1187,16 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "polling" -version = "3.10.0" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5bd19146350fe804f7cb2669c851c03d69da628803dab0d98018142aaa5d829" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.0.8", - "windows-sys 0.60.2", + "rustix 1.1.2", + "windows-sys 0.61.2", ] [[package]] @@ -1219,9 +1231,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.97" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d61789d7719defeb74ea5fe81f2fdfdbd28a803847077cecce2ff14e1472f6f1" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" dependencies = [ "unicode-ident", ] @@ -1237,9 +1249,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.40" +version = "1.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" dependencies = [ "proc-macro2", ] @@ -1273,9 +1285,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.17" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ "bitflags", ] @@ -1293,9 +1305,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.3" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", @@ -1305,9 +1317,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", @@ -1316,9 +1328,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "rusqlite" @@ -1349,15 +1361,15 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.8" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys 0.9.4", - "windows-sys 0.60.2", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", ] [[package]] @@ -1562,9 +1574,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.105" +version = "2.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bc3fcb250e53458e712715cf74285c1f889686520d79294a9ef3bd7aa1fc619" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" dependencies = [ "proc-macro2", "quote", @@ -1585,15 +1597,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.20.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix 1.0.8", - "windows-sys 0.59.0", + "rustix 1.1.2", + "windows-sys 0.61.2", ] [[package]] @@ -1637,18 +1649,18 @@ checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "toml_datetime" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.6" +version = "0.23.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3effe7c0e86fdff4f69cdd2ccc1b96f933e24811c5441d44904e8683e27184b" +checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" dependencies = [ "indexmap", "toml_datetime", @@ -1658,9 +1670,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ "winnow", ] @@ -1721,9 +1733,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "unicode-segmentation" @@ -1774,11 +1786,20 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.2+wasi-0.2.4" +version = "0.14.7+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" dependencies = [ - "wit-bindgen-rt", + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", ] [[package]] @@ -1789,7 +1810,7 @@ checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" dependencies = [ "cc", "downcast-rs", - "rustix 1.0.8", + "rustix 1.1.2", "smallvec", "wayland-sys", ] @@ -1801,7 +1822,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ "bitflags", - "rustix 1.0.8", + "rustix 1.1.2", "wayland-backend", "wayland-scanner", ] @@ -1996,7 +2017,16 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.3", + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", ] [[package]] @@ -2017,19 +2047,19 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.3" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.1.3", - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -2058,9 +2088,9 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" @@ -2070,9 +2100,9 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" @@ -2082,9 +2112,9 @@ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" @@ -2094,9 +2124,9 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" @@ -2106,9 +2136,9 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" @@ -2118,9 +2148,9 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" @@ -2130,9 +2160,9 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" @@ -2142,9 +2172,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" @@ -2156,13 +2186,10 @@ dependencies = [ ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags", -] +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "wl-clipboard-rs" From a9da424e702f3e6f9a13b327ba2200b610dec6f1 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 14 Oct 2025 08:47:39 +0300 Subject: [PATCH 032/121] chore: release v0.3.2 Signed-off-by: NotAShelf Change-Id: I6a6a6964fc36c56f505e7a679727e1f4f7d7095c --- Cargo.lock | 2 +- Cargo.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 51c0bf0..0cad45c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1511,7 +1511,7 @@ dependencies = [ [[package]] name = "stash-clipboard" -version = "0.3.1" +version = "0.3.2" dependencies = [ "base64", "clap", diff --git a/Cargo.toml b/Cargo.toml index bae6579..ab80a8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stash-clipboard" description = "Wayland clipboard manager with fast persistent history and multi-media support" -version = "0.3.1" +version = "0.3.2" edition = "2024" authors = ["NotAShelf "] license = "MPL-2.0" @@ -39,7 +39,7 @@ regex = "1.11.3" ratatui = "0.29.0" crossterm = "0.29.0" unicode-segmentation = "1.12.0" -unicode-width = "0.2.0" +unicode-width = "0.2.0" # FIXME: held back by ratatui wayland-client = { version = "0.31.11", optional = true } wayland-protocols = { version = "0.32.0", optional = true } wayland-protocols-wlr = { version = "0.3.9", optional = true } From b50702480f6a8b6fbf4a7983287278fb6f4ca1a0 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 15 Oct 2025 14:22:56 +0300 Subject: [PATCH 033/121] meta: rename 'vendor' to contrib; don't vendor service in Nix derivation Signed-off-by: NotAShelf Change-Id: I6a6a696479e976a7f4db18e6501e347a4940ce28 --- .gitignore | 2 +- README.md | 7 ++++--- {vendor => contrib}/stash.service | 0 nix/package.nix | 16 +++++----------- 4 files changed, 10 insertions(+), 15 deletions(-) rename {vendor => contrib}/stash.service (100%) diff --git a/.gitignore b/.gitignore index ad467a2..99b71d1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ !/nix !/src -!/vendor +!/contrib # Rust/Cargo !/Cargo.lock diff --git a/README.md b/README.md index a974185..caa0a3d 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ stash watch This runs a daemon that monitors the clipboard and stores new entries 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 `vendor/`. Packagers are encouraged to vendor the +premade Systemd service in `contrib/`. Packagers are encouraged to vendor the service unless adding their own. > [!TIP] @@ -216,8 +216,9 @@ 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. Remember to -set the appropriate file permissions if using this option. +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 diff --git a/vendor/stash.service b/contrib/stash.service similarity index 100% rename from vendor/stash.service rename to contrib/stash.service diff --git a/nix/package.nix b/nix/package.nix index 3c32991..8ca3d8e 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -30,18 +30,12 @@ in strictDeps = true; - # Whether cargo's target directory should be copied as an output - doInstallCargoArtifacts = true; - - # Install Systemd service for Stash into $out/share. - # This can be used to use Stash in 'systemd.packages' + # 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 = '' mkdir -p $out - install -Dm755 ${../vendor/stash.service} $out/share/stash.service - - # 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*. for bin in stash-copy stash-paste wl-copy wl-paste; do ln -sf $out/bin/stash $out/bin/$bin done @@ -51,7 +45,7 @@ in doInstallCheck = true; # After the version check, let's see if all binaries are linked correctly. - # We could probably add a check phase to the versions of each. + # We could probably add a check phase to get the versions of each. postInstallCheck = '' for bin in stash stash-copy stash-paste wl-copy wl-paste; do [ -x "$out/bin/$bin" ] || { echo "$bin missing"; exit 1; } From d3911dd81a3c27b0403098ad140afc460a9c9849 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 15 Oct 2025 14:44:17 +0300 Subject: [PATCH 034/121] nix: add NixOS module Closes #3 Signed-off-by: NotAShelf Change-Id: I6a6a6964ecdb3bf1e5f7e2b902713eb8d2755ad1 --- flake.nix | 5 +++ nix/modules/nixos.nix | 78 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 nix/modules/nixos.nix diff --git a/flake.nix b/flake.nix index d7078d3..b41dbf9 100644 --- a/flake.nix +++ b/flake.nix @@ -13,6 +13,11 @@ forEachSystem = nixpkgs.lib.genAttrs systems; pkgsForEach = nixpkgs.legacyPackages; in { + nixosModules = { + stash = import ./nix/modules/nixos.nix self; + default = self.nixosModules.stash; + }; + packages = forEachSystem (system: let craneLib = crane.mkLib pkgsForEach.${system}; in { diff --git a/nix/modules/nixos.nix b/nix/modules/nixos.nix new file mode 100644 index 0000000..23072a0 --- /dev/null +++ b/nix/modules/nixos.nix @@ -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; + }; + }; + }; + }; +} From 7a4f6378e96f19aebe0e446a4f48c46b1e38a022 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 23 Oct 2025 15:57:07 +0300 Subject: [PATCH 035/121] nix: build with the mold linker on x86_64-linux Signed-off-by: NotAShelf Change-Id: I5d1e28f9b74fe1a4881a7105722ef3376a6a6964 --- nix/package.nix | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/nix/package.nix b/nix/package.nix index 8ca3d8e..336926a 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -1,6 +1,8 @@ { lib, craneLib, + stdenv, + mold, versionCheckHook, }: let pname = "stash"; @@ -52,6 +54,11 @@ in done ''; + env = lib.optionalAttrs (stdenv.isLinux && !stdenv.hostPlatform.isAarch) { + 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"; From 955a5d51f874b307b03b3e1d57820097daf7bfab Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 25 Oct 2025 08:07:59 +0300 Subject: [PATCH 036/121] multicall: cleanup; match wl-copy/wl-paste interfaces more closely Signed-off-by: NotAShelf Change-Id: I8cc05c0141cccff8378ef4fd83ccf77d6a6a6964 --- Cargo.lock | 1 + Cargo.toml | 1 + src/multicall.rs | 530 +++++++++++++++++++++++++++++++++-------------- 3 files changed, 371 insertions(+), 161 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0cad45c..ef17d85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1521,6 +1521,7 @@ dependencies = [ "env_logger", "imagesize", "inquire", + "libc", "log", "notify-rust", "ratatui", diff --git a/Cargo.toml b/Cargo.toml index ab80a8d..b66f921 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ inquire = { default-features = false, version = "0.9.1", features = [ log = "0.4.28" env_logger = "0.11.8" thiserror = "2.0.17" +libc = "0.2" wl-clipboard-rs = "0.9.2" rusqlite = { version = "0.37.0", features = ["bundled"] } smol = "2.0.2" diff --git a/src/multicall.rs b/src/multicall.rs index f387df0..86b0853 100644 --- a/src/multicall.rs +++ b/src/multicall.rs @@ -1,93 +1,119 @@ -use std::io::{self, Read, Write}; +use std::{ + io::{self, Read, Write}, + os::fd::IntoRawFd, + process::Command, +}; use clap::{ArgAction, Parser}; -use wl_clipboard_rs::paste::{ - ClipboardType, - Error, - MimeType, - Seat, - get_contents, +use wl_clipboard_rs::{ + copy::{ + ClipboardType as CopyClipboardType, + MimeType as CopyMimeType, + Options, + Seat as CopySeat, + ServeRequests, + Source, + }, + paste::{ + ClipboardType as PasteClipboardType, + Error as PasteError, + MimeType as PasteMimeType, + Seat as PasteSeat, + get_contents, + get_mime_types, + }, + utils::{PrimarySelectionCheckError, is_primary_selection_supported}, }; +/// Extract the base name from argv[0]. +fn get_base(argv0: &str) -> &str { + std::path::Path::new(argv0) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("") +} + /// Dispatch multicall binary logic based on argv[0]. /// Returns true if a multicall command was handled and the process should exit. pub fn multicall_dispatch() -> bool { let argv0 = std::env::args().next().unwrap_or_default(); - let base = std::path::Path::new(&argv0) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or(""); + let base = get_base(&argv0); match base { "stash-copy" | "wl-copy" => { - multicall_stash_copy(); + wl_copy_main(); true }, "stash-paste" | "wl-paste" => { - multicall_stash_paste(); + wl_paste_main(); true }, _ => false, } } -#[allow(clippy::too_many_lines)] -fn multicall_stash_copy() { - use clap::{ArgAction, Parser}; - use wl_clipboard_rs::{ - copy::{ClipboardType, MimeType, Options, ServeRequests, Source}, - utils::{PrimarySelectionCheckError, is_primary_selection_supported}, - }; - #[derive(Parser, Debug)] - #[command( - name = "stash-copy", - about = "Copy clipboard contents on Wayland.", - version, - disable_help_subcommand = true - )] - #[allow(clippy::struct_excessive_bools)] - struct Args { - /// Serve only a single paste request and then exit - #[arg(short = 'o', long = "paste-once", action = ArgAction::SetTrue)] - paste_once: bool, - /// Stay in the foreground instead of forking - #[arg(short = 'f', long = "foreground", action = ArgAction::SetTrue)] - foreground: bool, - /// Clear the clipboard instead of copying - #[arg(short = 'c', long = "clear", action = ArgAction::SetTrue)] - clear: bool, - /// Use the \"primary\" clipboard - #[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)] - primary: bool, - /// Use the regular clipboard - #[arg(short = 'r', long = "regular", action = ArgAction::SetTrue)] - regular: bool, - /// Trim the trailing newline character before copying - #[arg(short = 'n', long = "trim-newline", action = ArgAction::SetTrue)] - trim_newline: bool, - /// Pick the seat to work with - #[arg(short = 's', long = "seat")] - seat: Option, - /// Override the inferred MIME type for the content - #[arg(short = 't', long = "type")] - mime_type: Option, - /// 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, - /// Text to copy (if not given, read from stdin) - #[arg(value_name = "TEXT TO COPY", action = ArgAction::Append)] - text: Vec, - } +#[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, - let args = Args::parse(); + /// 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, + + /// Override the inferred MIME type for the content + #[arg(short = 't', long = "type")] + mime_type: Option, + + /// 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, + + /// Text to copy (if not given, read from stdin) + #[arg(value_name = "TEXT TO COPY", action = ArgAction::Append)] + text: Vec, +} + +fn wl_copy_main() { + let args = WlCopyArgs::parse(); if args.check_primary { match is_primary_selection_supported() { @@ -115,120 +141,201 @@ fn multicall_stash_copy() { } let clipboard = if args.primary { - ClipboardType::Primary + CopyClipboardType::Primary } else { - ClipboardType::Regular + CopyClipboardType::Regular }; let mime_type = if let Some(mt) = args.mime_type.as_deref() { if mt == "text" || mt == "text/plain" { - MimeType::Text + CopyMimeType::Text } else if mt == "autodetect" { - MimeType::Autodetect + CopyMimeType::Autodetect } else { - MimeType::Specific(mt.to_string()) + CopyMimeType::Specific(mt.to_string()) } } else { - MimeType::Autodetect + CopyMimeType::Autodetect }; - let mut input: Vec = Vec::new(); - if args.text.is_empty() { - if let Err(e) = std::io::stdin().read_to_end(&mut input) { - eprintln!("failed to read stdin: {e}"); - std::process::exit(1); - } - } else { - input = args.text.join(" ").into_bytes(); - } - - let mut opts = Options::new(); - opts.clipboard(clipboard); - - if args.trim_newline { - opts.trim_newline(true); - } - if args.foreground { - opts.foreground(true); - } - if let Some(seat) = args.seat.as_deref() { - log::debug!( - "'--seat' is not supported by stash (using default seat: {seat})" - ); - } - if args.omit_additional_text_mime_types { - opts.omit_additional_text_mime_types(true); - } - // --paste-once overrides serve-requests - if args.paste_once { - opts.serve_requests(ServeRequests::Only(1)); - } else if let Some(n) = args.serve_requests { - opts.serve_requests(ServeRequests::Only(n)); - } - // --clear + // Handle clear operation if args.clear { - // Clear clipboard by setting empty contents + let mut opts = Options::new(); + opts.clipboard(clipboard); + if let Some(seat_name) = args.seat.as_deref() { + opts.seat(CopySeat::Specific(seat_name.to_string())); + } else { + opts.seat(CopySeat::All); + } + if let Err(e) = opts.copy(Source::Bytes(Vec::new().into()), mime_type) { log::error!("failed to clear clipboard: {e}"); std::process::exit(1); } return; } - if let Err(e) = opts.copy(Source::Bytes(input.into()), mime_type) { - log::error!("failed to copy to clipboard: {e}"); - std::process::exit(1); + + // Read input data + let input: Vec = if args.text.is_empty() { + let mut buffer = Vec::new(); + if let Err(e) = std::io::stdin().read_to_end(&mut buffer) { + eprintln!("failed to read stdin: {e}"); + std::process::exit(1); + } + buffer + } else { + args.text.join(" ").into_bytes() + }; + + // Configure copy options + let mut opts = Options::new(); + opts.clipboard(clipboard); + + if let Some(seat_name) = args.seat.as_deref() { + opts.seat(CopySeat::Specific(seat_name.to_string())); + } else { + opts.seat(CopySeat::All); + } + + if args.trim_newline { + opts.trim_newline(true); + } + + if args.omit_additional_text_mime_types { + opts.omit_additional_text_mime_types(true); + } + + // Configure serving behavior + if args.paste_once { + opts.serve_requests(ServeRequests::Only(1)); + } else if let Some(n) = args.serve_requests { + opts.serve_requests(ServeRequests::Only(n)); + } + + // Handle foreground vs background mode + if args.foreground { + // Foreground mode: copy and serve in current process + if let Err(e) = opts.copy(Source::Bytes(input.into()), mime_type) { + log::error!("failed to copy to clipboard: {e}"); + std::process::exit(1); + } + } else { + // Background mode: fork and let child serve requests + // First prepare the copy to validate before forking + let mut opts_fg = opts.clone(); + opts_fg.foreground(true); + + let prepared_copy = + match opts_fg.prepare_copy(Source::Bytes(input.into()), mime_type) { + Ok(copy) => copy, + Err(e) => { + log::error!("failed to prepare copy: {e}"); + std::process::exit(1); + }, + }; + + // Fork the process + match unsafe { libc::fork() } { + -1 => { + log::error!("failed to fork: {}", std::io::Error::last_os_error()); + std::process::exit(1); + }, + 0 => { + // Child process: serve clipboard requests + // Redirect stdin/stdout to /dev/null to detach from terminal + if let Ok(dev_null) = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/null") + { + let fd = dev_null.into_raw_fd(); + unsafe { + libc::dup2(fd, libc::STDIN_FILENO); + libc::dup2(fd, libc::STDOUT_FILENO); + libc::close(fd); + } + } + + // Serve clipboard requests + if let Err(e) = prepared_copy.serve() { + log::error!("failed to serve clipboard: {e}"); + std::process::exit(1); + } + std::process::exit(0); + }, + _ => { + // Parent process: exit immediately + std::process::exit(0); + }, + } } } -fn multicall_stash_paste() { - #[derive(Parser, Debug)] - #[command( - name = "stash-paste", - about = "Paste clipboard contents on Wayland.", - version, - disable_help_subcommand = true - )] - struct Args { - /// List the offered MIME types instead of pasting - #[arg(short = 'l', long = "list-types", action = ArgAction::SetTrue)] - list_types: bool, - /// Use the "primary" clipboard - #[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)] - primary: bool, - /// Do not append a newline character - #[arg(short = 'n', long = "no-newline", action = ArgAction::SetTrue)] - no_newline: bool, - /// Pick the seat to work with - #[arg(short = 's', long = "seat")] - seat: Option, - /// Request the given MIME type instead of inferring the MIME type - #[arg(short = 't', long = "type")] - mime_type: Option, - /// Enable verbose logging - #[arg(short = 'v', long = "verbose", action = ArgAction::Count)] - verbose: u8, - } +#[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, - let args = Args::parse(); + /// 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, + + /// Request the given MIME type instead of inferring the MIME type + #[arg(short = 't', long = "type")] + mime_type: Option, + + /// 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>, +} + +fn wl_paste_main() { + let args = WlPasteArgs::parse(); let clipboard = if args.primary { - ClipboardType::Primary + PasteClipboardType::Primary } else { - ClipboardType::Regular + PasteClipboardType::Regular }; - if let Some(seat) = args.seat.as_deref() { - log::debug!( - "'--seat' is not supported by stash (using default seat: {seat})" - ); - } + let seat = if let Some(seat_name) = args.seat.as_deref() { + PasteSeat::Specific(seat_name) + } else { + PasteSeat::Unspecified + }; + // Handle list-types option if args.list_types { - match get_contents(clipboard, Seat::Unspecified, MimeType::Text) { - Ok((_reader, available_types)) => { - log::info!("{available_types}"); + match get_mime_types(clipboard, seat) { + Ok(types) => { + for mime_type in types { + println!("{}", mime_type); + } std::process::exit(0); }, + Err(PasteError::NoSeats) => { + log::error!("no seats available (is a Wayland compositor running?)"); + std::process::exit(1); + }, Err(e) => { log::error!("failed to list types: {e}"); std::process::exit(1); @@ -236,12 +343,106 @@ fn multicall_stash_paste() { } } + // Handle watch mode + if let Some(watch_args) = args.watch { + if watch_args.is_empty() { + eprintln!("--watch requires a command to run"); + std::process::exit(1); + } + + // For now, implement a simple version that just runs once + // Full watch mode would require more complex implementation + log::warn!("watch mode is not fully implemented in this version"); + + 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(); + if let Err(e) = reader.read_to_end(&mut content) { + log::error!("failed to read clipboard: {e}"); + std::process::exit(1); + } + + // Set environment variable for clipboard state + unsafe { + std::env::set_var( + "CLIPBOARD_STATE", + if content.is_empty() { "nil" } else { "data" }, + ) + }; + + // Spawn the command with the content as stdin + use std::process::Stdio; + cmd.stdin(Stdio::piped()); + + let mut child = match cmd.spawn() { + Ok(child) => child, + Err(e) => { + log::error!("failed to spawn command: {e}"); + std::process::exit(1); + }, + }; + + if let Some(stdin) = child.stdin.take() { + use std::io::Write; + let mut stdin = stdin; + if let Err(e) = stdin.write_all(&content) { + log::error!("failed to write to command stdin: {e}"); + std::process::exit(1); + } + } + + match child.wait() { + Ok(status) => { + std::process::exit(status.code().unwrap_or(1)); + }, + Err(e) => { + log::error!("failed to wait for command: {e}"); + std::process::exit(1); + }, + } + }, + Err(PasteError::NoSeats) => { + log::error!("no seats available (is a Wayland compositor running?)"); + std::process::exit(1); + }, + Err(PasteError::ClipboardEmpty) => { + unsafe { + std::env::set_var("CLIPBOARD_STATE", "nil"); + } + // Run command with /dev/null as stdin + use std::process::Stdio; + cmd.stdin(Stdio::null()); + + match cmd.status() { + Ok(status) => { + std::process::exit(status.code().unwrap_or(1)); + }, + Err(e) => { + log::error!("failed to run command: {e}"); + std::process::exit(1); + }, + } + }, + Err(e) => { + log::error!("clipboard error: {e}"); + std::process::exit(1); + }, + } + } + + // Regular paste mode let mime_type = match args.mime_type.as_deref() { - None | Some("text" | "autodetect") => MimeType::Text, - Some(other) => MimeType::Specific(other), + None | Some("text" | "autodetect") => PasteMimeType::Text, + Some(other) => PasteMimeType::Specific(other), }; - match get_contents(clipboard, Seat::Unspecified, mime_type) { + match get_contents(clipboard, seat, mime_type) { Ok((mut reader, _types)) => { let mut out = io::stdout(); let mut buf = Vec::new(); @@ -250,9 +451,15 @@ fn multicall_stash_paste() { if n == 0 && args.no_newline { std::process::exit(1); } - let _ = out.write_all(&buf); + if let Err(e) = out.write_all(&buf) { + log::error!("failed to write to stdout: {e}"); + std::process::exit(1); + } if !args.no_newline && !buf.ends_with(b"\n") { - let _ = out.write_all(b"\n"); + if let Err(e) = out.write_all(b"\n") { + log::error!("failed to write newline to stdout: {e}"); + std::process::exit(1); + } } }, Err(e) => { @@ -261,16 +468,17 @@ fn multicall_stash_paste() { }, } }, - Err(Error::NoSeats) => { + Err(PasteError::NoSeats) => { log::error!("no seats available (is a Wayland compositor running?)"); std::process::exit(1); }, - Err(Error::ClipboardEmpty) => { + Err(PasteError::ClipboardEmpty) => { if args.no_newline { std::process::exit(1); } + // Otherwise, exit successfully with no output }, - Err(Error::NoMimeType) => { + Err(PasteError::NoMimeType) => { log::error!("clipboard does not contain requested MIME type"); std::process::exit(1); }, From e94d931e67bc1a23bde957695d5e3c18b8466929 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 25 Oct 2025 09:01:35 +0300 Subject: [PATCH 037/121] chore: remove redundant unix check in build wrapper Signed-off-by: NotAShelf Change-Id: I174857e67f2e400d5dfdd8bfbe7c681d6a6a6964 --- build.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/build.rs b/build.rs index 533368c..f777a7c 100644 --- a/build.rs +++ b/build.rs @@ -5,16 +5,6 @@ const MULTICALL_LINKS: &[&str] = &["stash-copy", "stash-paste", "wl-copy", "wl-paste"]; fn main() { - // Only run on Unix-like systems - #[cfg(not(unix))] - { - println!( - "cargo:warning=Multicall symlinks are only supported on Unix-like \ - systems." - ); - return; - } - // OUT_DIR is something like .../target/debug/build//out // We want .../target/debug or .../target/release let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set"); From 78acc38044af542595375b5a6bb0bdd4b802d36b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 27 Oct 2025 09:31:53 +0300 Subject: [PATCH 038/121] multicall: cleanup; modularize Signed-off-by: NotAShelf Change-Id: I658f22fdf983777354a5beb32df631916a6a6964 --- src/multicall.rs | 490 -------------------------------------- src/multicall/mod.rs | 44 ++++ src/multicall/wl_copy.rs | 276 +++++++++++++++++++++ src/multicall/wl_paste.rs | 454 +++++++++++++++++++++++++++++++++++ 4 files changed, 774 insertions(+), 490 deletions(-) delete mode 100644 src/multicall.rs create mode 100644 src/multicall/mod.rs create mode 100644 src/multicall/wl_copy.rs create mode 100644 src/multicall/wl_paste.rs diff --git a/src/multicall.rs b/src/multicall.rs deleted file mode 100644 index 86b0853..0000000 --- a/src/multicall.rs +++ /dev/null @@ -1,490 +0,0 @@ -use std::{ - io::{self, Read, Write}, - os::fd::IntoRawFd, - process::Command, -}; - -use clap::{ArgAction, Parser}; -use wl_clipboard_rs::{ - copy::{ - ClipboardType as CopyClipboardType, - MimeType as CopyMimeType, - Options, - Seat as CopySeat, - ServeRequests, - Source, - }, - paste::{ - ClipboardType as PasteClipboardType, - Error as PasteError, - MimeType as PasteMimeType, - Seat as PasteSeat, - get_contents, - get_mime_types, - }, - utils::{PrimarySelectionCheckError, is_primary_selection_supported}, -}; - -/// Extract the base name from argv[0]. -fn get_base(argv0: &str) -> &str { - std::path::Path::new(argv0) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("") -} - -/// Dispatch multicall binary logic based on argv[0]. -/// Returns true if a multicall command was handled and the process should exit. -pub fn multicall_dispatch() -> bool { - let argv0 = std::env::args().next().unwrap_or_default(); - let base = get_base(&argv0); - match base { - "stash-copy" | "wl-copy" => { - wl_copy_main(); - true - }, - "stash-paste" | "wl-paste" => { - wl_paste_main(); - true - }, - _ => false, - } -} - -#[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, - - /// Override the inferred MIME type for the content - #[arg(short = 't', long = "type")] - mime_type: Option, - - /// 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, - - /// Text to copy (if not given, read from stdin) - #[arg(value_name = "TEXT TO COPY", action = ArgAction::Append)] - text: Vec, -} - -fn wl_copy_main() { - let args = WlCopyArgs::parse(); - - if args.check_primary { - match is_primary_selection_supported() { - Ok(true) => { - log::info!("primary selection is supported."); - std::process::exit(0); - }, - Ok(false) => { - log::info!("primary selection is NOT supported."); - std::process::exit(1); - }, - Err(PrimarySelectionCheckError::NoSeats) => { - log::error!("could not determine: no seats available."); - std::process::exit(2); - }, - Err(PrimarySelectionCheckError::MissingProtocol) => { - log::error!("data-control protocol not supported by compositor."); - std::process::exit(3); - }, - Err(e) => { - log::error!("error checking primary selection support: {e}"); - std::process::exit(4); - }, - } - } - - let clipboard = if args.primary { - CopyClipboardType::Primary - } else { - CopyClipboardType::Regular - }; - - let mime_type = if let Some(mt) = args.mime_type.as_deref() { - if mt == "text" || mt == "text/plain" { - CopyMimeType::Text - } else if mt == "autodetect" { - CopyMimeType::Autodetect - } else { - CopyMimeType::Specific(mt.to_string()) - } - } else { - CopyMimeType::Autodetect - }; - - // Handle clear operation - if args.clear { - let mut opts = Options::new(); - opts.clipboard(clipboard); - if let Some(seat_name) = args.seat.as_deref() { - opts.seat(CopySeat::Specific(seat_name.to_string())); - } else { - opts.seat(CopySeat::All); - } - - if let Err(e) = opts.copy(Source::Bytes(Vec::new().into()), mime_type) { - log::error!("failed to clear clipboard: {e}"); - std::process::exit(1); - } - return; - } - - // Read input data - let input: Vec = if args.text.is_empty() { - let mut buffer = Vec::new(); - if let Err(e) = std::io::stdin().read_to_end(&mut buffer) { - eprintln!("failed to read stdin: {e}"); - std::process::exit(1); - } - buffer - } else { - args.text.join(" ").into_bytes() - }; - - // Configure copy options - let mut opts = Options::new(); - opts.clipboard(clipboard); - - if let Some(seat_name) = args.seat.as_deref() { - opts.seat(CopySeat::Specific(seat_name.to_string())); - } else { - opts.seat(CopySeat::All); - } - - if args.trim_newline { - opts.trim_newline(true); - } - - if args.omit_additional_text_mime_types { - opts.omit_additional_text_mime_types(true); - } - - // Configure serving behavior - if args.paste_once { - opts.serve_requests(ServeRequests::Only(1)); - } else if let Some(n) = args.serve_requests { - opts.serve_requests(ServeRequests::Only(n)); - } - - // Handle foreground vs background mode - if args.foreground { - // Foreground mode: copy and serve in current process - if let Err(e) = opts.copy(Source::Bytes(input.into()), mime_type) { - log::error!("failed to copy to clipboard: {e}"); - std::process::exit(1); - } - } else { - // Background mode: fork and let child serve requests - // First prepare the copy to validate before forking - let mut opts_fg = opts.clone(); - opts_fg.foreground(true); - - let prepared_copy = - match opts_fg.prepare_copy(Source::Bytes(input.into()), mime_type) { - Ok(copy) => copy, - Err(e) => { - log::error!("failed to prepare copy: {e}"); - std::process::exit(1); - }, - }; - - // Fork the process - match unsafe { libc::fork() } { - -1 => { - log::error!("failed to fork: {}", std::io::Error::last_os_error()); - std::process::exit(1); - }, - 0 => { - // Child process: serve clipboard requests - // Redirect stdin/stdout to /dev/null to detach from terminal - if let Ok(dev_null) = std::fs::OpenOptions::new() - .read(true) - .write(true) - .open("/dev/null") - { - let fd = dev_null.into_raw_fd(); - unsafe { - libc::dup2(fd, libc::STDIN_FILENO); - libc::dup2(fd, libc::STDOUT_FILENO); - libc::close(fd); - } - } - - // Serve clipboard requests - if let Err(e) = prepared_copy.serve() { - log::error!("failed to serve clipboard: {e}"); - std::process::exit(1); - } - std::process::exit(0); - }, - _ => { - // Parent process: exit immediately - std::process::exit(0); - }, - } - } -} - -#[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, - - /// Request the given MIME type instead of inferring the MIME type - #[arg(short = 't', long = "type")] - mime_type: Option, - - /// 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>, -} - -fn wl_paste_main() { - let args = WlPasteArgs::parse(); - - let clipboard = if args.primary { - PasteClipboardType::Primary - } else { - PasteClipboardType::Regular - }; - - let seat = if let Some(seat_name) = args.seat.as_deref() { - PasteSeat::Specific(seat_name) - } else { - PasteSeat::Unspecified - }; - - // Handle list-types option - if args.list_types { - match get_mime_types(clipboard, seat) { - Ok(types) => { - for mime_type in types { - println!("{}", mime_type); - } - std::process::exit(0); - }, - Err(PasteError::NoSeats) => { - log::error!("no seats available (is a Wayland compositor running?)"); - std::process::exit(1); - }, - Err(e) => { - log::error!("failed to list types: {e}"); - std::process::exit(1); - }, - } - } - - // Handle watch mode - if let Some(watch_args) = args.watch { - if watch_args.is_empty() { - eprintln!("--watch requires a command to run"); - std::process::exit(1); - } - - // For now, implement a simple version that just runs once - // Full watch mode would require more complex implementation - log::warn!("watch mode is not fully implemented in this version"); - - 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(); - if let Err(e) = reader.read_to_end(&mut content) { - log::error!("failed to read clipboard: {e}"); - std::process::exit(1); - } - - // Set environment variable for clipboard state - unsafe { - std::env::set_var( - "CLIPBOARD_STATE", - if content.is_empty() { "nil" } else { "data" }, - ) - }; - - // Spawn the command with the content as stdin - use std::process::Stdio; - cmd.stdin(Stdio::piped()); - - let mut child = match cmd.spawn() { - Ok(child) => child, - Err(e) => { - log::error!("failed to spawn command: {e}"); - std::process::exit(1); - }, - }; - - if let Some(stdin) = child.stdin.take() { - use std::io::Write; - let mut stdin = stdin; - if let Err(e) = stdin.write_all(&content) { - log::error!("failed to write to command stdin: {e}"); - std::process::exit(1); - } - } - - match child.wait() { - Ok(status) => { - std::process::exit(status.code().unwrap_or(1)); - }, - Err(e) => { - log::error!("failed to wait for command: {e}"); - std::process::exit(1); - }, - } - }, - Err(PasteError::NoSeats) => { - log::error!("no seats available (is a Wayland compositor running?)"); - std::process::exit(1); - }, - Err(PasteError::ClipboardEmpty) => { - unsafe { - std::env::set_var("CLIPBOARD_STATE", "nil"); - } - // Run command with /dev/null as stdin - use std::process::Stdio; - cmd.stdin(Stdio::null()); - - match cmd.status() { - Ok(status) => { - std::process::exit(status.code().unwrap_or(1)); - }, - Err(e) => { - log::error!("failed to run command: {e}"); - std::process::exit(1); - }, - } - }, - Err(e) => { - log::error!("clipboard error: {e}"); - std::process::exit(1); - }, - } - } - - // Regular paste mode - let mime_type = match args.mime_type.as_deref() { - None | Some("text" | "autodetect") => PasteMimeType::Text, - Some(other) => PasteMimeType::Specific(other), - }; - - match get_contents(clipboard, seat, mime_type) { - Ok((mut reader, _types)) => { - let mut out = io::stdout(); - let mut buf = Vec::new(); - match reader.read_to_end(&mut buf) { - Ok(n) => { - if n == 0 && args.no_newline { - std::process::exit(1); - } - if let Err(e) = out.write_all(&buf) { - log::error!("failed to write to stdout: {e}"); - std::process::exit(1); - } - if !args.no_newline && !buf.ends_with(b"\n") { - if let Err(e) = out.write_all(b"\n") { - log::error!("failed to write newline to stdout: {e}"); - std::process::exit(1); - } - } - }, - Err(e) => { - log::error!("failed to read clipboard: {e}"); - std::process::exit(1); - }, - } - }, - Err(PasteError::NoSeats) => { - log::error!("no seats available (is a Wayland compositor running?)"); - std::process::exit(1); - }, - Err(PasteError::ClipboardEmpty) => { - if args.no_newline { - std::process::exit(1); - } - // Otherwise, exit successfully with no output - }, - Err(PasteError::NoMimeType) => { - log::error!("clipboard does not contain requested MIME type"); - std::process::exit(1); - }, - Err(e) => { - log::error!("clipboard error: {e}"); - std::process::exit(1); - }, - } -} diff --git a/src/multicall/mod.rs b/src/multicall/mod.rs new file mode 100644 index 0000000..46f19f3 --- /dev/null +++ b/src/multicall/mod.rs @@ -0,0 +1,44 @@ +// 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; + +use std::env; + +/// Extract the base name from argv[0]. +fn get_base(argv0: &str) -> &str { + std::path::Path::new(argv0) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("") +} + +/// Dispatch multicall binary logic based on `argv[0]`. +/// Returns `true` if a multicall command was handled and the process should +/// exit. +pub fn multicall_dispatch() -> bool { + let argv0 = env::args().next().unwrap_or_else(|| { + log::warn!("unable to determine program name"); + String::new() + }); + let base = get_base(&argv0); + match base { + "stash-copy" | "wl-copy" => { + if let Err(e) = wl_copy::wl_copy_main() { + log::error!("copy failed: {e}"); + std::process::exit(1); + } + true + }, + "stash-paste" | "wl-paste" => { + if let Err(e) = wl_paste::wl_paste_main() { + log::error!("paste failed: {e}"); + std::process::exit(1); + } + true + }, + _ => false, + } +} diff --git a/src/multicall/wl_copy.rs b/src/multicall/wl_copy.rs new file mode 100644 index 0000000..cab79f5 --- /dev/null +++ b/src/multicall/wl_copy.rs @@ -0,0 +1,276 @@ +use std::io::{self, Read}; + +use clap::{ArgAction, Parser}; +use color_eyre::eyre::{Context, Result, bail}; +use wl_clipboard_rs::{ + copy::{ + ClipboardType as CopyClipboardType, + MimeType as CopyMimeType, + Options, + Seat as CopySeat, + ServeRequests, + Source, + }, + utils::{PrimarySelectionCheckError, is_primary_selection_supported}, +}; + +// Maximum clipboard content size to prevent memory exhaustion (100MB) +const MAX_CLIPBOARD_SIZE: usize = 100 * 1024 * 1024; + +#[derive(Parser, Debug)] +#[command( + name = "wl-copy", + about = "Copy clipboard contents on Wayland.", + version +)] +#[allow(clippy::struct_excessive_bools)] +struct WlCopyArgs { + /// Serve only a single paste request and then exit + #[arg(short = 'o', long = "paste-once", action = ArgAction::SetTrue)] + paste_once: bool, + + /// Stay in the foreground instead of forking + #[arg(short = 'f', long = "foreground", action = ArgAction::SetTrue)] + foreground: bool, + + /// Clear the clipboard instead of copying + #[arg(short = 'c', long = "clear", action = ArgAction::SetTrue)] + clear: bool, + + /// Use the "primary" clipboard + #[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)] + primary: bool, + + /// Use the regular clipboard + #[arg(short = 'r', long = "regular", action = ArgAction::SetTrue)] + regular: bool, + + /// Trim the trailing newline character before copying + #[arg(short = 'n', long = "trim-newline", action = ArgAction::SetTrue)] + trim_newline: bool, + + /// Pick the seat to work with + #[arg(short = 's', long = "seat")] + seat: Option, + + /// Override the inferred MIME type for the content + #[arg(short = 't', long = "type")] + mime_type: Option, + + /// 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, + + /// Text to copy (if not given, read from stdin) + #[arg(value_name = "TEXT TO COPY", action = ArgAction::Append)] + text: Vec, +} + +fn handle_check_primary() { + let exit_code = match is_primary_selection_supported() { + Ok(true) => { + log::info!("primary selection is supported."); + 0 + }, + Ok(false) => { + log::info!("primary selection is NOT supported."); + 1 + }, + Err(PrimarySelectionCheckError::NoSeats) => { + log::error!("could not determine: no seats available."); + 2 + }, + Err(PrimarySelectionCheckError::MissingProtocol) => { + log::error!("data-control protocol not supported by compositor."); + 3 + }, + Err(e) => { + log::error!("error checking primary selection support: {e}"); + 4 + }, + }; + + // Exit with the relevant code + std::process::exit(exit_code); +} + +fn get_clipboard_type(primary: bool) -> CopyClipboardType { + if primary { + CopyClipboardType::Primary + } else { + CopyClipboardType::Regular + } +} + +fn get_mime_type(mime_arg: Option<&str>) -> CopyMimeType { + match mime_arg { + Some("text" | "text/plain") => CopyMimeType::Text, + Some("autodetect") | None => CopyMimeType::Autodetect, + Some(specific) => CopyMimeType::Specific(specific.to_string()), + } +} + +fn read_input_data(text_args: &[String]) -> Result> { + if text_args.is_empty() { + let mut buffer = Vec::new(); + let mut stdin = io::stdin(); + + // Read with size limit to prevent memory exhaustion + let mut temp_buffer = [0; 8192]; + loop { + let bytes_read = stdin + .read(&mut temp_buffer) + .context("failed to read from stdin")?; + + if bytes_read == 0 { + break; + } + + if buffer.len() + bytes_read > MAX_CLIPBOARD_SIZE { + bail!( + "input exceeds maximum clipboard size of {} bytes", + MAX_CLIPBOARD_SIZE + ); + } + + buffer.extend_from_slice(&temp_buffer[..bytes_read]); + } + + Ok(buffer) + } else { + let content = text_args.join(" "); + if content.len() > MAX_CLIPBOARD_SIZE { + bail!( + "input exceeds maximum clipboard size of {} bytes", + MAX_CLIPBOARD_SIZE + ); + } + Ok(content.into_bytes()) + } +} + +fn configure_copy_options( + args: &WlCopyArgs, + clipboard: CopyClipboardType, +) -> Options { + let mut opts = Options::new(); + opts.clipboard(clipboard); + opts.seat( + args + .seat + .as_deref() + .map_or(CopySeat::All, |s| CopySeat::Specific(s.to_string())), + ); + + if args.trim_newline { + opts.trim_newline(true); + } + + if args.omit_additional_text_mime_types { + opts.omit_additional_text_mime_types(true); + } + + if args.paste_once { + opts.serve_requests(ServeRequests::Only(1)); + } else if let Some(n) = args.serve_requests { + opts.serve_requests(ServeRequests::Only(n)); + } + + opts +} + +fn handle_clear_clipboard( + args: &WlCopyArgs, + clipboard: CopyClipboardType, + mime_type: CopyMimeType, +) -> Result<()> { + let mut opts = Options::new(); + opts.clipboard(clipboard); + opts.seat( + args + .seat + .as_deref() + .map_or(CopySeat::All, |s| CopySeat::Specific(s.to_string())), + ); + + opts + .copy(Source::Bytes(Vec::new().into()), mime_type) + .context("failed to clear clipboard")?; + + Ok(()) +} + +fn fork_and_serve(prepared_copy: wl_clipboard_rs::copy::PreparedCopy) { + // Use a simpler approach: serve in background thread instead of forking + // This avoids all the complexity and safety issues with fork() + let handle = std::thread::spawn(move || { + if let Err(e) = prepared_copy.serve() { + log::error!("background clipboard service failed: {e}"); + } + }); + + // Give the background thread a moment to start + std::thread::sleep(std::time::Duration::from_millis(50)); + log::debug!("clipboard service started in background thread"); + + // Detach the thread to allow it to run independently + // The thread will be cleaned up when it completes or when the process exits + std::mem::forget(handle); +} + +pub fn wl_copy_main() -> Result<()> { + let args = WlCopyArgs::parse(); + + if args.check_primary { + handle_check_primary(); + } + + let clipboard = get_clipboard_type(args.primary); + let mime_type = get_mime_type(args.mime_type.as_deref()); + + // Handle clear operation + if args.clear { + handle_clear_clipboard(&args, clipboard, mime_type)?; + return Ok(()); + } + + // Read input data + let input = + read_input_data(&args.text).context("failed to read input data")?; + + // Configure copy options + let opts = configure_copy_options(&args, clipboard); + + // Handle foreground vs background mode + if args.foreground { + // Foreground mode: copy and serve in current process + opts + .copy(Source::Bytes(input.into()), mime_type) + .context("failed to copy to clipboard")?; + } else { + // Background mode: spawn child process to serve requests + // First prepare to copy to validate before spawning + let mut opts_fg = opts.clone(); + opts_fg.foreground(true); + + let prepared_copy = opts_fg + .prepare_copy(Source::Bytes(input.into()), mime_type) + .context("failed to prepare copy")?; + + fork_and_serve(prepared_copy); + } + + Ok(()) +} diff --git a/src/multicall/wl_paste.rs b/src/multicall/wl_paste.rs new file mode 100644 index 0000000..909926d --- /dev/null +++ b/src/multicall/wl_paste.rs @@ -0,0 +1,454 @@ +// https://wayland.freedesktop.org/docs/html/apa.html#protocol-spec-wl_data_device +// https://docs.rs/wl-clipboard-rs/latest/wl_clipboard_rs +// https://github.com/YaLTeR/wl-clipboard-rs/blob/master/wl-clipboard-rs-tools/src/bin/wl_paste.rs +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + io::{self, Read, Write}, + process::{Command, Stdio}, + sync::{Arc, Mutex}, + thread, + time::{Duration, Instant}, +}; + +use clap::{ArgAction, Parser}; +use color_eyre::eyre::{Context, Result, bail}; +use wl_clipboard_rs::paste::{ + ClipboardType as PasteClipboardType, + Error as PasteError, + MimeType as PasteMimeType, + Seat as PasteSeat, + get_contents, + get_mime_types, +}; + +// Watch mode timing constants +const WATCH_POLL_INTERVAL_MS: u64 = 500; +const WATCH_DEBOUNCE_INTERVAL_MS: u64 = 1000; + +// Maximum clipboard content size to prevent memory exhaustion (100MB) +const MAX_CLIPBOARD_SIZE: usize = 100 * 1024 * 1024; + +#[derive(Parser, Debug)] +#[command( + name = "wl-paste", + about = "Paste clipboard contents on Wayland.", + version, + disable_help_subcommand = true +)] +struct WlPasteArgs { + /// List the offered MIME types instead of pasting + #[arg(short = 'l', long = "list-types", action = ArgAction::SetTrue)] + list_types: bool, + + /// Use the "primary" clipboard + #[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)] + primary: bool, + + /// Do not append a newline character + #[arg(short = 'n', long = "no-newline", action = ArgAction::SetTrue)] + no_newline: bool, + + /// Pick the seat to work with + #[arg(short = 's', long = "seat")] + seat: Option, + + /// Request the given MIME type instead of inferring the MIME type + #[arg(short = 't', long = "type")] + mime_type: Option, + + /// 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>, +} + +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::)); + 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 { + match get_contents(clipboard, seat, PasteMimeType::Text) { + Ok((mut reader, _types)) => { + let mut content = Vec::new(); + let mut temp_buffer = [0; 8192]; + + loop { + let bytes_read = reader + .read(&mut temp_buffer) + .context("failed to read clipboard content")?; + + if bytes_read == 0 { + break; + } + + if content.len() + bytes_read > MAX_CLIPBOARD_SIZE { + bail!( + "clipboard content exceeds maximum size of {} bytes", + MAX_CLIPBOARD_SIZE + ); + } + + content.extend_from_slice(&temp_buffer[..bytes_read]); + } + + let mut hasher = DefaultHasher::new(); + content.hash(&mut hasher); + Ok(hasher.finish()) + }, + Err(PasteError::ClipboardEmpty) => { + Ok(0) // Empty clipboard has hash 0 + }, + Err(e) => bail!("clipboard error: {e}"), + } +} + +/// Validate command name to prevent command injection +fn validate_command_name(cmd: &str) -> Result<()> { + if cmd.is_empty() { + bail!("command name cannot be empty"); + } + + // Reject commands with shell metacharacters or path traversal + if cmd.contains(|c| { + ['|', '&', ';', '$', '`', '(', ')', '<', '>', '"', '\'', '\\'].contains(&c) + }) { + bail!("command contains invalid characters: {cmd}"); + } + + // Reject absolute paths and relative path traversal + if cmd.starts_with('/') || cmd.contains("..") { + bail!("command paths are not allowed: {cmd}"); + } + + Ok(()) +} + +/// Set environment variable safely with validation +fn set_clipboard_state_env(has_content: bool) -> Result<()> { + let value = if has_content { "data" } else { "nil" }; + + // Validate the environment variable value + if !matches!(value, "data" | "nil") { + bail!("invalid clipboard state value: {value}"); + } + + // Safe to set environment variable with validated, known-safe value + unsafe { + std::env::set_var("STASH_CLIPBOARD_STATE", value); + } + Ok(()) +} + +fn execute_watch_command( + watch_args: &[String], + clipboard: PasteClipboardType, + seat: PasteSeat, +) -> Result<()> { + if watch_args.is_empty() { + bail!("watch command cannot be empty"); + } + + // Validate command name for security + validate_command_name(&watch_args[0])?; + + let mut cmd = Command::new(&watch_args[0]); + if watch_args.len() > 1 { + cmd.args(&watch_args[1..]); + } + + // Get clipboard content and pipe it to the command + match get_contents(clipboard, seat, PasteMimeType::Text) { + Ok((mut reader, _types)) => { + let mut content = Vec::new(); + let mut temp_buffer = [0; 8192]; + + loop { + let bytes_read = reader + .read(&mut temp_buffer) + .context("failed to read clipboard")?; + + if bytes_read == 0 { + break; + } + + if content.len() + bytes_read > MAX_CLIPBOARD_SIZE { + bail!( + "clipboard content exceeds maximum size of {} bytes", + MAX_CLIPBOARD_SIZE + ); + } + + content.extend_from_slice(&temp_buffer[..bytes_read]); + } + + // Set environment variable safely + set_clipboard_state_env(!content.is_empty())?; + + // Spawn the command with the content as stdin + cmd.stdin(Stdio::piped()); + + let mut child = cmd.spawn()?; + + if let Some(stdin) = child.stdin.take() { + let mut stdin = stdin; + if let Err(e) = stdin.write_all(&content) { + bail!("failed to write to command stdin: {e}"); + } + } + + match child.wait() { + Ok(status) => { + if !status.success() { + log::warn!("watch command exited with status: {status}"); + } + }, + Err(e) => { + bail!("failed to wait for command: {e}"); + }, + } + }, + Err(PasteError::ClipboardEmpty) => { + // Set environment variable safely + set_clipboard_state_env(false)?; + + // Run command with /dev/null as stdin + cmd.stdin(Stdio::null()); + + match cmd.status() { + Ok(status) => { + if !status.success() { + log::warn!("watch command exited with status: {status}"); + } + }, + Err(e) => { + bail!("failed to run command: {e}"); + }, + } + }, + Err(e) => { + bail!("clipboard error: {e}"); + }, + } + + Ok(()) +} + +fn handle_regular_paste( + args: &WlPasteArgs, + clipboard: PasteClipboardType, + seat: PasteSeat, +) -> Result<()> { + let mime_type = get_paste_mime_type(args.mime_type.as_deref()); + + match get_contents(clipboard, seat, mime_type) { + Ok((mut reader, _types)) => { + let mut out = io::stdout(); + let mut buf = Vec::new(); + let mut temp_buffer = [0; 8192]; + + loop { + let bytes_read = reader + .read(&mut temp_buffer) + .context("failed to read clipboard")?; + + if bytes_read == 0 { + break; + } + + if buf.len() + bytes_read > MAX_CLIPBOARD_SIZE { + bail!( + "clipboard content exceeds maximum size of {} bytes", + MAX_CLIPBOARD_SIZE + ); + } + + buf.extend_from_slice(&temp_buffer[..bytes_read]); + } + + if buf.is_empty() && args.no_newline { + bail!("no content available and --no-newline specified"); + } + if let Err(e) = out.write_all(&buf) { + bail!("failed to write to stdout: {e}"); + } + if !args.no_newline && !buf.ends_with(b"\n") { + if let Err(e) = out.write_all(b"\n") { + bail!("failed to write newline to stdout: {e}"); + } + } + }, + Err(PasteError::NoSeats) => { + bail!("no seats available (is a Wayland compositor running?)"); + }, + Err(PasteError::ClipboardEmpty) => { + if args.no_newline { + bail!("clipboard empty and --no-newline specified"); + } + // Otherwise, exit successfully with no output + }, + Err(PasteError::NoMimeType) => { + bail!("clipboard does not contain requested MIME type"); + }, + Err(e) => { + bail!("clipboard error: {e}"); + }, + } + + Ok(()) +} + +pub fn wl_paste_main() -> Result<()> { + let args = WlPasteArgs::parse(); + + let clipboard = if args.primary { + PasteClipboardType::Primary + } else { + PasteClipboardType::Regular + }; + let seat = args + .seat + .as_deref() + .map_or(PasteSeat::Unspecified, PasteSeat::Specific); + + // Handle list-types option + if args.list_types { + handle_list_types(clipboard, seat)?; + return Ok(()); + } + + // Handle watch mode + if args.watch.is_some() { + handle_watch_mode(&args, clipboard, seat)?; + return Ok(()); + } + + // Regular paste mode + handle_regular_paste(&args, clipboard, seat)?; + + Ok(()) +} From c95d9a4567e925cf74f11e9f59f4bf7aa8f900e9 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 27 Oct 2025 11:10:37 +0300 Subject: [PATCH 039/121] chore: remove unused deps; format with taplo Signed-off-by: NotAShelf Change-Id: If575be0b2c6f1f8b8eac6cacaa2784606a6a6964 --- .rustfmt.toml | 47 +++++++------ Cargo.lock | 183 +++++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 14 ++-- 3 files changed, 211 insertions(+), 33 deletions(-) diff --git a/.rustfmt.toml b/.rustfmt.toml index cb120a3..324bf8b 100644 --- a/.rustfmt.toml +++ b/.rustfmt.toml @@ -1,27 +1,26 @@ -condense_wildcard_suffixes = true +condense_wildcard_suffixes = true doc_comment_code_block_width = 80 -edition = "2024" # Keep in sync with Cargo.toml. +edition = "2024" # Keep in sync with Cargo.toml. enum_discrim_align_threshold = 60 -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 +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 - +tab_spaces = 2 +unstable_features = true +use_field_init_shorthand = true +use_try_shorthand = true +wrap_comments = true diff --git a/Cargo.lock b/Cargo.lock index ef17d85..d40b375 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.3" @@ -232,6 +247,21 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + [[package]] name = "base64" version = "0.22.1" @@ -353,6 +383,33 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -440,6 +497,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "ctrlc" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "881c5d0a13b2f1498e2306e82cbada78390e152d4b1378fb28a84f4dcd0dc4f3" +dependencies = [ + "dispatch", + "nix", + "windows-sys 0.61.2", +] + [[package]] name = "darling" version = "0.20.11" @@ -526,6 +594,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "dispatch2" version = "0.3.0" @@ -650,6 +724,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -740,6 +824,12 @@ dependencies = [ "wasi 0.14.7+wasi-0.2.4", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "hashbrown" version = "0.15.5" @@ -796,6 +886,12 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "indexmap" version = "2.11.4" @@ -883,6 +979,12 @@ dependencies = [ "syn", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.177" @@ -985,6 +1087,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + [[package]] name = "mio" version = "1.0.4" @@ -1079,6 +1190,15 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1117,6 +1237,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + [[package]] name = "parking" version = "2.2.1" @@ -1346,6 +1472,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + [[package]] name = "rustix" version = "0.38.44" @@ -1444,6 +1576,15 @@ dependencies = [ "syn", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1516,12 +1657,13 @@ dependencies = [ "base64", "clap", "clap-verbosity-flag", + "color-eyre", "crossterm 0.29.0", + "ctrlc", "dirs", "env_logger", "imagesize", "inquire", - "libc", "log", "notify-rust", "ratatui", @@ -1534,7 +1676,6 @@ dependencies = [ "unicode-segmentation", "unicode-width 0.2.0", "wayland-client", - "wayland-protocols", "wayland-protocols-wlr", "wl-clipboard-rs", ] @@ -1629,6 +1770,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.44" @@ -1707,6 +1857,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", ] [[package]] @@ -1773,6 +1945,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -1823,6 +2001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ "bitflags", + "log", "rustix 1.1.2", "wayland-backend", "wayland-scanner", diff --git a/Cargo.toml b/Cargo.toml index b66f921..103ec7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,26 +10,27 @@ repository = "https://github.com/notashelf/stash" rust-version = "1.85" [[bin]] -name = "stash" # actual binary name for Nix, Cargo, etc. +name = "stash" # actual binary name for Nix, Cargo, etc. path = "src/main.rs" [features] default = ["use-toplevel", "notifications"] -use-toplevel = ["dep:wayland-client", "dep:wayland-protocols", "dep:wayland-protocols-wlr"] +use-toplevel = ["dep:wayland-client", "dep:wayland-protocols-wlr"] notifications = ["dep:notify-rust"] [dependencies] clap = { version = "4.5.48", features = ["derive", "env"] } clap-verbosity-flag = "3.0.4" +ctrlc = "3.5.0" +color-eyre = "0.6.5" dirs = "6.0.0" imagesize = "0.14.0" inquire = { default-features = false, version = "0.9.1", features = [ - "crossterm", + "crossterm", ] } log = "0.4.28" env_logger = "0.11.8" thiserror = "2.0.17" -libc = "0.2" wl-clipboard-rs = "0.9.2" rusqlite = { version = "0.37.0", features = ["bundled"] } smol = "2.0.2" @@ -41,9 +42,8 @@ ratatui = "0.29.0" crossterm = "0.29.0" unicode-segmentation = "1.12.0" unicode-width = "0.2.0" # FIXME: held back by ratatui -wayland-client = { version = "0.31.11", optional = true } -wayland-protocols = { version = "0.32.0", optional = true } -wayland-protocols-wlr = { version = "0.3.9", optional = true } +wayland-client = { version = "0.31.11", features = ["log"], optional = true } +wayland-protocols-wlr = { version = "0.3.9", default-features = false, optional = true } notify-rust = { version = "4.11.7", optional = true } [profile.release] From 43a3aae49656d55b04da180e3e9daf65f0672822 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 27 Oct 2025 11:49:26 +0300 Subject: [PATCH 040/121] docs: add attributions section; detail remaining sections Signed-off-by: NotAShelf Change-Id: Ice462ee8fc34e375a01940b6b013f5496a6a6964 --- README.md | 122 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 106 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index caa0a3d..f5eba93 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,9 @@
- Wayland clipboard "manager" with fast persistent history and multi-media - support. Stores and previews clipboard entries (text, images) on the command + Lightweight 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. line.
@@ -35,8 +36,8 @@ ## Features -Stash is a feature-rich, yet simple clipboard management utility with many -features such as but not limited to: +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 @@ -64,7 +65,7 @@ you are on NixOS. ```nix { # Add Stash to your inputs like so - inputs.stash.url = "github:notashelf/stash"; + inputs.stash.url = "github:NotAShelf/stash"; outputs = { /* ... */ }; } @@ -86,10 +87,11 @@ in { } ``` -You can also run it one time with `nix run` +If you want to give Stash a try before you switch to it, you may also run it one +time with `nix run`. ```sh -nix run github:notashelf/stash -- watch # start the watch daemon +nix run github:NotAShelf/stash -- watch # start the watch daemon ``` ### Without Nix @@ -98,12 +100,13 @@ nix run github:notashelf/stash -- watch # start the watch daemon You can also install Stash on any of your systems _without_ using Nix. New releases are made when a version gets tagged, and are available under -[GitHub Releases]. To install Stash on your system without Nix, eiter: +[GitHub Releases]. To install Stash on your system without Nix, either: - Download a tagged release from [GitHub Releases] for your platform and place 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`. + releases and put it somewhere like `/usr/bin` or `~/.local/bin` depending on + your distribution. - Build and install from source with Cargo: ```bash @@ -112,16 +115,63 @@ releases are made when a version gets tagged, and are available under ## Usage -Command interface is only slightly different from Cliphist. In most cases, it -will be as simple as replacing `cliphist` with `stash` in your commands, aliases -or scripts. - > [!NOTE] > It is not a priority to provide 1:1 backwards compatibility with Cliphist. > While the interface is _almost_ identical, Stash chooses to build upon > Cliphist's design and extend existing design choices. See > [Migrating from Cliphist](#migrating-from-cliphist) for more details. +The command interface of Stash is _only slightly_ different from Cliphist. In +most cases, you may simply replace `cliphist` with `stash` and your commands, +aliases or scripts will continue to work as intended. + +Some of the commands allow further fine-graining with flags such as `--type` or +`--format` to allow specific input and output specifiers. See `--help` for +individual subcommands if in doubt. + + + +```console +$ stash help +Wayland clipboard manager + +Usage: stash [OPTIONS] [COMMAND] + +Commands: + store Store clipboard contents + list List clipboard history + decode Decode and output clipboard entry by id + delete Delete clipboard entry by id (if numeric), or entries matching a query (if not). Numeric arguments are treated as ids. Use --type to specify explicitly + wipe Wipe all clipboard history + import Import clipboard data from stdin (default: TSV format) + watch Start a process to watch clipboard for changes and store automatically + help Print this message or the help of the given subcommand(s) + +Options: + --max-items + Maximum number of clipboard entries to keep [default: 18446744073709551615] + --max-dedupe-search + Number of recent entries to check for duplicates when storing new clipboard data [default: 20] + --preview-width + Maximum width (in characters) for clipboard entry previews in list output [default: 100] + --db-path + Path to the `SQLite` clipboard database file + --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 +``` + + + ### Store an entry ```bash @@ -134,18 +184,34 @@ 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 `. + ### Decode an entry by ID ```bash -stash decode --input "1234" +stash decode ``` +> [!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] ``` +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 @@ -205,7 +271,8 @@ This can be configured in one of two ways. You can use the **environment variable** `STASTH_SENSITIVE_REGEX` to a valid regex pattern, and if the 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. +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 @@ -228,6 +295,9 @@ logged. > **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 @@ -327,6 +397,26 @@ figured out something new, e.g. a neat shell trick, feel free to add it here! cliphist list --db ~/.cache/cliphist/db | stash import ``` +3. Stash provides its own implementation of `wl-copy` and `wl-paste` commands + backed by `wl-clipboard-rs`. Those implementations are backwards compatible + with `wl-clipboard`, and may be used as **drop-in** replacements. The default + build wrapper in `build.rs` links `stash` to `stash-copy` and `stash-paste`, + which are also available as `wl-copy` and `wl-paste` respectively. The Nix + package automatically links those to `$out/bin` for you, which means they are + installed by default but other package managers may need additional steps by + the packagers. While building from source, you may link + `target/release/stash` manually. + +## Attributions + +My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the +[wl-clipboard-rs](https://github.com/YaLTeR/wl-clipboard-rs) crate. Stash is +powered by [several crates](./Cargo.toml), but none of them were as detrimental +in Stash's design process. + +Additional thanks to my testers, who have tested earlier versions of Stash and +provided feedback. Thank you :) + ## License This project is made available under Mozilla Public License (MPL) version 2.0. From d59ac77b9fcac8edefcb639a1cc4b647c9348769 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 27 Oct 2025 12:19:45 +0300 Subject: [PATCH 041/121] stash: utilize clap for multicall functionality; simplify CLI handler Signed-off-by: NotAShelf Change-Id: I84d9f46bb9bba0e893aa4f99d6ff48f76a6a6964 --- src/main.rs | 53 ++++++++++++++++++++++---------------------- src/multicall/mod.rs | 38 ------------------------------- 2 files changed, 26 insertions(+), 65 deletions(-) diff --git a/src/main.rs b/src/main.rs index f5c6b2e..d36af5f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,6 @@ use std::{ env, io::{self, IsTerminal}, path::PathBuf, - process, }; use clap::{CommandFactory, Parser, Subcommand}; @@ -129,14 +128,27 @@ fn report_error( } #[allow(clippy::too_many_lines)] // whatever -fn main() { - // Multicall dispatch: stash-copy, stash-paste, wl-copy, wl-paste - if crate::multicall::multicall_dispatch() { - // If handled, exit immediately - std::process::exit(0); +fn main() -> color_eyre::eyre::Result<()> { + // Check if we're being called as a multicall binary + 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(()); + } } - // If not multicall, proceed with normal CLI handling + // Normal CLI handling smol::block_on(async { let cli = Cli::parse(); env_logger::Builder::new() @@ -151,24 +163,11 @@ fn main() { }); 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); - } + std::fs::create_dir_all(parent)?; } - 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); - }, - }; + let conn = rusqlite::Connection::open(&db_path)?; + let db = db::SqliteClipboardDb::new(conn)?; match cli.command { Some(Command::Store) => { @@ -345,12 +344,12 @@ fn main() { &[], ); }, + None => { - if let Err(e) = Cli::command().print_help() { - log::error!("Failed to print help: {e}"); - } + Cli::command().print_help()?; println!(); }, } - }); + Ok(()) + }) } diff --git a/src/multicall/mod.rs b/src/multicall/mod.rs index 46f19f3..5f1c795 100644 --- a/src/multicall/mod.rs +++ b/src/multicall/mod.rs @@ -4,41 +4,3 @@ // 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; - -use std::env; - -/// Extract the base name from argv[0]. -fn get_base(argv0: &str) -> &str { - std::path::Path::new(argv0) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("") -} - -/// Dispatch multicall binary logic based on `argv[0]`. -/// Returns `true` if a multicall command was handled and the process should -/// exit. -pub fn multicall_dispatch() -> bool { - let argv0 = env::args().next().unwrap_or_else(|| { - log::warn!("unable to determine program name"); - String::new() - }); - let base = get_base(&argv0); - match base { - "stash-copy" | "wl-copy" => { - if let Err(e) = wl_copy::wl_copy_main() { - log::error!("copy failed: {e}"); - std::process::exit(1); - } - true - }, - "stash-paste" | "wl-paste" => { - if let Err(e) = wl_paste::wl_paste_main() { - log::error!("paste failed: {e}"); - std::process::exit(1); - } - true - }, - _ => false, - } -} From 9fc118a9242c370828baabef161e7fac15e31058 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 09:54:29 +0000 Subject: [PATCH 042/121] 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] --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d40b375..4c7670e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -335,9 +335,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.5.49" +version = "4.5.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4512b90fa68d3a9932cea5184017c5d200f5921df706d45e853537dea51508f" +checksum = "0c2cfd7bf8a6017ddaa4e32ffe7403d547790db06bd171c1c53926faab501623" dependencies = [ "clap_builder", "clap_derive", @@ -355,9 +355,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.49" +version = "4.5.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0025e98baa12e766c67ba13ff4695a887a1eba19569aad00a472546795bd6730" +checksum = "0a4c05b9e80c5ccd3a7ef080ad7b6ba7d6fc00a985b8b157197075677c82c7a0" dependencies = [ "anstream", "anstyle", diff --git a/Cargo.toml b/Cargo.toml index 103ec7d..721afd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ use-toplevel = ["dep:wayland-client", "dep:wayland-protocols-wlr"] notifications = ["dep:notify-rust"] [dependencies] -clap = { version = "4.5.48", features = ["derive", "env"] } +clap = { version = "4.5.50", features = ["derive", "env"] } clap-verbosity-flag = "3.0.4" ctrlc = "3.5.0" color-eyre = "0.6.5" From 61ff65e9e8f427d557640b802c8fd5d6f5b1990a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 27 Oct 2025 17:13:40 +0300 Subject: [PATCH 043/121] stash: make log messages lowercase Signed-off-by: NotAShelf Change-Id: I45a9055b6bc3bfbf2179627470d0cedd6a6a6964 --- src/main.rs | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/main.rs b/src/main.rs index d36af5f..d925c97 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,7 +45,7 @@ struct Cli { preview_width: u32, /// Path to the `SQLite` clipboard database file. - #[arg(long)] + #[arg(long, env = "STASH_DB_PATH")] db_path: Option, /// Application names to exclude from clipboard history @@ -183,7 +183,7 @@ fn main() -> color_eyre::eyre::Result<()> { #[cfg(not(feature = "use-toplevel"))] &[], ), - "Failed to store entry", + "failed to store entry", ); }, Some(Command::List { format }) => { @@ -191,7 +191,7 @@ fn main() -> color_eyre::eyre::Result<()> { Some("tsv") => { report_error( db.list(io::stdout(), cli.preview_width), - "Failed to list entries", + "failed to list entries", ); }, Some("json") => { @@ -200,23 +200,23 @@ fn main() -> color_eyre::eyre::Result<()> { println!("{json}"); }, Err(e) => { - log::error!("Failed to list entries as JSON: {e}"); + log::error!("failed to list entries as JSON: {e}"); }, } }, Some(other) => { - log::error!("Unsupported format: {other}"); + log::error!("unsupported format: {other}"); }, None => { if std::io::stdout().is_terminal() { report_error( db.list_tui(cli.preview_width), - "Failed to list entries in TUI", + "failed to list entries in TUI", ); } else { report_error( db.list(io::stdout(), cli.preview_width), - "Failed to list entries", + "failed to list entries", ); } }, @@ -225,7 +225,7 @@ fn main() -> color_eyre::eyre::Result<()> { Some(Command::Decode { input }) => { report_error( db.decode(io::stdin(), io::stdout(), input), - "Failed to decode entry", + "failed to decode entry", ); }, Some(Command::Delete { arg, r#type, ask }) => { @@ -238,7 +238,7 @@ fn main() -> color_eyre::eyre::Result<()> { .unwrap_or(false); if !should_proceed { - log::info!("Aborted by user."); + log::info!("aborted by user."); } } if should_proceed { @@ -251,13 +251,13 @@ fn main() -> color_eyre::eyre::Result<()> { "Failed to delete entry by id", ); } else { - log::error!("Argument is not a valid id"); + log::error!("argument is not a valid id"); } }, (Some(s), Some("query")) => { report_error( db.query_delete(&s), - "Failed to delete entry by query", + "failed to delete entry by query", ); }, (Some(s), None) => { @@ -265,23 +265,23 @@ fn main() -> color_eyre::eyre::Result<()> { use std::io::Cursor; report_error( db.delete(Cursor::new(format!("{id}\n"))), - "Failed to delete entry by id", + "failed to delete entry by id", ); } else { report_error( db.query_delete(&s), - "Failed to delete entry by query", + "failed to delete entry by query", ); } }, (None, _) => { report_error( db.delete(io::stdin()), - "Failed to delete entry from stdin", + "failed to delete entry from stdin", ); }, (_, Some(_)) => { - log::error!("Unknown type for --type. Use \"id\" or \"query\"."); + log::error!("unknown type for --type. Use \"id\" or \"query\"."); }, } } @@ -296,11 +296,11 @@ fn main() -> color_eyre::eyre::Result<()> { .prompt() .unwrap_or(false); if !should_proceed { - log::info!("Aborted by user."); + log::info!("wipe command aborted by user."); } } if should_proceed { - report_error(db.wipe(), "Failed to wipe database"); + report_error(db.wipe(), "failed to wipe database"); } }, @@ -315,7 +315,7 @@ fn main() -> color_eyre::eyre::Result<()> { .prompt() .unwrap_or(false); if !should_proceed { - log::info!("Aborted by user."); + log::info!("import command aborted by user."); } } if should_proceed { @@ -325,11 +325,11 @@ fn main() -> color_eyre::eyre::Result<()> { if let Err(e) = ImportCommand::import_tsv(&db, io::stdin(), cli.max_items) { - log::error!("Failed to import TSV: {e}"); + log::error!("failed to import TSV: {e}"); } }, _ => { - log::error!("Unsupported import format: {format}"); + log::error!("unsupported import format: {format}"); }, } } From 96089f364b976b9d97c54fc703a3a9224ed2e401 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 28 Oct 2025 13:02:51 +0300 Subject: [PATCH 044/121] docs: fix typo Signed-off-by: NotAShelf Change-Id: I9b29db50afe2f2768bd4bc260bc5aaf96a6a6964 --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index f5eba93..c3fd56c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,6 @@ Lightweight 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. - line.
From 2d8ccf2a4f7b71ab6a384d400a5887374fafbccb Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 12 Nov 2025 23:49:06 +0300 Subject: [PATCH 045/121] multicall: go back to forking solution Signed-off-by: NotAShelf Change-Id: I2a24a3c7efc41fc45c675fd98e08782e6a6a6964 --- Cargo.lock | 1 + Cargo.toml | 1 + src/multicall/wl_copy.rs | 60 ++++++++++++++++++++++++++-------------- 3 files changed, 42 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4c7670e..a0a22c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1664,6 +1664,7 @@ dependencies = [ "env_logger", "imagesize", "inquire", + "libc", "log", "notify-rust", "ratatui", diff --git a/Cargo.toml b/Cargo.toml index 721afd9..dc491e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ imagesize = "0.14.0" inquire = { default-features = false, version = "0.9.1", features = [ "crossterm", ] } +libc = "0.2.177" log = "0.4.28" env_logger = "0.11.8" thiserror = "2.0.17" diff --git a/src/multicall/wl_copy.rs b/src/multicall/wl_copy.rs index cab79f5..3794420 100644 --- a/src/multicall/wl_copy.rs +++ b/src/multicall/wl_copy.rs @@ -106,7 +106,7 @@ fn handle_check_primary() { std::process::exit(exit_code); } -fn get_clipboard_type(primary: bool) -> CopyClipboardType { +const fn get_clipboard_type(primary: bool) -> CopyClipboardType { if primary { CopyClipboardType::Primary } else { @@ -213,21 +213,32 @@ fn handle_clear_clipboard( } fn fork_and_serve(prepared_copy: wl_clipboard_rs::copy::PreparedCopy) { - // Use a simpler approach: serve in background thread instead of forking - // This avoids all the complexity and safety issues with fork() - let handle = std::thread::spawn(move || { - if let Err(e) = prepared_copy.serve() { - log::error!("background clipboard service failed: {e}"); + // 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); + }, } - }); - - // Give the background thread a moment to start - std::thread::sleep(std::time::Duration::from_millis(50)); - log::debug!("clipboard service started in background thread"); - - // Detach the thread to allow it to run independently - // The thread will be cleaned up when it completes or when the process exits - std::mem::forget(handle); + } } pub fn wl_copy_main() -> Result<()> { @@ -255,14 +266,23 @@ pub fn wl_copy_main() -> Result<()> { // Handle foreground vs background mode if args.foreground { - // Foreground mode: copy and serve in current process - opts - .copy(Source::Bytes(input.into()), mime_type) - .context("failed to copy to clipboard")?; + // 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.clone(); + let mut opts_fg = opts; opts_fg.foreground(true); let prepared_copy = opts_fg From a68946d54dfdf0f9aeeab2e82543f65674021ebd Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 13 Nov 2025 00:05:20 +0300 Subject: [PATCH 046/121] various: fix clippy lints Signed-off-by: NotAShelf Change-Id: I4bb649a5161460d8794dc5c93baa6cc46a6a6964 --- src/commands/query.rs | 2 +- src/db/mod.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/query.rs b/src/commands/query.rs index c5b5851..e1bd465 100644 --- a/src/commands/query.rs +++ b/src/commands/query.rs @@ -6,6 +6,6 @@ pub trait QueryCommand { impl QueryCommand for SqliteClipboardDb { fn query_delete(&self, query: &str) -> Result { - ::delete_query(self, query) + ::delete_query(self, query) } } diff --git a/src/db/mod.rs b/src/db/mod.rs index fa27cce..0b906d5 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -266,7 +266,7 @@ impl ClipboardDb for SqliteClipboardDb { .execute( "INSERT INTO clipboard (contents, mime, content_hash) VALUES (?1, ?2, \ ?3)", - params![buf, mime.map(|s| s.to_string()), content_hash], + params![buf, mime, content_hash], ) .map_err(|e| StashError::Store(e.to_string().into()))?; From c8ead9a30870fa4b3e13cd922d33d402d84454c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Nov 2025 14:26:17 +0000 Subject: [PATCH 047/121] 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] --- .github/workflows/nix-cache.yaml | 2 +- .github/workflows/release.yaml | 6 +++--- .github/workflows/rust.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/nix-cache.yaml b/.github/workflows/nix-cache.yaml index 2ffb411..9a9b4dc 100644 --- a/.github/workflows/nix-cache.yaml +++ b/.github/workflows/nix-cache.yaml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: "Checkout" - uses: actions/checkout@v5 + uses: actions/checkout@v6 - uses: cachix/install-nix-action@v31 with: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ab9d8a6..62bfdd3 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - uses: cachix/install-nix-action@v31 with: @@ -62,7 +62,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -106,7 +106,7 @@ jobs: needs: [create-release, build-release] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Download Assets uses: robinraju/release-downloader@v1 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 360ef75..0ae86c8 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Build run: cargo build --verbose From 8a25a03486d08ce4cc7488dd69ed050d0e382131 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 22 Dec 2025 16:06:38 +0300 Subject: [PATCH 048/121] flake: bump nixpkgs Signed-off-by: NotAShelf Change-Id: I4572c7075e11282280d514ffde4361586a6a6964 --- flake.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/flake.lock b/flake.lock index 02d5006..62a0021 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1754269165, - "narHash": "sha256-0tcS8FHd4QjbCVoxN9jI+PjHgA4vc/IjkUSp+N3zy0U=", + "lastModified": 1766194365, + "narHash": "sha256-4AFsUZ0kl6MXSm4BaQgItD0VGlEKR3iq7gIaL7TjBvc=", "owner": "ipetkov", "repo": "crane", - "rev": "444e81206df3f7d92780680e45858e31d2f07a08", + "rev": "7d8ec2c71771937ab99790b45e6d9b93d15d9379", "type": "github" }, "original": { @@ -17,11 +17,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1754725699, - "narHash": "sha256-iAcj9T/Y+3DBy2J0N+yF9XQQQ8IEb5swLFzs23CdP88=", + "lastModified": 1766309749, + "narHash": "sha256-3xY8CZ4rSnQ0NqGhMKAy5vgC+2IVK0NoVEzDoOh4DA4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "85dbfc7aaf52ecb755f87e577ddbe6dbbdbc1054", + "rev": "a6531044f6d0bef691ea18d4d4ce44d0daa6e816", "type": "github" }, "original": { From c2182d21dc4ddae82577c83f9e88ea33236fb3fa Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 22 Dec 2025 16:45:19 +0300 Subject: [PATCH 049/121] chore: bump dependencies; fix lifetime warnings for Rust 1.90+ Signed-off-by: NotAShelf Change-Id: If755ceefb970311c7660118cb2019c2c6a6a6964 --- Cargo.lock | 106 ++++++++++++++++++++++++++++++++++---- Cargo.toml | 2 +- src/commands/list.rs | 4 +- src/db/mod.rs | 33 ++++++------ src/multicall/wl_paste.rs | 2 +- 5 files changed, 116 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4c7670e..17fa67e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -296,6 +296,12 @@ dependencies = [ "piper", ] +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + [[package]] name = "cassowary" version = "0.3.0" @@ -776,6 +782,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "futures-core" version = "0.3.31" @@ -838,22 +850,25 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -899,7 +914,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.15.5", ] [[package]] @@ -979,6 +994,16 @@ dependencies = [ "syn", ] +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1003,9 +1028,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.35.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" dependencies = [ "cc", "pkg-config", @@ -1460,9 +1485,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "rusqlite" -version = "0.37.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ "bitflags", "fallible-iterator", @@ -1470,6 +1495,7 @@ dependencies = [ "hashlink", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", ] [[package]] @@ -1650,6 +1676,19 @@ dependencies = [ "futures-lite", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05e98301bf8b0540c7de45ecd760539b9c62f5772aed172f08efba597c11cd5d" +dependencies = [ + "cc", + "hashbrown 0.16.1", + "js-sys", + "thiserror", + "wasm-bindgen", +] + [[package]] name = "stash-clipboard" version = "0.3.2" @@ -1981,6 +2020,51 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wayland-backend" version = "0.3.11" diff --git a/Cargo.toml b/Cargo.toml index 721afd9..236a146 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ log = "0.4.28" env_logger = "0.11.8" thiserror = "2.0.17" wl-clipboard-rs = "0.9.2" -rusqlite = { version = "0.37.0", features = ["bundled"] } +rusqlite = { version = "0.38.0", features = ["bundled"] } smol = "2.0.2" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" diff --git a/src/commands/list.rs b/src/commands/list.rs index c2be9c1..e501b45 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -61,14 +61,14 @@ impl SqliteClipboardDb { .query([]) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut entries: Vec<(u64, String, String)> = Vec::new(); + let mut entries: Vec<(i64, String, String)> = Vec::new(); let mut max_id_width = 2; let mut max_mime_width = 8; while let Some(row) = rows .next() .map_err(|e| StashError::ListDecode(e.to_string().into()))? { - let id: u64 = row + let id: i64 = row .get(0) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let contents: Vec = row diff --git a/src/db/mod.rs b/src/db/mod.rs index fa27cce..124384d 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -56,7 +56,7 @@ pub enum StashError { #[error("Failed to delete entry during query delete: {0}")] QueryDelete(Box), #[error("Failed to delete entry with id {0}: {1}")] - DeleteEntry(u64, Box), + DeleteEntry(i64, Box), } pub trait ClipboardDb { @@ -66,7 +66,7 @@ pub trait ClipboardDb { max_dedupe_search: u64, max_items: u64, excluded_apps: Option<&[String]>, - ) -> Result; + ) -> Result; fn deduplicate_by_hash( &self, @@ -89,7 +89,7 @@ pub trait ClipboardDb { ) -> Result<(), StashError>; fn delete_query(&self, query: &str) -> Result; fn delete_entries(&self, input: impl Read) -> Result; - fn next_sequence(&self) -> u64; + fn next_sequence(&self) -> i64; } #[derive(Serialize, Deserialize)] @@ -184,7 +184,7 @@ impl SqliteClipboardDb { .next() .map_err(|e| StashError::ListDecode(e.to_string().into()))? { - let id: u64 = row + let id: i64 = row .get(0) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let contents: Vec = row @@ -219,7 +219,7 @@ impl ClipboardDb for SqliteClipboardDb { max_dedupe_search: u64, max_items: u64, excluded_apps: Option<&[String]>, - ) -> Result { + ) -> Result { let mut buf = Vec::new(); if input.read_to_end(&mut buf).is_err() || buf.is_empty() @@ -297,7 +297,7 @@ impl ClipboardDb for SqliteClipboardDb { .next() .map_err(|e| StashError::DeduplicationRead(e.to_string().into()))? { - let id: u64 = row + let id: i64 = row .get(0) .map_err(|e| StashError::DeduplicationDecode(e.to_string().into()))?; self @@ -310,12 +310,13 @@ impl ClipboardDb for SqliteClipboardDb { } fn trim_db(&self, max: u64) -> Result<(), StashError> { - let count: u64 = self + let count: i64 = self .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) .map_err(|e| StashError::Trim(e.to_string().into()))?; - if count > max { - let to_delete = count - max; + let max_i64 = i64::try_from(max).unwrap_or(i64::MAX); + if count > max_i64 { + let to_delete = count - max_i64; self .conn .execute( @@ -329,7 +330,7 @@ impl ClipboardDb for SqliteClipboardDb { } fn delete_last(&self) -> Result<(), StashError> { - let id: Option = self + let id: Option = self .conn .query_row( "SELECT id FROM clipboard ORDER BY id DESC LIMIT 1", @@ -379,7 +380,7 @@ impl ClipboardDb for SqliteClipboardDb { .next() .map_err(|e| StashError::ListDecode(e.to_string().into()))? { - let id: u64 = row + let id: i64 = row .get(0) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let contents: Vec = row @@ -413,7 +414,7 @@ impl ClipboardDb for SqliteClipboardDb { .map_err(|e| StashError::DecodeExtractId(e.to_string().into()))?; buf }; - let id = extract_id(&input_str) + let id: i64 = extract_id(&input_str) .map_err(|e| StashError::DecodeExtractId(e.into()))?; let (contents, _mime): (Vec, Option) = self .conn @@ -443,7 +444,7 @@ impl ClipboardDb for SqliteClipboardDb { .next() .map_err(|e| StashError::QueryDelete(e.to_string().into()))? { - let id: u64 = row + let id: i64 = row .get(0) .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; let contents: Vec = row @@ -475,11 +476,11 @@ impl ClipboardDb for SqliteClipboardDb { Ok(deleted) } - fn next_sequence(&self) -> u64 { + fn next_sequence(&self) -> i64 { match self .conn .query_row("SELECT MAX(id) FROM clipboard", [], |row| { - row.get::<_, Option>(0) + row.get::<_, Option>(0) }) { Ok(Some(max_id)) => max_id + 1, Ok(None) | Err(_) => 1, @@ -519,7 +520,7 @@ fn load_sensitive_regex() -> Option { REGEX_CACHE.get().and_then(std::clone::Clone::clone) } -pub fn extract_id(input: &str) -> Result { +pub fn extract_id(input: &str) -> Result { let id_str = input.split('\t').next().unwrap_or(""); id_str.parse().map_err(|_| "invalid id") } diff --git a/src/multicall/wl_paste.rs b/src/multicall/wl_paste.rs index 909926d..6aae54f 100644 --- a/src/multicall/wl_paste.rs +++ b/src/multicall/wl_paste.rs @@ -66,7 +66,7 @@ struct WlPasteArgs { watch: Option>, } -fn get_paste_mime_type(mime_arg: Option<&str>) -> PasteMimeType { +fn get_paste_mime_type(mime_arg: Option<&str>) -> PasteMimeType<'_> { match mime_arg { None | Some("text" | "autodetect") => PasteMimeType::Text, Some(other) => PasteMimeType::Specific(other), From f6818c9e6f2e4da4f8e7966ba4a96a317e1ad530 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 22 Dec 2025 16:54:56 +0300 Subject: [PATCH 050/121] chore: release v0.3.3 Signed-off-by: NotAShelf Change-Id: I5d53159c67ea83260c213ea93114b3d96a6a6964 --- Cargo.lock | 2 +- Cargo.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 17fa67e..26c2361 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1691,7 +1691,7 @@ dependencies = [ [[package]] name = "stash-clipboard" -version = "0.3.2" +version = "0.3.3" dependencies = [ "base64", "clap", diff --git a/Cargo.toml b/Cargo.toml index 236a146..3a3e43d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "stash-clipboard" description = "Wayland clipboard manager with fast persistent history and multi-media support" -version = "0.3.2" +version = "0.3.3" edition = "2024" authors = ["NotAShelf "] license = "MPL-2.0" readme = true repository = "https://github.com/notashelf/stash" -rust-version = "1.85" +rust-version = "1.90" [[bin]] name = "stash" # actual binary name for Nix, Cargo, etc. From bbfe5834230f62c909b8855c855c54eb0ab8f5a8 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 23 Dec 2025 09:15:21 +0300 Subject: [PATCH 051/121] 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 Change-Id: I8b1e6f7013d081150be761820cafd1926a6a6964 --- src/multicall/wl_paste.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/multicall/wl_paste.rs b/src/multicall/wl_paste.rs index 6aae54f..594dbfb 100644 --- a/src/multicall/wl_paste.rs +++ b/src/multicall/wl_paste.rs @@ -366,7 +366,7 @@ fn handle_regular_paste( let mime_type = get_paste_mime_type(args.mime_type.as_deref()); match get_contents(clipboard, seat, mime_type) { - Ok((mut reader, _types)) => { + Ok((mut reader, types)) => { let mut out = io::stdout(); let mut buf = Vec::new(); let mut temp_buffer = [0; 8192]; @@ -396,7 +396,20 @@ fn handle_regular_paste( if let Err(e) = out.write_all(&buf) { bail!("failed to write to stdout: {e}"); } - if !args.no_newline && !buf.ends_with(b"\n") { + + // 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() { + types.starts_with("text/") + || types == "application/json" + || types == "application/xml" + || types == "application/x-sh" + } else { + // If no MIME type, check if content is valid UTF-8 + std::str::from_utf8(&buf).is_ok() + }; + + if !args.no_newline && is_text_content && !buf.ends_with(b"\n") { if let Err(e) = out.write_all(b"\n") { bail!("failed to write newline to stdout: {e}"); } From f2274aa524c16001887f0d7af4e68bef683b216c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 23 Dec 2025 10:09:47 +0300 Subject: [PATCH 052/121] multicall: auto-select MIME type more intelligently when not specified Signed-off-by: NotAShelf Change-Id: Idfd5ab25079161d694bda429e70500a16a6a6964 --- src/multicall/wl_paste.rs | 74 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/src/multicall/wl_paste.rs b/src/multicall/wl_paste.rs index 594dbfb..af686c4 100644 --- a/src/multicall/wl_paste.rs +++ b/src/multicall/wl_paste.rs @@ -358,12 +358,74 @@ fn execute_watch_command( 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, +) -> Option { + 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<()> { - let mime_type = get_paste_mime_type(args.mime_type.as_deref()); + // 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)) => { @@ -409,10 +471,12 @@ fn handle_regular_paste( std::str::from_utf8(&buf).is_ok() }; - if !args.no_newline && is_text_content && !buf.ends_with(b"\n") { - if let Err(e) = out.write_all(b"\n") { - bail!("failed to write newline to stdout: {e}"); - } + 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) => { From 59423f9ae4595e78d6a634b2ec61c6b517f53d88 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 20 Jan 2026 10:14:32 +0300 Subject: [PATCH 053/121] 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 Change-Id: Icbcdbd6ac28bbb21324785cae30911f96a6a6964 --- src/commands/list.rs | 90 ++++++++++++---------- src/db/mod.rs | 173 +++++++++++++++++++++++++++++++------------ src/wayland/mod.rs | 13 +--- 3 files changed, 179 insertions(+), 97 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index e501b45..c1e0164 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -55,7 +55,10 @@ impl SqliteClipboardDb { // Query entries from DB let mut stmt = self .conn - .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") + .prepare( + "SELECT id, contents, mime FROM clipboard ORDER BY last_accessed \ + DESC, id DESC", + ) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -242,8 +245,7 @@ impl SqliteClipboardDb { if event::poll(std::time::Duration::from_millis(250)) .map_err(|e| StashError::ListDecode(e.to_string().into()))? - { - if let Event::Key(key) = event::read() + && let Event::Key(key) = event::read() .map_err(|e| StashError::ListDecode(e.to_string().into()))? { match (key.code, key.modifiers) { @@ -275,50 +277,62 @@ impl SqliteClipboardDb { state.select(Some(i)); }, (KeyCode::Enter, _) => { - if let Some(idx) = state.selected() { - if let Some((id, ..)) = entries.get(idx) { - // Fetch full contents for the selected entry - let (contents, mime): (Vec, Option) = self - .conn - .query_row( - "SELECT contents, mime FROM clipboard WHERE id = ?1", - rusqlite::params![id], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .map_err(|e| { - StashError::ListDecode(e.to_string().into()) - })?; - // Copy to clipboard - let opts = Options::new(); - // Default clipboard is regular, seat is default - let mime_type = match mime { - Some(ref m) if m == "text/plain" => MimeType::Text, - Some(ref m) => MimeType::Specific(m.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(); + if let Some(idx) = state.selected() + && let Some((id, ..)) = entries.get(idx) { + match self.copy_entry(*id) { + Ok((new_id, contents, mime)) => { + if new_id != *id { + entries[idx] = ( + new_id, + entries[idx].1.clone(), + entries[idx].2.clone(), + ); + } + 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().to_owned()) + }, + 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 copy entry to clipboard: {e}"); + log::error!("Failed to fetch entry {id}: {e}"); let _ = Notification::new() .summary("Stash") - .body(&format!("Failed to copy to clipboard: {e}")) + .body(&format!("Failed to fetch entry: {e}")) .show(); }, } } - } }, (KeyCode::Char('D'), KeyModifiers::SHIFT) => { - if let Some(idx) = state.selected() { - if let Some((id, ..)) = entries.get(idx) { + if let Some(idx) = state.selected() + && let Some((id, ..)) = entries.get(idx) { // Delete entry from DB self .conn @@ -345,12 +359,10 @@ impl SqliteClipboardDb { .body("Deleted entry") .show(); } - } }, _ => {}, } } - } } Ok(()) })(); diff --git a/src/db/mod.rs b/src/db/mod.rs index 469ec2a..b5445a7 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -89,6 +89,10 @@ pub trait ClipboardDb { ) -> Result<(), StashError>; fn delete_query(&self, query: &str) -> Result; fn delete_entries(&self, input: impl Read) -> Result; + fn copy_entry( + &self, + id: i64, + ) -> Result<(i64, Vec, Option), StashError>; fn next_sequence(&self) -> i64; } @@ -149,17 +153,44 @@ impl SqliteClipboardDb { ) .map_err(|e| StashError::Store(e.to_string().into()))?; + conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS clipboard ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contents BLOB NOT NULL, + mime TEXT, + content_hash INTEGER, + last_accessed INTEGER DEFAULT (CAST(strftime('%s', 'now') AS \ + INTEGER)) + );", + ) + .map_err(|e| StashError::Store(e.to_string().into()))?; + // Add content_hash column if it doesn't exist // Migration MUST be done to avoid breaking existing installations. let _ = conn.execute("ALTER TABLE clipboard ADD COLUMN content_hash INTEGER", []); + // Add last_accessed column if it doesn't exist + let _ = conn.execute( + "ALTER TABLE clipboard ADD COLUMN last_accessed INTEGER DEFAULT \ + (CAST(strftime('%s', 'now') AS INTEGER))", + [], + ); + // Create index for content_hash if it doesn't exist let _ = conn.execute( "CREATE INDEX IF NOT EXISTS idx_content_hash ON clipboard(content_hash)", [], ); + // Create index for last_accessed if it doesn't exist + let _ = conn.execute( + "CREATE INDEX IF NOT EXISTS idx_last_accessed ON \ + clipboard(last_accessed)", + [], + ); + // Initialize Wayland state in background thread. This will be used to track // focused window state. #[cfg(feature = "use-toplevel")] @@ -172,7 +203,10 @@ impl SqliteClipboardDb { pub fn list_json(&self) -> Result { let mut stmt = self .conn - .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") + .prepare( + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) DESC, id DESC", + ) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -243,11 +277,11 @@ impl ClipboardDb for SqliteClipboardDb { let regex = load_sensitive_regex(); if let Some(re) = regex { // Only check text data - if let Ok(s) = std::str::from_utf8(&buf) { - if re.is_match(s) { - warn!("Clipboard entry matches sensitive regex, skipping store."); - return Err(StashError::Store("Filtered by sensitive regex".into())); - } + if let Ok(s) = std::str::from_utf8(&buf) + && re.is_match(s) + { + warn!("Clipboard entry matches sensitive regex, skipping store."); + return Err(StashError::Store("Filtered by sensitive regex".into())); } } @@ -317,6 +351,8 @@ impl ClipboardDb for SqliteClipboardDb { let max_i64 = i64::try_from(max).unwrap_or(i64::MAX); if count > max_i64 { let to_delete = count - max_i64; + + #[allow(clippy::useless_conversion)] self .conn .execute( @@ -369,7 +405,10 @@ impl ClipboardDb for SqliteClipboardDb { ) -> Result { let mut stmt = self .conn - .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") + .prepare( + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) DESC, id DESC", + ) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -476,6 +515,48 @@ impl ClipboardDb for SqliteClipboardDb { Ok(deleted) } + fn copy_entry( + &self, + id: i64, + ) -> Result<(i64, Vec, Option), StashError> { + let (contents, mime, content_hash): (Vec, Option, Option) = + self + .conn + .query_row( + "SELECT contents, mime, content_hash FROM clipboard WHERE id = ?1", + params![id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; + + if let Some(hash) = content_hash { + let most_recent_id: Option = self + .conn + .query_row( + "SELECT id FROM clipboard WHERE content_hash = ?1 AND last_accessed \ + = (SELECT MAX(last_accessed) FROM clipboard WHERE content_hash = \ + ?1)", + params![hash], + |row| row.get(0), + ) + .optional() + .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; + + if most_recent_id != Some(id) { + self + .conn + .execute( + "UPDATE clipboard SET last_accessed = CAST(strftime('%s', 'now') \ + AS INTEGER) WHERE id = ?1", + params![id], + ) + .map_err(|e| StashError::Store(e.to_string().into()))?; + } + } + + Ok((id, contents, mime)) + } + fn next_sequence(&self) -> i64 { match self .conn @@ -693,11 +774,11 @@ fn get_focused_window_app() -> Option { } // Fallback: Check WAYLAND_CLIENT_NAME environment variable - if let Ok(client) = env::var("WAYLAND_CLIENT_NAME") { - if !client.is_empty() { - debug!("Found WAYLAND_CLIENT_NAME: {client}"); - return Some(client); - } + if let Ok(client) = env::var("WAYLAND_CLIENT_NAME") + && !client.is_empty() + { + debug!("Found WAYLAND_CLIENT_NAME: {client}"); + return Some(client); } debug!("No focused window detection method worked"); @@ -717,19 +798,17 @@ fn get_recently_active_excluded_app( if let Ok(entries) = std::fs::read_dir(proc_dir) { for entry in entries.flatten() { - if let Ok(pid) = entry.file_name().to_string_lossy().parse::() { - if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) { - let process_name = comm.trim(); + if let Ok(pid) = entry.file_name().to_string_lossy().parse::() + && let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) + { + let process_name = comm.trim(); - // Check process name against exclusion list - if app_matches_exclusion(process_name, excluded_apps) - && has_recent_activity(pid) - { - candidates.push(( - process_name.to_string(), - get_process_activity_score(pid), - )); - } + // Check process name against exclusion list + if app_matches_exclusion(process_name, excluded_apps) + && has_recent_activity(pid) + { + candidates + .push((process_name.to_string(), get_process_activity_score(pid))); } } } @@ -763,15 +842,13 @@ fn has_recent_activity(pid: u32) -> bool { // Check /proc/PID/io for recent I/O activity if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) { for line in io_stats.lines() { - if line.starts_with("write_bytes:") || line.starts_with("read_bytes:") { - if let Some(value_str) = line.split(':').nth(1) { - if let Ok(value) = value_str.trim().parse::() { - if value > 1024 * 1024 { - // 1MB threshold - return true; - } - } - } + if (line.starts_with("write_bytes:") || line.starts_with("read_bytes:")) + && let Some(value_str) = line.split(':').nth(1) + && let Ok(value) = value_str.trim().parse::() + && value > 1024 * 1024 + { + // 1MB threshold + return true; } } } @@ -786,24 +863,22 @@ fn get_process_activity_score(pid: u32) -> u64 { // Add CPU time to score if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { let fields: Vec<&str> = stat.split_whitespace().collect(); - if fields.len() > 14 { - if let (Ok(utime), Ok(stime)) = + if fields.len() > 14 + && let (Ok(utime), Ok(stime)) = (fields[13].parse::(), fields[14].parse::()) - { - score += utime + stime; - } + { + score += utime + stime; } } // Add I/O activity to score if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) { for line in io_stats.lines() { - if line.starts_with("write_bytes:") || line.starts_with("read_bytes:") { - if let Some(value_str) = line.split(':').nth(1) { - if let Ok(value) = value_str.trim().parse::() { - score += value / 1024; // convert to KB - } - } + if (line.starts_with("write_bytes:") || line.starts_with("read_bytes:")) + && let Some(value_str) = line.split(':').nth(1) + && let Ok(value) = value_str.trim().parse::() + { + score += value / 1024; // convert to KB } } } @@ -834,11 +909,11 @@ fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool { } else if excluded.contains('*') { // Simple wildcard matching let pattern = excluded.replace('*', ".*"); - if let Ok(regex) = regex::Regex::new(&pattern) { - if regex.is_match(app_name) { - debug!("Matched wildcard pattern: {app_name} matches {excluded}"); - return true; - } + if let Ok(regex) = regex::Regex::new(&pattern) + && regex.is_match(app_name) + { + debug!("Matched wildcard pattern: {app_name} matches {excluded}"); + return true; } } } diff --git a/src/wayland/mod.rs b/src/wayland/mod.rs index 016d609..17e655f 100644 --- a/src/wayland/mod.rs +++ b/src/wayland/mod.rs @@ -33,12 +33,11 @@ pub fn init_wayland_state() { /// Get the currently focused window application name using Wayland protocols pub fn get_focused_window_app() -> Option { // Try Wayland protocol first - if let Ok(focused) = FOCUSED_APP.lock() { - if let Some(ref app) = *focused { + if let Ok(focused) = FOCUSED_APP.lock() + && let Some(ref app) = *focused { debug!("Found focused app via Wayland protocol: {app}"); return Some(app.clone()); } - } debug!("No focused window detection method worked"); None @@ -81,12 +80,10 @@ impl Dispatch for AppState { interface, version: _, } = event - { - if interface == "zwlr_foreign_toplevel_manager_v1" { + && interface == "zwlr_foreign_toplevel_manager_v1" { let _manager: ZwlrForeignToplevelManagerV1 = registry.bind(name, 1, qh, ()); } - } } fn event_created_child( @@ -155,12 +152,10 @@ impl Dispatch for AppState { // Update focused app to the `app_id` of this handle if let (Ok(apps), Ok(mut focused)) = (TOPLEVEL_APPS.lock(), FOCUSED_APP.lock()) - { - if let Some(app_id) = apps.get(&handle_id) { + && let Some(app_id) = apps.get(&handle_id) { debug!("Setting focused app to: {app_id}"); *focused = Some(app_id.clone()); } - } } }, zwlr_foreign_toplevel_handle_v1::Event::Closed => { From dca7cca4550b21eef687c7db063327be60a6f59f Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 11:27:32 +0300 Subject: [PATCH 054/121] nix: add cargo-nextest to devshell Signed-off-by: NotAShelf Change-Id: I2266c2f3fccff23fa3950f8fac3365f36a6a6964 --- nix/shell.nix | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nix/shell.nix b/nix/shell.nix index 9df0432..273d74a 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -6,6 +6,7 @@ clippy, taplo, rust-analyzer-unwrapped, + cargo-nextest, rustPlatform, }: mkShell { @@ -20,6 +21,9 @@ mkShell { cargo taplo rust-analyzer-unwrapped + + # Additional Cargo Tooling + cargo-nextest ]; RUST_SRC_PATH = "${rustPlatform.rustLibSrc}"; From 20b6a12461443d7efe930577e66b18a7e1365b2c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 12:22:07 +0300 Subject: [PATCH 055/121] stash: make db module public for test visibility Signed-off-by: NotAShelf Change-Id: I5f75e6515114e7479a3fe63771a4e7fe6a6a6964 --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index d925c97..28f9fb0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,7 +8,7 @@ use clap::{CommandFactory, Parser, Subcommand}; use inquire::Confirm; mod commands; -mod db; +pub(crate) mod db; mod multicall; #[cfg(feature = "use-toplevel")] mod wayland; From 31655435804dd7ea67982a5ea4b08eb8f2e14932 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 13:28:43 +0300 Subject: [PATCH 056/121] commands: prevent usize underflow when navigating empty entry list Signed-off-by: NotAShelf Change-Id: I0432dcc88b22226772f6bb6e05cc64d36a6a6964 --- src/commands/list.rs | 172 ++++++++++++++++++++++--------------------- 1 file changed, 87 insertions(+), 85 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index c1e0164..1afab2a 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -247,10 +247,13 @@ impl SqliteClipboardDb { .map_err(|e| StashError::ListDecode(e.to_string().into()))? && let Event::Key(key) = event::read() .map_err(|e| StashError::ListDecode(e.to_string().into()))? - { - match (key.code, key.modifiers) { - (KeyCode::Char('q') | KeyCode::Esc, _) => break, - (KeyCode::Down | KeyCode::Char('j'), _) => { + { + match (key.code, key.modifiers) { + (KeyCode::Char('q') | KeyCode::Esc, _) => break, + (KeyCode::Down | KeyCode::Char('j'), _) => { + if entries.is_empty() { + state.select(None); + } else { let i = match state.selected() { Some(i) => { if i >= entries.len() - 1 { @@ -262,8 +265,12 @@ impl SqliteClipboardDb { None => 0, }; state.select(Some(i)); - }, - (KeyCode::Up | KeyCode::Char('k'), _) => { + } + }, + (KeyCode::Up | KeyCode::Char('k'), _) => { + if entries.is_empty() { + state.select(None); + } else { let i = match state.selected() { Some(i) => { if i == 0 { @@ -275,94 +282,89 @@ impl SqliteClipboardDb { None => 0, }; state.select(Some(i)); - }, - (KeyCode::Enter, _) => { - if let Some(idx) = state.selected() - && let Some((id, ..)) = entries.get(idx) { - match self.copy_entry(*id) { - Ok((new_id, contents, mime)) => { - if new_id != *id { - entries[idx] = ( - new_id, - entries[idx].1.clone(), - entries[idx].2.clone(), - ); - } - 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().to_owned()) - }, - 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}"); + } + }, + (KeyCode::Enter, _) => { + if let Some(idx) = state.selected() + && let Some((id, ..)) = entries.get(idx) + { + match self.copy_entry(*id) { + Ok((new_id, contents, mime)) => { + if new_id != *id { + entries[idx] = ( + new_id, + entries[idx].1.clone(), + entries[idx].2.clone(), + ); + } + 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().to_owned()), + 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(&format!("Failed to fetch entry: {e}")) + .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(); }, } - } - }, - (KeyCode::Char('D'), KeyModifiers::SHIFT) => { - if let Some(idx) = state.selected() - && let Some((id, ..)) = entries.get(idx) { - // Delete entry from DB - self - .conn - .execute( - "DELETE FROM clipboard WHERE id = ?1", - rusqlite::params![id], - ) - .map_err(|e| { - StashError::DeleteEntry(*id, e.to_string().into()) - })?; - // Remove from entries and update selection - entries.remove(idx); - let new_len = entries.len(); - if new_len == 0 { - state.select(None); - } else if idx >= new_len { - state.select(Some(new_len - 1)); - } else { - state.select(Some(idx)); - } - // Show notification + }, + Err(e) => { + log::error!("Failed to fetch entry {id}: {e}"); let _ = Notification::new() .summary("Stash") - .body("Deleted entry") + .body(&format!("Failed to fetch entry: {e}")) .show(); - } - }, - _ => {}, - } + }, + } + } + }, + (KeyCode::Char('D'), KeyModifiers::SHIFT) => { + if let Some(idx) = state.selected() + && let Some((id, ..)) = entries.get(idx) + { + // Delete entry from DB + self + .conn + .execute( + "DELETE FROM clipboard WHERE id = ?1", + rusqlite::params![id], + ) + .map_err(|e| { + StashError::DeleteEntry(*id, e.to_string().into()) + })?; + // Remove from entries and update selection + entries.remove(idx); + let new_len = entries.len(); + if new_len == 0 { + state.select(None); + } else if idx >= new_len { + state.select(Some(new_len - 1)); + } else { + state.select(Some(idx)); + } + // Show notification + let _ = Notification::new() + .summary("Stash") + .body("Deleted entry") + .show(); + } + }, + _ => {}, } + } } Ok(()) })(); From c65073e0d10fd0896346c019d22683caf21867d8 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 13:29:04 +0300 Subject: [PATCH 057/121] 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 Change-Id: Ifeab42b0816a5161d736767cb82065346a6a6964 --- src/db/mod.rs | 510 +++++++++++++++++++++++++++++++++++++++++---- src/wayland/mod.rs | 27 +-- 2 files changed, 487 insertions(+), 50 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index b5445a7..3d07a9e 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -114,7 +114,7 @@ pub struct SqliteClipboardDb { } impl SqliteClipboardDb { - pub fn new(conn: Connection) -> Result { + pub fn new(mut conn: Connection) -> Result { conn .pragma_update(None, "synchronous", "OFF") .map_err(|e| { @@ -143,53 +143,124 @@ impl SqliteClipboardDb { conn.pragma_update(None, "page_size", "512") // small(er) pages .map_err(|e| StashError::Store(format!("Failed to set page_size pragma: {e}").into()))?; - conn - .execute_batch( + let tx = conn.transaction().map_err(|e| { + StashError::Store( + format!("Failed to begin migration transaction: {e}").into(), + ) + })?; + + let schema_version: i64 = tx + .pragma_query_value(None, "user_version", |row| row.get(0)) + .map_err(|e| { + StashError::Store(format!("Failed to read schema version: {e}").into()) + })?; + + if schema_version == 0 { + tx.execute_batch( "CREATE TABLE IF NOT EXISTS clipboard ( id INTEGER PRIMARY KEY AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT );", ) - .map_err(|e| StashError::Store(e.to_string().into()))?; + .map_err(|e| { + StashError::Store( + format!("Failed to create clipboard table: {e}").into(), + ) + })?; - conn - .execute_batch( - "CREATE TABLE IF NOT EXISTS clipboard ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - contents BLOB NOT NULL, - mime TEXT, - content_hash INTEGER, - last_accessed INTEGER DEFAULT (CAST(strftime('%s', 'now') AS \ - INTEGER)) - );", - ) - .map_err(|e| StashError::Store(e.to_string().into()))?; + tx.execute("PRAGMA user_version = 1", []).map_err(|e| { + StashError::Store(format!("Failed to set schema version: {e}").into()) + })?; + } // Add content_hash column if it doesn't exist // Migration MUST be done to avoid breaking existing installations. - let _ = - conn.execute("ALTER TABLE clipboard ADD COLUMN content_hash INTEGER", []); + if schema_version < 2 { + let has_content_hash: bool = tx + .query_row( + "SELECT sql FROM sqlite_master WHERE type='table' AND \ + name='clipboard'", + [], + |row| { + let sql: String = row.get(0)?; + Ok(sql.to_lowercase().contains("content_hash")) + }, + ) + .unwrap_or(false); + + if !has_content_hash { + tx.execute("ALTER TABLE clipboard ADD COLUMN content_hash INTEGER", []) + .map_err(|e| { + StashError::Store( + format!("Failed to add content_hash column: {e}").into(), + ) + })?; + } + + // Create index for content_hash if it doesn't exist + tx.execute( + "CREATE INDEX IF NOT EXISTS idx_content_hash ON \ + clipboard(content_hash)", + [], + ) + .map_err(|e| { + StashError::Store( + format!("Failed to create content_hash index: {e}").into(), + ) + })?; + + tx.execute("PRAGMA user_version = 2", []).map_err(|e| { + StashError::Store(format!("Failed to set schema version: {e}").into()) + })?; + } // Add last_accessed column if it doesn't exist - let _ = conn.execute( - "ALTER TABLE clipboard ADD COLUMN last_accessed INTEGER DEFAULT \ - (CAST(strftime('%s', 'now') AS INTEGER))", - [], - ); + if schema_version < 3 { + let has_last_accessed: bool = tx + .query_row( + "SELECT sql FROM sqlite_master WHERE type='table' AND \ + name='clipboard'", + [], + |row| { + let sql: String = row.get(0)?; + Ok(sql.to_lowercase().contains("last_accessed")) + }, + ) + .unwrap_or(false); - // Create index for content_hash if it doesn't exist - let _ = conn.execute( - "CREATE INDEX IF NOT EXISTS idx_content_hash ON clipboard(content_hash)", - [], - ); + if !has_last_accessed { + tx.execute("ALTER TABLE clipboard ADD COLUMN last_accessed INTEGER", [ + ]) + .map_err(|e| { + StashError::Store( + format!("Failed to add last_accessed column: {e}").into(), + ) + })?; + } - // Create index for last_accessed if it doesn't exist - let _ = conn.execute( - "CREATE INDEX IF NOT EXISTS idx_last_accessed ON \ - clipboard(last_accessed)", - [], - ); + // Create index for last_accessed if it doesn't exist + tx.execute( + "CREATE INDEX IF NOT EXISTS idx_last_accessed ON \ + clipboard(last_accessed)", + [], + ) + .map_err(|e| { + StashError::Store( + format!("Failed to create last_accessed index: {e}").into(), + ) + })?; + + tx.execute("PRAGMA user_version = 3", []).map_err(|e| { + StashError::Store(format!("Failed to set schema version: {e}").into()) + })?; + } + + tx.commit().map_err(|e| { + StashError::Store( + format!("Failed to commit migration transaction: {e}").into(), + ) + })?; // Initialize Wayland state in background thread. This will be used to track // focused window state. @@ -298,14 +369,27 @@ impl ClipboardDb for SqliteClipboardDb { self .conn .execute( - "INSERT INTO clipboard (contents, mime, content_hash) VALUES (?1, ?2, \ - ?3)", - params![buf, mime, content_hash], + "INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \ + VALUES (?1, ?2, ?3, ?4)", + params![ + buf, + mime, + content_hash, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Time went backwards") + .as_secs() as i64 + ], ) .map_err(|e| StashError::Store(e.to_string().into()))?; + let id = self + .conn + .query_row("SELECT last_insert_rowid()", [], |row| row.get(0)) + .map_err(|e| StashError::Store(e.to_string().into()))?; + self.trim_db(max_items)?; - Ok(self.next_sequence()) + Ok(id) } fn deduplicate_by_hash( @@ -921,3 +1005,353 @@ fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool { debug!("No match found for '{app_name}'"); false } + +#[cfg(test)] +mod tests { + use rusqlite::Connection; + + use super::*; + + fn get_schema_version(conn: &Connection) -> rusqlite::Result { + conn.pragma_query_value(None, "user_version", |row| row.get(0)) + } + + fn table_column_exists(conn: &Connection, table: &str, column: &str) -> bool { + let query = format!( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='{}'", + table + ); + match conn.query_row(&query, [], |row| row.get::<_, String>(0)) { + Ok(sql) => sql.contains(column), + Err(_) => false, + } + } + + fn index_exists(conn: &Connection, index: &str) -> bool { + let query = "SELECT name FROM sqlite_master WHERE type='index' AND name=?1"; + conn + .query_row(query, [index], |row| row.get::<_, String>(0)) + .is_ok() + } + + #[test] + fn test_fresh_database_v3_schema() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_fresh.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + + let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + + assert_eq!( + get_schema_version(&db.conn).expect("Failed to get schema version"), + 3 + ); + + assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); + assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); + + assert!(index_exists(&db.conn, "idx_content_hash")); + assert!(index_exists(&db.conn, "idx_last_accessed")); + + db.conn + .execute( + "INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \ + VALUES (x'010203', 'text/plain', 12345, 1704067200)", + [], + ) + .expect("Failed to insert test data"); + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 1); + } + + #[test] + fn test_migration_from_v0() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_v0.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + + conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \ + AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT);", + ) + .expect("Failed to create table"); + + conn + .execute_batch( + "INSERT INTO clipboard (contents, mime) VALUES (x'010203', \ + 'text/plain')", + ) + .expect("Failed to insert data"); + + assert_eq!(get_schema_version(&conn).expect("Failed to get version"), 0); + + let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + + assert_eq!( + get_schema_version(&db.conn) + .expect("Failed to get version after migration"), + 3 + ); + + assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); + assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 1, "Existing data should be preserved"); + } + + #[test] + fn test_migration_from_v1() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_v1.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + + conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \ + AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT);", + ) + .expect("Failed to create table"); + + conn + .pragma_update(None, "user_version", 1i64) + .expect("Failed to set version"); + + conn + .execute_batch( + "INSERT INTO clipboard (contents, mime) VALUES (x'010203', \ + 'text/plain')", + ) + .expect("Failed to insert data"); + + let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + + assert_eq!( + get_schema_version(&db.conn) + .expect("Failed to get version after migration"), + 3 + ); + + assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); + assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 1, "Existing data should be preserved"); + } + + #[test] + fn test_migration_from_v2() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_v2.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + + conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \ + AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT, content_hash \ + INTEGER);", + ) + .expect("Failed to create table"); + + conn + .pragma_update(None, "user_version", 2i64) + .expect("Failed to set version"); + + conn + .execute_batch( + "INSERT INTO clipboard (contents, mime, content_hash) VALUES \ + (x'010203', 'text/plain', 12345)", + ) + .expect("Failed to insert data"); + + let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + + assert_eq!( + get_schema_version(&db.conn) + .expect("Failed to get version after migration"), + 3 + ); + + assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); + assert!(index_exists(&db.conn, "idx_last_accessed")); + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 1, "Existing data should be preserved"); + } + + #[test] + fn test_idempotent_migration() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_idempotent.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + + conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \ + AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT);", + ) + .expect("Failed to create table"); + + let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + let version_after_first = + get_schema_version(&db.conn).expect("Failed to get version"); + + let db2 = + SqliteClipboardDb::new(db.conn).expect("Failed to create database again"); + let version_after_second = + get_schema_version(&db2.conn).expect("Failed to get version"); + + assert_eq!(version_after_first, version_after_second); + assert_eq!(version_after_first, 3); + } + + #[test] + fn test_store_and_retrieve_with_new_columns() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_store.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + + let test_data = b"Hello, World!"; + let cursor = std::io::Cursor::new(test_data.to_vec()); + + let id = db + .store_entry(cursor, 100, 1000, None) + .expect("Failed to store entry"); + + let content_hash: Option = db + .conn + .query_row( + "SELECT content_hash FROM clipboard WHERE id = ?1", + [id], + |row| row.get(0), + ) + .expect("Failed to get content_hash"); + + let last_accessed: Option = db + .conn + .query_row( + "SELECT last_accessed FROM clipboard WHERE id = ?1", + [id], + |row| row.get(0), + ) + .expect("Failed to get last_accessed"); + + assert!(content_hash.is_some(), "content_hash should be set"); + assert!(last_accessed.is_some(), "last_accessed should be set"); + } + + #[test] + fn test_last_accessed_updated_on_copy() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_copy.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + + let test_data = b"Test content for copy"; + let cursor = std::io::Cursor::new(test_data.to_vec()); + let id_a = db + .store_entry(cursor, 100, 1000, None) + .expect("Failed to store entry A"); + + let original_last_accessed: i64 = db + .conn + .query_row( + "SELECT last_accessed FROM clipboard WHERE id = ?1", + [id_a], + |row| row.get(0), + ) + .expect("Failed to get last_accessed"); + + std::thread::sleep(std::time::Duration::from_millis(1100)); + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + test_data.hash(&mut hasher); + let content_hash = hasher.finish() as i64; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Time went backwards") + .as_secs() as i64; + + db.conn + .execute( + "INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \ + VALUES (?1, ?2, ?3, ?4)", + params![test_data as &[u8], "text/plain", content_hash, now], + ) + .expect("Failed to insert entry B directly"); + + std::thread::sleep(std::time::Duration::from_millis(1100)); + + let (..) = db.copy_entry(id_a).expect("Failed to copy entry"); + + let new_last_accessed: i64 = db + .conn + .query_row( + "SELECT last_accessed FROM clipboard WHERE id = ?1", + [id_a], + |row| row.get(0), + ) + .expect("Failed to get updated last_accessed"); + + assert!( + new_last_accessed > original_last_accessed, + "last_accessed should be updated when copying an entry that is not the \ + most recent" + ); + } + + #[test] + fn test_migration_with_existing_columns_but_v0() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_v0_with_cols.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + + conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \ + AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT, content_hash \ + INTEGER, last_accessed INTEGER);", + ) + .expect("Failed to create table with all columns"); + + conn + .pragma_update(None, "user_version", 0i64) + .expect("Failed to set version to 0"); + + conn + .execute_batch( + "INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \ + VALUES (x'010203', 'text/plain', 12345, 1704067200)", + ) + .expect("Failed to insert data"); + + let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + + assert_eq!( + get_schema_version(&db.conn).expect("Failed to get version"), + 3 + ); + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 1, "Existing data should be preserved"); + } +} diff --git a/src/wayland/mod.rs b/src/wayland/mod.rs index 17e655f..9cfa765 100644 --- a/src/wayland/mod.rs +++ b/src/wayland/mod.rs @@ -34,10 +34,11 @@ pub fn init_wayland_state() { pub fn get_focused_window_app() -> Option { // Try Wayland protocol first if let Ok(focused) = FOCUSED_APP.lock() - && let Some(ref app) = *focused { - debug!("Found focused app via Wayland protocol: {app}"); - return Some(app.clone()); - } + && let Some(ref app) = *focused + { + debug!("Found focused app via Wayland protocol: {app}"); + return Some(app.clone()); + } debug!("No focused window detection method worked"); None @@ -80,10 +81,11 @@ impl Dispatch for AppState { interface, version: _, } = event - && interface == "zwlr_foreign_toplevel_manager_v1" { - let _manager: ZwlrForeignToplevelManagerV1 = - registry.bind(name, 1, qh, ()); - } + && interface == "zwlr_foreign_toplevel_manager_v1" + { + let _manager: ZwlrForeignToplevelManagerV1 = + registry.bind(name, 1, qh, ()); + } } fn event_created_child( @@ -152,10 +154,11 @@ impl Dispatch for AppState { // Update focused app to the `app_id` of this handle if let (Ok(apps), Ok(mut focused)) = (TOPLEVEL_APPS.lock(), FOCUSED_APP.lock()) - && let Some(app_id) = apps.get(&handle_id) { - debug!("Setting focused app to: {app_id}"); - *focused = Some(app_id.clone()); - } + && let Some(app_id) = apps.get(&handle_id) + { + debug!("Setting focused app to: {app_id}"); + *focused = Some(app_id.clone()); + } } }, zwlr_foreign_toplevel_handle_v1::Event::Closed => { From 3d22a271bc6cd4e8c2c2d01f5bf199a4f3b26775 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 13:31:52 +0300 Subject: [PATCH 058/121] chore: add tempfile dependency for tests Signed-off-by: NotAShelf Change-Id: Ibf7a842a2a26f83e8adaf1123386306b6a6a6964 --- Cargo.lock | 1 + Cargo.toml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 57c420f..108d514 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1712,6 +1712,7 @@ dependencies = [ "serde", "serde_json", "smol", + "tempfile", "thiserror", "unicode-segmentation", "unicode-width 0.2.0", diff --git a/Cargo.toml b/Cargo.toml index f396825..725d84b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,9 @@ wayland-client = { version = "0.31.11", features = ["log"], optional = true } wayland-protocols-wlr = { version = "0.3.9", default-features = false, optional = true } notify-rust = { version = "4.11.7", optional = true } +[dev-dependencies] +tempfile = "3.18.0" + [profile.release] lto = true opt-level = "z" From 047445b14395003dd9c9b800831b1b20fd1566a0 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 13:48:06 +0300 Subject: [PATCH 059/121] db: distinguish HEIC from HEIF in mime type detection Signed-off-by: NotAShelf Change-Id: I1a25c6d30fde6b4cc33c2a1666b2e1606a6a6964 --- src/db/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/db/mod.rs b/src/db/mod.rs index 3d07a9e..97e2bb3 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -725,6 +725,7 @@ pub fn detect_mime(data: &[u8]) -> Option { 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", }; From 441334a25002eb5f7abd576dbd1260660d7f6c71 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 13:55:21 +0300 Subject: [PATCH 060/121] chore: bump dependencies Signed-off-by: NotAShelf Change-Id: I8ef1f283622f50d93ba9d581699e272b6a6a6964 --- Cargo.lock | 1471 ++++++++++++++++++++++++++++++++++------------------ Cargo.toml | 54 +- 2 files changed, 1001 insertions(+), 524 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 108d514..943fbbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,9 +19,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -64,24 +64,30 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys", ] [[package]] name = "anstyle-wincon" -version = "3.0.10" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -144,16 +150,16 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.2", + "rustix", "slab", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "async-lock" -version = "3.4.1" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ "event-listener", "event-listener-strategy", @@ -186,7 +192,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.2", + "rustix", ] [[package]] @@ -197,7 +203,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -212,10 +218,10 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.2", + "rustix", "signal-hook-registry", "slab", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -232,7 +238,16 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", ] [[package]] @@ -269,10 +284,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "bitflags" -version = "2.9.4" +name = "bit-set" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] [[package]] name = "block2" @@ -303,10 +348,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] -name = "cassowary" -version = "0.3.0" +name = "bytemuck" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" [[package]] name = "castaway" @@ -319,9 +364,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.41" +version = "1.2.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" dependencies = [ "find-msvc-tools", "shlex", @@ -329,9 +374,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -341,9 +386,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.5.50" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c2cfd7bf8a6017ddaa4e32ffe7403d547790db06bd171c1c53926faab501623" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" dependencies = [ "clap_builder", "clap_derive", @@ -361,9 +406,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.50" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a4c05b9e80c5ccd3a7ef080ad7b6ba7d6fc00a985b8b157197075677c82c7a0" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" dependencies = [ "anstream", "anstyle", @@ -380,14 +425,14 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] name = "clap_lex" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "color-eyre" @@ -424,9 +469,9 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "compact_str" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" dependencies = [ "castaway", "cfg-if", @@ -447,48 +492,41 @@ dependencies = [ [[package]] name = "convert_case" -version = "0.7.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crossterm" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" -dependencies = [ - "bitflags", - "crossterm_winapi", - "mio", - "parking_lot", - "rustix 0.38.44", - "signal-hook", - "signal-hook-mio", - "winapi", -] - [[package]] name = "crossterm" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags", + "bitflags 2.10.0", "crossterm_winapi", "derive_more", "document-features", "mio", "parking_lot", - "rustix 1.1.2", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -504,21 +542,41 @@ dependencies = [ ] [[package]] -name = "ctrlc" -version = "3.5.0" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "881c5d0a13b2f1498e2306e82cbada78390e152d4b1378fb28a84f4dcd0dc4f3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "dispatch", - "nix", - "windows-sys 0.61.2", + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "ctrlc" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790" +dependencies = [ + "dispatch2", + "nix 0.30.1", + "windows-sys", ] [[package]] name = "darling" -version = "0.20.11" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ "darling_core", "darling_macro", @@ -526,57 +584,73 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.11" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.114", ] [[package]] name = "darling_macro" -version = "0.20.11" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.114", ] [[package]] -name = "deranged" -version = "0.5.4" +name = "deltae" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", ] [[package]] name = "derive_more" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ "convert_case", "proc-macro2", "quote", - "syn", + "rustc_version", + "syn 2.0.114", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", ] [[package]] @@ -597,30 +671,26 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys", ] -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - [[package]] name = "dispatch2" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags", + "bitflags 2.10.0", + "block2", + "libc", "objc2", ] [[package]] name = "document-features" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ "litrs", ] @@ -645,9 +715,9 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "endi" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enumflags2" @@ -667,7 +737,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -706,7 +776,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +dependencies = [ + "num-traits", ] [[package]] @@ -752,6 +831,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -759,10 +848,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] -name = "find-msvc-tools" -version = "0.1.4" +name = "filedescriptor" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" [[package]] name = "fixedbitset" @@ -770,6 +876,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "fnv" version = "1.0.7" @@ -814,26 +926,36 @@ dependencies = [ ] [[package]] -name = "getrandom" -version = "0.2.16" +name = "generic-array" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ - "cfg-if", - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "typenum", + "version_check", ] [[package]] name = "getrandom" -version = "0.3.3" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.7+wasi-0.2.4", + "wasip2", ] [[package]] @@ -848,8 +970,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.1.5", ] @@ -859,6 +979,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -909,101 +1031,121 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" -version = "2.11.4" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] name = "indoc" -version = "2.0.6" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] [[package]] name = "inquire" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2628910d0114e9139056161d8644a2026be7b117f8498943f9437748b04c9e0a" +checksum = "ae51d5da01ce7039024fbdec477767c102c454dbdb09d4e2a432ece705b1b25d" dependencies = [ - "bitflags", - "crossterm 0.29.0", + "bitflags 2.10.0", + "crossterm", "dyn-clone", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width", ] [[package]] name = "instability" -version = "0.3.9" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" dependencies = [ "darling", "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" -version = "0.2.15" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" dependencies = [ "jiff-static", "log", "portable-atomic", "portable-atomic-util", - "serde", + "serde_core", ] [[package]] name = "jiff-static" -version = "0.2.15" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", ] +[[package]] +name = "kasuari" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lazy_static" version = "1.5.0" @@ -1012,17 +1154,17 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags", + "bitflags 2.10.0", "libc", ] @@ -1038,10 +1180,13 @@ dependencies = [ ] [[package]] -name = "linux-raw-sys" -version = "0.4.15" +name = "line-clipping" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +dependencies = [ + "bitflags 2.10.0", +] [[package]] name = "linux-raw-sys" @@ -1051,9 +1196,9 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litrs" -version = "0.4.2" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" @@ -1066,24 +1211,24 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.12.5" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] name = "mac-notification-sys" -version = "0.6.6" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "119c8490084af61b44c9eda9d626475847a186737c0378c85e32d77c33a01cd4" +checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" dependencies = [ "cc", "objc2", @@ -1091,12 +1236,28 @@ dependencies = [ "time", ] +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + [[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + [[package]] name = "memoffset" version = "0.9.1" @@ -1123,14 +1284,27 @@ dependencies = [ [[package]] name = "mio" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", ] [[package]] @@ -1139,11 +1313,10 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags", + "bitflags 2.10.0", "cfg-if", "cfg_aliases", "libc", - "memoffset", ] [[package]] @@ -1156,6 +1329,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "notify-rust" version = "4.11.7" @@ -1176,6 +1358,35 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "objc2" version = "0.6.3" @@ -1191,7 +1402,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.10.0", "dispatch2", "objc2", ] @@ -1208,7 +1419,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.10.0", "block2", "libc", "objc2", @@ -1232,9 +1443,9 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "option-ext" @@ -1242,6 +1453,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -1259,7 +1479,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1298,21 +1518,111 @@ dependencies = [ ] [[package]] -name = "paste" -version = "1.0.15" +name = "pest" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "pest_meta" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +dependencies = [ + "pest", + "sha2", +] [[package]] name = "petgraph" -version = "0.6.5" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "fixedbitset", + "fixedbitset 0.5.7", + "hashbrown 0.15.5", "indexmap", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1346,15 +1656,15 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.2", - "windows-sys 0.61.2", + "rustix", + "windows-sys", ] [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" [[package]] name = "portable-atomic-util" @@ -1382,9 +1692,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -1399,10 +1709,19 @@ dependencies = [ ] [[package]] -name = "quote" -version = "1.0.41" +name = "quick-xml" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ "proc-macro2", ] @@ -1414,24 +1733,103 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "ratatui" -version = "0.29.0" +name = "rand" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ - "bitflags", - "cassowary", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.10.0", "compact_str", - "crossterm 0.28.1", + "hashbrown 0.16.1", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.16.1", "indoc", "instability", "itertools", - "lru", - "paste", + "line-clipping", + "ratatui-core", "strum", + "time", "unicode-segmentation", - "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width", ] [[package]] @@ -1440,7 +1838,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.10.0", ] [[package]] @@ -1449,9 +1847,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1483,13 +1881,23 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + [[package]] name = "rusqlite" version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ - "bitflags", + "bitflags 2.10.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -1500,34 +1908,30 @@ dependencies = [ [[package]] name = "rustc-demangle" -version = "0.1.26" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] -name = "rustix" -version = "0.38.44" +name = "rustc_version" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "semver", ] [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "linux-raw-sys", + "windows-sys", ] [[package]] @@ -1538,9 +1942,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "scopeguard" @@ -1548,6 +1952,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -1575,20 +1985,20 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -1599,7 +2009,18 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", ] [[package]] @@ -1629,9 +2050,9 @@ dependencies = [ [[package]] name = "signal-hook-mio" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", "mio", @@ -1640,13 +2061,20 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.11" @@ -1678,14 +2106,13 @@ dependencies = [ [[package]] name = "sqlite-wasm-rs" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05e98301bf8b0540c7de45ecd760539b9c62f5772aed172f08efba597c11cd5d" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" dependencies = [ "cc", - "hashbrown 0.16.1", "js-sys", - "thiserror", + "rsqlite-vfs", "wasm-bindgen", ] @@ -1697,7 +2124,7 @@ dependencies = [ "clap", "clap-verbosity-flag", "color-eyre", - "crossterm 0.29.0", + "crossterm", "ctrlc", "dirs", "env_logger", @@ -1713,9 +2140,9 @@ dependencies = [ "serde_json", "smol", "tempfile", - "thiserror", + "thiserror 2.0.18", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width", "wayland-client", "wayland-protocols-wlr", "wl-clipboard-rs", @@ -1735,31 +2162,41 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.26.3" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.26.4" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck", "proc-macro2", "quote", - "rustversion", - "syn", + "syn 2.0.114", ] [[package]] name = "syn" -version = "2.0.106" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -1772,43 +2209,126 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" dependencies = [ - "quick-xml", - "thiserror", + "quick-xml 0.37.5", + "thiserror 2.0.18", "windows", "windows-version", ] [[package]] name = "tempfile" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom 0.3.4", "once_cell", - "rustix 1.1.2", - "windows-sys 0.61.2", + "rustix", + "windows-sys", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom 7.1.3", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.10.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset 0.4.2", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", ] [[package]] name = "thiserror" -version = "2.0.17" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", ] [[package]] @@ -1822,37 +2342,39 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", + "libc", "num-conv", + "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap", "toml_datetime", @@ -1862,18 +2384,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ "winnow", ] [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -1882,20 +2404,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -1913,9 +2435,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "sharded-slab", "thread_local", @@ -1924,16 +2446,27 @@ dependencies = [ [[package]] name = "tree_magic_mini" -version = "3.2.0" +version = "3.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f943391d896cdfe8eec03a04d7110332d445be7df856db382dd96a730667562c" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" dependencies = [ "memchr", - "nom", - "once_cell", + "nom 8.0.0", "petgraph", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.1.0" @@ -1947,9 +2480,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.19" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-segmentation" @@ -1959,26 +2492,20 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-truncate" -version = "1.1.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width 0.1.14", + "unicode-width", ] [[package]] name = "unicode-width" -version = "0.1.14" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "utf8parse" @@ -1986,6 +2513,19 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "atomic", + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1998,35 +2538,41 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -2037,9 +2583,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2047,59 +2593,59 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.114", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] [[package]] name = "wayland-backend" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.2", + "rustix", "smallvec", "wayland-sys", ] [[package]] name = "wayland-client" -version = "0.31.11" +version = "0.31.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" +checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" dependencies = [ - "bitflags", + "bitflags 2.10.0", "log", - "rustix 1.1.2", + "rustix", "wayland-backend", "wayland-scanner", ] [[package]] name = "wayland-protocols" -version = "0.32.9" +version = "0.32.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" +checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" dependencies = [ - "bitflags", + "bitflags 2.10.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -2107,11 +2653,11 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" +checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" dependencies = [ - "bitflags", + "bitflags 2.10.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -2120,24 +2666,96 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.7" +version = "0.31.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" +checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.38.4", "quote", ] [[package]] name = "wayland-sys" -version = "0.31.7" +version = "0.31.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" dependencies = [ "pkg-config", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2214,7 +2832,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -2225,7 +2843,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.114", ] [[package]] @@ -2268,24 +2886,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -2295,39 +2895,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.1.0" @@ -2346,129 +2913,32 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "wl-clipboard-rs" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5ff8d0e60065f549fafd9d6cb626203ea64a798186c80d8e7df4f8af56baeb" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" dependencies = [ "libc", "log", "os_pipe", - "rustix 0.38.44", - "tempfile", - "thiserror", + "rustix", + "thiserror 2.0.18", "tree_magic_mini", "wayland-backend", "wayland-client", @@ -2478,9 +2948,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.11.0" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7" +checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" dependencies = [ "async-broadcast", "async-executor", @@ -2496,13 +2966,15 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix", + "libc", "ordered-stream", + "rustix", "serde", "serde_repr", "tracing", "uds_windows", - "windows-sys 0.60.2", + "uuid", + "windows-sys", "winnow", "zbus_macros", "zbus_names", @@ -2511,14 +2983,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.11.0" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca" +checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.114", "zbus_names", "zvariant", "zvariant_utils", @@ -2526,21 +2998,26 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.2.0" +version = "4.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "static_assertions", "winnow", "zvariant", ] [[package]] -name = "zvariant" -version = "5.7.0" +name = "zmij" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "999dd3be73c52b1fccd109a4a81e4fcd20fab1d3599c8121b38d04e1419498db" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" + +[[package]] +name = "zvariant" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" dependencies = [ "endi", "enumflags2", @@ -2552,26 +3029,26 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.7.0" +version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6643fd0b26a46d226bd90d3f07c1b5321fe9bb7f04673cb37ac6d6883885b68e" +checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.114", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" dependencies = [ "proc-macro2", "quote", "serde", - "syn", + "syn 2.0.114", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 725d84b..bb56641 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,47 +10,47 @@ repository = "https://github.com/notashelf/stash" rust-version = "1.90" [[bin]] -name = "stash" # actual binary name for Nix, Cargo, etc. +name = "stash" # actual binary name for Nix, Cargo, etc. path = "src/main.rs" -[features] -default = ["use-toplevel", "notifications"] -use-toplevel = ["dep:wayland-client", "dep:wayland-protocols-wlr"] -notifications = ["dep:notify-rust"] - [dependencies] -clap = { version = "4.5.50", features = ["derive", "env"] } +base64 = "0.22.1" +clap = { version = "4.5.54", features = ["derive", "env"] } clap-verbosity-flag = "3.0.4" -ctrlc = "3.5.0" color-eyre = "0.6.5" +crossterm = "0.29.0" +ctrlc = "3.5.1" dirs = "6.0.0" +env_logger = "0.11.8" imagesize = "0.14.0" -inquire = { default-features = false, version = "0.9.1", features = [ +inquire = { version = "0.9.2", default-features = false, features = [ "crossterm", ] } -libc = "0.2.177" -log = "0.4.28" -env_logger = "0.11.8" -thiserror = "2.0.17" -wl-clipboard-rs = "0.9.2" -rusqlite = { version = "0.38.0", features = ["bundled"] } -smol = "2.0.2" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.145" -base64 = "0.22.1" -regex = "1.11.3" -ratatui = "0.29.0" -crossterm = "0.29.0" -unicode-segmentation = "1.12.0" -unicode-width = "0.2.0" # FIXME: held back by ratatui -wayland-client = { version = "0.31.11", features = ["log"], optional = true } -wayland-protocols-wlr = { version = "0.3.9", default-features = false, optional = true } +libc = "0.2.180" +log = "0.4.29" notify-rust = { version = "4.11.7", optional = true } +ratatui = "0.30.0" +regex = "1.12.2" +rusqlite = { version = "0.38.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.12.0" +unicode-width = "0.2.2" +wayland-client = { version = "0.31.12", features = ["log"], optional = true } +wayland-protocols-wlr = { version = "0.3.10", default-features = false, optional = true } +wl-clipboard-rs = "0.9.3" [dev-dependencies] tempfile = "3.18.0" +[features] +default = ["notifications", "use-toplevel"] +notifications = ["dep:notify-rust"] +use-toplevel = ["dep:wayland-client", "dep:wayland-protocols-wlr"] + [profile.release] -lto = true opt-level = "z" strip = true +lto = true From bb1c5dc50b20751d47c8aab41288575ea979bb1a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 14:03:34 +0300 Subject: [PATCH 061/121] chore: release v0.3.4 Signed-off-by: NotAShelf Change-Id: Ie637c425e0d13985e3025a2ebaac41916a6a6964 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 943fbbb..d3b5980 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2118,7 +2118,7 @@ dependencies = [ [[package]] name = "stash-clipboard" -version = "0.3.3" +version = "0.3.4" dependencies = [ "base64", "clap", diff --git a/Cargo.toml b/Cargo.toml index bb56641..94ec687 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stash-clipboard" description = "Wayland clipboard manager with fast persistent history and multi-media support" -version = "0.3.3" +version = "0.3.4" edition = "2024" authors = ["NotAShelf "] license = "MPL-2.0" From 71fc1ff40f5e71dd5f946e8c51e0a8b032d04bcf Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 14:32:00 +0300 Subject: [PATCH 062/121] 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 Change-Id: Ie9e5b0767673e74389b8e59c466afd946a6a6964 --- Cargo.lock | 35 ++++++++------ Cargo.toml | 3 +- src/db/mod.rs | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d3b5980..26321a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1011,6 +1011,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + [[package]] name = "ident_case" version = "1.0.1" @@ -1121,9 +1127,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -2128,6 +2134,7 @@ dependencies = [ "ctrlc", "dirs", "env_logger", + "humantime", "imagesize", "inquire", "libc", @@ -2561,18 +2568,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", @@ -2583,9 +2590,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2593,9 +2600,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ "bumpalo", "proc-macro2", @@ -2606,9 +2613,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] @@ -2924,9 +2931,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "wl-clipboard-rs" diff --git a/Cargo.toml b/Cargo.toml index 94ec687..cd21891 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ repository = "https://github.com/notashelf/stash" rust-version = "1.90" [[bin]] -name = "stash" # actual binary name for Nix, Cargo, etc. +name = "stash" # actual binary name for Nix, Cargo, etc. path = "src/main.rs" [dependencies] @@ -22,6 +22,7 @@ crossterm = "0.29.0" ctrlc = "3.5.1" dirs = "6.0.0" env_logger = "0.11.8" +humantime = "2.3.0" imagesize = "0.14.0" inquire = { version = "0.9.2", default-features = false, features = [ "crossterm", diff --git a/src/db/mod.rs b/src/db/mod.rs index 97e2bb3..8c98ddd 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -256,6 +256,46 @@ impl SqliteClipboardDb { })?; } + // Add expires_at column if it doesn't exist (v4) + if schema_version < 4 { + let has_expires_at: bool = tx + .query_row( + "SELECT sql FROM sqlite_master WHERE type='table' AND \ + name='clipboard'", + [], + |row| { + let sql: String = row.get(0)?; + Ok(sql.to_lowercase().contains("expires_at")) + }, + ) + .unwrap_or(false); + + if !has_expires_at { + tx.execute("ALTER TABLE clipboard ADD COLUMN expires_at REAL", []) + .map_err(|e| { + StashError::Store( + format!("Failed to add expires_at column: {e}").into(), + ) + })?; + } + + // Create partial index for expires_at (only index non-NULL values) + tx.execute( + "CREATE INDEX IF NOT EXISTS idx_expires_at ON clipboard(expires_at) \ + WHERE expires_at IS NOT NULL", + [], + ) + .map_err(|e| { + StashError::Store( + format!("Failed to create expires_at index: {e}").into(), + ) + })?; + + tx.execute("PRAGMA user_version = 4", []).map_err(|e| { + StashError::Store(format!("Failed to set schema version: {e}").into()) + })?; + } + tx.commit().map_err(|e| { StashError::Store( format!("Failed to commit migration transaction: {e}").into(), @@ -653,6 +693,93 @@ impl ClipboardDb for SqliteClipboardDb { } } +impl SqliteClipboardDb { + /// Get current Unix timestamp with sub-second precision + pub fn now() -> f64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs_f64() + } + + /// Clean up all expired entries. Returns count deleted. + pub fn cleanup_expired(&self) -> Result { + let now = Self::now(); + self + .conn + .execute( + "DELETE FROM clipboard WHERE expires_at IS NOT NULL AND expires_at <= \ + ?1", + [now], + ) + .map_err(|e| StashError::Trim(e.to_string().into())) + } + + /// Get expired entries (for --expired flag diagnostic output) + pub fn get_expired_entries( + &self, + ) -> Result, Option)>, StashError> { + let now = Self::now(); + let mut stmt = self + .conn + .prepare( + "SELECT id, contents, mime FROM clipboard WHERE expires_at IS NOT \ + NULL AND expires_at <= ?1 ORDER BY expires_at ASC", + ) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let mut rows = stmt + .query([now]) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let mut entries = Vec::new(); + while let Some(row) = rows + .next() + .map_err(|e| StashError::ListDecode(e.to_string().into()))? + { + let id: i64 = row + .get(0) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let contents: Vec = row + .get(1) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let mime: Option = row + .get(2) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + entries.push((id, contents, mime)); + } + Ok(entries) + } + + /// Get the earliest expiration (timestamp, id) for heap initialization + pub fn get_next_expiration(&self) -> Result, StashError> { + match self.conn.query_row( + "SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT NULL \ + ORDER BY expires_at ASC LIMIT 1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) { + Ok(result) => Ok(Some(result)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(StashError::Store(e.to_string().into())), + } + } + + /// Set expiration timestamp for an entry + pub fn set_expiration( + &self, + id: i64, + expires_at: f64, + ) -> Result<(), StashError> { + self + .conn + .execute( + "UPDATE clipboard SET expires_at = ?2 WHERE id = ?1", + params![id, expires_at], + ) + .map_err(|e| StashError::Store(e.to_string().into()))?; + Ok(()) + } +} + /// Try to load a sensitive regex from systemd credential or env. /// /// # Returns From dd7a55c7604f037ace290ed204e704eff097127e Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 15:12:33 +0300 Subject: [PATCH 063/121] 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 Change-Id: I8705d404eae5d93ad48f738a24f698196a6a6964 --- src/commands/watch.rs | 171 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 167 insertions(+), 4 deletions(-) diff --git a/src/commands/watch.rs b/src/commands/watch.rs index ce2acf7..e7ac13e 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -1,5 +1,5 @@ use std::{ - collections::hash_map::DefaultHasher, + collections::{BinaryHeap, hash_map::DefaultHasher}, hash::{Hash, Hasher}, io::Read, time::Duration, @@ -10,12 +10,89 @@ use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents}; use crate::db::{ClipboardDb, SqliteClipboardDb}; +/// Wrapper to provide Ord implementation for f64 by negating values. +/// This allows BinaryHeap (which is a max-heap) to function as a min-heap. +#[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 { + 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 { + 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 { + 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 + } +} + pub trait WatchCommand { fn watch( &self, max_dedupe_search: u64, max_items: u64, excluded_apps: &[String], + expire_after: Option, ); } @@ -25,10 +102,52 @@ impl WatchCommand for SqliteClipboardDb { max_dedupe_search: u64, max_items: u64, excluded_apps: &[String], + expire_after: Option, ) { smol::block_on(async { log::info!("Starting clipboard watch daemon"); + // Cleanup any already-expired entries on startup + if let Ok(count) = self.cleanup_expired() { + if count > 0 { + log::info!("Cleaned up {} expired entries on startup", count); + } + } + + // Build expiration queue from existing entries + let mut exp_queue = ExpirationQueue::new(); + if let Ok(Some((expires_at, id))) = self.get_next_expiration() { + exp_queue.push(expires_at, id); + // Load remaining expirations + let mut stmt = self + .conn + .prepare( + "SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT \ + NULL ORDER BY expires_at ASC", + ) + .ok(); + if let Some(ref mut stmt) = stmt { + let mut rows = stmt.query([]).ok(); + if let Some(ref mut rows) = rows { + while let Ok(Some(row)) = rows.next() { + if let (Ok(exp), Ok(row_id)) = + (row.get::<_, f64>(0), row.get::<_, i64>(1)) + { + // Skip first entry which is already added + if exp_queue + .heap + .iter() + .any(|(_, existing_id)| *existing_id == row_id) + { + continue; + } + exp_queue.push(exp, row_id); + } + } + } + } + } + // We use hashes for comparison instead of storing full contents let mut last_hash: Option = None; let mut buf = Vec::with_capacity(4096); @@ -53,6 +172,39 @@ impl WatchCommand for SqliteClipboardDb { } loop { + // Process any pending expirations + 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 (handles stale heap entries) + let exists = self + .conn + .query_row( + "SELECT 1 FROM clipboard WHERE id = ?1", + [id], + |_| Ok(()), + ) + .is_ok(); + if exists { + self + .conn + .execute("DELETE FROM clipboard WHERE id = ?1", [id]) + .ok(); + log::info!("Entry {id} expired and removed"); + } + } + } else { + // Sleep precisely until next expiration (sub-second precision) + let sleep_duration = next_exp - now; + Timer::after(Duration::from_secs_f64(sleep_duration)).await; + continue; // Skip normal poll, process expirations first + } + } + + // Normal clipboard polling match get_contents( ClipboardType::Regular, Seat::Unspecified, @@ -70,16 +222,23 @@ impl WatchCommand for SqliteClipboardDb { if !buf.is_empty() { let current_hash = hash_contents(&buf); if last_hash != Some(current_hash) { - let id = self.next_sequence(); match self.store_entry( &buf[..], max_dedupe_search, max_items, Some(excluded_apps), ) { - Ok(_) => { + Ok(id) => { log::info!("Stored new clipboard entry (id: {id})"); last_hash = Some(current_hash); + + // Set expiration if configured + if let Some(duration) = expire_after { + let expires_at = + SqliteClipboardDb::now() + duration.as_secs_f64(); + self.set_expiration(id, expires_at).ok(); + exp_queue.push(expires_at, id); + } }, Err(crate::db::StashError::ExcludedByApp(_)) => { log::info!("Clipboard entry excluded by app filter"); @@ -106,7 +265,11 @@ impl WatchCommand for SqliteClipboardDb { } }, } - Timer::after(Duration::from_millis(500)).await; + + // Normal poll interval (only if no expirations pending) + if exp_queue.peek_next().is_none() { + Timer::after(Duration::from_millis(500)).await; + } } }); } From f4936e56ffc311ca4fe22d986d1304c678282c28 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 15:14:16 +0300 Subject: [PATCH 064/121] cli: add `--expire-after` flag to watch and `--expired` flag to list Signed-off-by: NotAShelf Change-Id: I833e7bfaecb5e3254d2ea16f2b880e246a6a6964 --- src/main.rs | 100 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 69 insertions(+), 31 deletions(-) diff --git a/src/main.rs b/src/main.rs index 28f9fb0..1bf87ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,9 +2,11 @@ use std::{ env, io::{self, IsTerminal}, path::PathBuf, + time::Duration, }; use clap::{CommandFactory, Parser, Subcommand}; +use humantime::parse_duration; use inquire::Confirm; mod commands; @@ -71,6 +73,10 @@ enum Command { /// Output format: "tsv" (default) or "json" #[arg(long, value_parser = ["tsv", "json"])] format: Option, + + /// Show only expired entries (diagnostic, does not remove them) + #[arg(long)] + expired: bool, }, /// Decode and output clipboard entry by id @@ -111,7 +117,11 @@ enum Command { }, /// Start a process to watch clipboard for changes and store automatically. - Watch, + Watch { + /// Expire new entries after duration (e.g., "3s", "500ms", "1h30m"). + #[arg(long, value_parser = parse_duration)] + expire_after: Option, + }, } fn report_error( @@ -186,40 +196,67 @@ fn main() -> color_eyre::eyre::Result<()> { "failed to store entry", ); }, - Some(Command::List { format }) => { - match format.as_deref() { - Some("tsv") => { - report_error( - db.list(io::stdout(), cli.preview_width), - "failed to list entries", - ); - }, - Some("json") => { - match db.list_json() { - Ok(json) => { - println!("{json}"); - }, - Err(e) => { - log::error!("failed to list entries as JSON: {e}"); - }, + Some(Command::List { format, expired }) => { + if expired { + // Diagnostic mode: show expired entries only (does not cleanup) + match db.get_expired_entries() { + Ok(entries) => { + for (id, contents, mime) in entries { + let preview = db::preview_entry( + &contents, + mime.as_deref(), + cli.preview_width, + ); + println!("{id}\t{preview}"); + } + }, + Err(e) => { + log::error!("failed to list expired entries: {e}"); + }, + } + } else { + // Normal list mode + // Cleanup expired entries when daemon is not running + if let Ok(count) = db.cleanup_expired() { + if count > 0 { + log::info!("Cleaned up {} expired entries", count); } - }, - Some(other) => { - log::error!("unsupported format: {other}"); - }, - None => { - if std::io::stdout().is_terminal() { - report_error( - db.list_tui(cli.preview_width), - "failed to list entries in TUI", - ); - } else { + } + + match format.as_deref() { + Some("tsv") => { report_error( db.list(io::stdout(), cli.preview_width), "failed to list entries", ); - } - }, + }, + Some("json") => { + match db.list_json() { + 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), + "failed to list entries in TUI", + ); + } else { + report_error( + db.list(io::stdout(), cli.preview_width), + "failed to list entries", + ); + } + }, + } } }, Some(Command::Decode { input }) => { @@ -334,7 +371,7 @@ fn main() -> color_eyre::eyre::Result<()> { } } }, - Some(Command::Watch) => { + Some(Command::Watch { expire_after }) => { db.watch( cli.max_dedupe_search, cli.max_items, @@ -342,6 +379,7 @@ fn main() -> color_eyre::eyre::Result<()> { &cli.excluded_apps, #[cfg(not(feature = "use-toplevel"))] &[], + expire_after, ); }, From d40b547c07ddee30b4b8b058a88f2602f6a24940 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 16:07:30 +0300 Subject: [PATCH 065/121] 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 Change-Id: Icfab67753d7f18e3798c0a930b16d05e6a6a6964 --- src/db/mod.rs | 177 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 122 insertions(+), 55 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index 8c98ddd..f2048cd 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -80,6 +80,7 @@ pub trait ClipboardDb { &self, out: impl Write, preview_width: u32, + include_expired: bool, ) -> Result; fn decode_entry( &self, @@ -93,7 +94,6 @@ pub trait ClipboardDb { &self, id: i64, ) -> Result<(i64, Vec, Option), StashError>; - fn next_sequence(&self) -> i64; } #[derive(Serialize, Deserialize)] @@ -296,6 +296,49 @@ impl SqliteClipboardDb { })?; } + // Add is_expired column if it doesn't exist (v5) + if schema_version < 5 { + let has_is_expired: bool = tx + .query_row( + "SELECT sql FROM sqlite_master WHERE type='table' AND \ + name='clipboard'", + [], + |row| { + let sql: String = row.get(0)?; + Ok(sql.to_lowercase().contains("is_expired")) + }, + ) + .unwrap_or(false); + + if !has_is_expired { + tx.execute( + "ALTER TABLE clipboard ADD COLUMN is_expired INTEGER DEFAULT 0", + [], + ) + .map_err(|e| { + StashError::Store( + format!("Failed to add is_expired column: {e}").into(), + ) + })?; + } + + // Create index for is_expired (for filtering) + tx.execute( + "CREATE INDEX IF NOT EXISTS idx_is_expired ON clipboard(is_expired) \ + WHERE is_expired = 1", + [], + ) + .map_err(|e| { + StashError::Store( + format!("Failed to create is_expired index: {e}").into(), + ) + })?; + + tx.execute("PRAGMA user_version = 5", []).map_err(|e| { + StashError::Store(format!("Failed to set schema version: {e}").into()) + })?; + } + tx.commit().map_err(|e| { StashError::Store( format!("Failed to commit migration transaction: {e}").into(), @@ -311,13 +354,17 @@ impl SqliteClipboardDb { } impl SqliteClipboardDb { - pub fn list_json(&self) -> Result { + pub fn list_json(&self, include_expired: bool) -> Result { + let query = if include_expired { + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) DESC, id DESC" + } else { + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC" + }; let mut stmt = self .conn - .prepare( - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) DESC, id DESC", - ) + .prepare(query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -526,13 +573,18 @@ impl ClipboardDb for SqliteClipboardDb { &self, mut out: impl Write, preview_width: u32, + include_expired: bool, ) -> Result { + let query = if include_expired { + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) DESC, id DESC" + } else { + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC" + }; let mut stmt = self .conn - .prepare( - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) DESC, id DESC", - ) + .prepare(query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -680,17 +732,6 @@ impl ClipboardDb for SqliteClipboardDb { Ok((id, contents, mime)) } - - fn next_sequence(&self) -> i64 { - match self - .conn - .query_row("SELECT MAX(id) FROM clipboard", [], |row| { - row.get::<_, Option>(0) - }) { - Ok(Some(max_id)) => max_id + 1, - Ok(None) | Err(_) => 1, - } - } } impl SqliteClipboardDb { @@ -715,40 +756,6 @@ impl SqliteClipboardDb { .map_err(|e| StashError::Trim(e.to_string().into())) } - /// Get expired entries (for --expired flag diagnostic output) - pub fn get_expired_entries( - &self, - ) -> Result, Option)>, StashError> { - let now = Self::now(); - let mut stmt = self - .conn - .prepare( - "SELECT id, contents, mime FROM clipboard WHERE expires_at IS NOT \ - NULL AND expires_at <= ?1 ORDER BY expires_at ASC", - ) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut rows = stmt - .query([now]) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut entries = Vec::new(); - while let Some(row) = rows - .next() - .map_err(|e| StashError::ListDecode(e.to_string().into()))? - { - let id: i64 = row - .get(0) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mime: Option = row - .get(2) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - entries.push((id, contents, mime)); - } - Ok(entries) - } - /// Get the earliest expiration (timestamp, id) for heap initialization pub fn get_next_expiration(&self) -> Result, StashError> { match self.conn.query_row( @@ -778,6 +785,66 @@ impl SqliteClipboardDb { .map_err(|e| StashError::Store(e.to_string().into()))?; Ok(()) } + + /// Optimize database using VACUUM + pub fn vacuum(&self) -> Result<(), StashError> { + self + .conn + .execute("VACUUM", []) + .map_err(|e| StashError::Store(e.to_string().into()))?; + Ok(()) + } + + /// Get database statistics + pub fn stats(&self) -> Result { + let total: i64 = self + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let expired: i64 = self + .conn + .query_row( + "SELECT COUNT(*) FROM clipboard WHERE is_expired = 1", + [], + |row| row.get(0), + ) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let active = total - expired; + + let with_expiration: i64 = self + .conn + .query_row( + "SELECT COUNT(*) FROM clipboard WHERE expires_at IS NOT NULL AND \ + (is_expired IS NULL OR is_expired = 0)", + [], + |row| row.get(0), + ) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + // Get database file size + let page_count: i64 = self + .conn + .query_row("PRAGMA page_count", [], |row| row.get(0)) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let page_size: i64 = self + .conn + .query_row("PRAGMA page_size", [], |row| row.get(0)) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let size_bytes = page_count * page_size; + let size_mb = size_bytes as f64 / 1024.0 / 1024.0; + + Ok(format!( + "Database Statistics:\n\nEntries:\nTotal: {total}\nActive: \ + {active}\nExpired: {expired}\nWith TTL: \ + {with_expiration}\n\nStorage:\nSize: {size_mb:.2} MB \ + ({size_bytes} bytes)\nPages: {page_count}\nPage size: \ + {page_size} bytes" + )) + } } /// Try to load a sensitive regex from systemd credential or env. From b070d4d93d158c7a9a50ed44f4300e66f1dd86bb Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 16:52:29 +0300 Subject: [PATCH 066/121] 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 Change-Id: Ib162dff3a76e23edcdfbd1af13b01b916a6a6964 --- src/commands/watch.rs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/commands/watch.rs b/src/commands/watch.rs index e7ac13e..018eeca 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -107,23 +107,17 @@ impl WatchCommand for SqliteClipboardDb { smol::block_on(async { log::info!("Starting clipboard watch daemon"); - // Cleanup any already-expired entries on startup - if let Ok(count) = self.cleanup_expired() { - if count > 0 { - log::info!("Cleaned up {} expired entries on startup", count); - } - } - // Build expiration queue from existing entries let mut exp_queue = ExpirationQueue::new(); if let Ok(Some((expires_at, id))) = self.get_next_expiration() { exp_queue.push(expires_at, id); - // Load remaining expirations + // Load remaining expirations (exclude already-marked expired entries) let mut stmt = self .conn .prepare( "SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT \ - NULL ORDER BY expires_at ASC", + NULL AND (is_expired IS NULL OR is_expired = 0) ORDER BY \ + expires_at ASC", ) .ok(); if let Some(ref mut stmt) = stmt { @@ -189,11 +183,15 @@ impl WatchCommand for SqliteClipboardDb { ) .is_ok(); if exists { + // Mark as expired instead of deleting self .conn - .execute("DELETE FROM clipboard WHERE id = ?1", [id]) + .execute( + "UPDATE clipboard SET is_expired = 1 WHERE id = ?1", + [id], + ) .ok(); - log::info!("Entry {id} expired and removed"); + log::info!("Entry {id} marked as expired"); } } } else { From 2e555ee043366bd1cdc7aa059508c46dc6ebc746 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 16:53:44 +0300 Subject: [PATCH 067/121] commands/list: add include_expired parameter for filtering Signed-off-by: NotAShelf Change-Id: Ia1ab13345cfa5e2cf9a92f8b32a6a9826a6a6964 --- src/commands/list.rs | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index 1afab2a..25903f3 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -6,8 +6,12 @@ use unicode_width::UnicodeWidthStr; use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; pub trait ListCommand { - fn list(&self, out: impl Write, preview_width: u32) - -> Result<(), StashError>; + fn list( + &self, + out: impl Write, + preview_width: u32, + include_expired: bool, + ) -> Result<(), StashError>; } impl ListCommand for SqliteClipboardDb { @@ -15,14 +19,21 @@ impl ListCommand for SqliteClipboardDb { &self, out: impl Write, preview_width: u32, + include_expired: bool, ) -> Result<(), StashError> { - self.list_entries(out, preview_width).map(|_| ()) + self + .list_entries(out, preview_width, include_expired) + .map(|_| ()) } } impl SqliteClipboardDb { #[allow(clippy::too_many_lines)] - pub fn list_tui(&self, preview_width: u32) -> Result<(), StashError> { + pub fn list_tui( + &self, + preview_width: u32, + include_expired: bool, + ) -> Result<(), StashError> { use std::io::stdout; use crossterm::{ @@ -53,12 +64,16 @@ impl SqliteClipboardDb { use wl_clipboard_rs::copy::{MimeType, Options, Source}; // Query entries from DB + let query = if include_expired { + "SELECT id, contents, mime FROM clipboard ORDER BY last_accessed DESC, \ + id DESC" + } else { + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0) ORDER BY last_accessed DESC, id DESC" + }; let mut stmt = self .conn - .prepare( - "SELECT id, contents, mime FROM clipboard ORDER BY last_accessed \ - DESC, id DESC", - ) + .prepare(query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) From 5731fb08a523a3b2f210ea17d0d18bb8dfec8750 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 16:54:22 +0300 Subject: [PATCH 068/121] 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 Change-Id: I34107880185d231d207b0dab7782d5d96a6a6964 --- src/main.rs | 174 +++++++++++++++++++++++++++++++++++----------------- 1 file changed, 118 insertions(+), 56 deletions(-) diff --git a/src/main.rs b/src/main.rs index 1bf87ba..aca9838 100644 --- a/src/main.rs +++ b/src/main.rs @@ -99,12 +99,21 @@ enum Command { }, /// Wipe all clipboard history + /// + /// DEPRECATED: Use `stash db wipe` instead + #[command(hide = true)] Wipe { /// Ask for confirmation before wiping #[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) @@ -124,6 +133,26 @@ enum Command { }, } +#[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( result: Result, context: &str, @@ -197,66 +226,39 @@ fn main() -> color_eyre::eyre::Result<()> { ); }, Some(Command::List { format, expired }) => { - if expired { - // Diagnostic mode: show expired entries only (does not cleanup) - match db.get_expired_entries() { - Ok(entries) => { - for (id, contents, mime) in entries { - let preview = db::preview_entry( - &contents, - mime.as_deref(), - cli.preview_width, - ); - println!("{id}\t{preview}"); - } - }, - Err(e) => { - log::error!("failed to list expired entries: {e}"); - }, - } - } else { - // Normal list mode - // Cleanup expired entries when daemon is not running - if let Ok(count) = db.cleanup_expired() { - if count > 0 { - log::info!("Cleaned up {} expired entries", count); + match format.as_deref() { + Some("tsv") => { + report_error( + db.list(io::stdout(), cli.preview_width, expired), + "failed to list entries", + ); + }, + Some("json") => { + match db.list_json(expired) { + Ok(json) => { + println!("{json}"); + }, + Err(e) => { + log::error!("failed to list entries as JSON: {e}"); + }, } - } - - match format.as_deref() { - Some("tsv") => { + }, + Some(other) => { + log::error!("unsupported format: {other}"); + }, + None => { + if std::io::stdout().is_terminal() { report_error( - db.list(io::stdout(), cli.preview_width), + db.list_tui(cli.preview_width, expired), + "failed to list entries in TUI", + ); + } else { + report_error( + db.list(io::stdout(), cli.preview_width, expired), "failed to list entries", ); - }, - Some("json") => { - match db.list_json() { - 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), - "failed to list entries in TUI", - ); - } else { - report_error( - db.list(io::stdout(), cli.preview_width), - "failed to list entries", - ); - } - }, - } + } + }, } }, Some(Command::Decode { input }) => { @@ -324,6 +326,10 @@ fn main() -> color_eyre::eyre::Result<()> { } }, Some(Command::Wipe { ask }) => { + eprintln!( + "Warning: The 'stash wipe' command is deprecated. Use 'stash db \ + wipe' instead." + ); let mut should_proceed = true; if ask { should_proceed = Confirm::new( @@ -341,6 +347,62 @@ fn main() -> color_eyre::eyre::Result<()> { } }, + 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::new(message) + .with_default(false) + .prompt() + .unwrap_or(false); + 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 {} expired entries", count); + }, + Err(e) => { + log::error!("failed to wipe expired entries: {e}"); + }, + } + } else { + report_error(db.wipe(), "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 { From b00e9b5f3a8e4135b81820dbd1407aa0123b7822 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 18:02:38 +0300 Subject: [PATCH 069/121] watch: clear clipboard when expired entry content matches current clipboard Signed-off-by: NotAShelf Change-Id: I4bede5db16cea993ed8e8591e8d198d56a6a6964 --- src/commands/watch.rs | 71 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 12 deletions(-) diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 018eeca..54706bb 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -6,12 +6,19 @@ use std::{ }; use smol::Timer; -use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents}; +use wl_clipboard_rs::{ + copy::{MimeType as CopyMimeType, Options, Source}, + paste::{ClipboardType, Seat, get_contents}, +}; use crate::db::{ClipboardDb, SqliteClipboardDb}; -/// Wrapper to provide Ord implementation for f64 by negating values. -/// This allows BinaryHeap (which is a max-heap) to function as a min-heap. +/// 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: +/// - +/// - +/// - #[derive(Debug, Clone, Copy)] struct Neg(f64); @@ -173,17 +180,18 @@ impl WatchCommand for SqliteClipboardDb { // Expired entries to process let expired_ids = exp_queue.pop_expired(now); for id in expired_ids { - // Verify entry still exists (handles stale heap entries) - let exists = self + // Verify entry still exists and get its content_hash + let expired_hash: Option = self .conn .query_row( - "SELECT 1 FROM clipboard WHERE id = ?1", + "SELECT content_hash FROM clipboard WHERE id = ?1", [id], - |_| Ok(()), + |row| row.get(0), ) - .is_ok(); - if exists { - // Mark as expired instead of deleting + .ok(); + + if let Some(stored_hash) = expired_hash { + // Mark as expired self .conn .execute( @@ -192,13 +200,52 @@ impl WatchCommand for SqliteClipboardDb { ) .ok(); log::info!("Entry {id} marked as expired"); + + // Check if this expired entry is currently in the clipboard + if let Ok((mut reader, _)) = get_contents( + ClipboardType::Regular, + Seat::Unspecified, + wl_clipboard_rs::paste::MimeType::Any, + ) { + 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(¤t_buf); + // Compare as i64 (database stores as i64) + if current_hash as i64 == stored_hash { + // 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}" + ); + } + } + } + } } } } else { - // Sleep precisely until next expiration (sub-second precision) + // Sleep *precisely* until next expiration let sleep_duration = next_exp - now; Timer::after(Duration::from_secs_f64(sleep_duration)).await; - continue; // Skip normal poll, process expirations first + continue; // skip normal poll, process expirations first } } From e185ecd32a7bf9151fccdb8bb66bfba27edabeda Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 22 Jan 2026 18:43:17 +0300 Subject: [PATCH 070/121] docs: document entry expiry features for `stash watch` & db cmds Signed-off-by: NotAShelf Change-Id: I60fe5afdb6e903b96023ca420bb7902d6a6a6964 --- README.md | 132 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 122 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c3fd56c..72ea591 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,9 @@ with many features such as but not necessarily limited to: - Import clipboard history from TSV (e.g., from `cliphist list`) - Image preview (shows dimensions and format) - Text previews with customizable width -- Deduplication and entry limit control +- De-duplication, whitespace prevention and entry limit control - Automatic clipboard monitoring with `stash watch` + - 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) @@ -141,7 +142,7 @@ Commands: list List clipboard history decode Decode and output clipboard entry by id delete Delete clipboard entry by id (if numeric), or entries matching a query (if not). Numeric arguments are treated as ids. Use --type to specify explicitly - wipe Wipe all clipboard history + 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) @@ -154,7 +155,7 @@ Options: --preview-width Maximum width (in characters) for clipboard entry previews in list output [default: 100] --db-path - Path to the `SQLite` clipboard database file + Path to the `SQLite` clipboard database file [env: STASH_DB_PATH=] --excluded-apps Application names to exclude from clipboard history [env: STASH_EXCLUDED_APPS=] --ask @@ -188,6 +189,11 @@ 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 `. +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 @@ -219,10 +225,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 @@ -235,13 +264,16 @@ automatically. This is designed as an alternative to shelling out to premade Systemd service in `contrib/`. Packagers are encouraged to vendor the service unless adding their own. -> [!TIP] -> Stash provides `wl-copy` and `wl-paste` binaries for backwards compatibility -> with the `wl-clipboard` tools. If _must_ depend on those binaries by name, you -> may simply use the `wl-copy` and `wl-paste` provided as `wl-clipboard-rs` -> wrappers on your system. In other words, you can use -> `wl-paste --watch stash store` as an alternative to `stash watch` if -> preferred. +#### 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. ### Options @@ -406,6 +438,86 @@ figured out something new, e.g. a neat shell trick, feel free to add it here! 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. + ## Attributions My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the From ff2f272055e5dca1fa3a101478467c4cbdef784a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 23 Jan 2026 22:38:21 +0300 Subject: [PATCH 071/121] mime: refactor mime detection to separate module; streamline Signed-off-by: NotAShelf Change-Id: I489054d2537a4c0de32d79f793478c206a6a6964 --- src/commands/import.rs | 10 +-- src/commands/watch.rs | 33 +++++---- src/db/mod.rs | 60 ++--------------- src/main.rs | 11 ++- src/mime.rs | 149 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 189 insertions(+), 74 deletions(-) create mode 100644 src/mime.rs diff --git a/src/commands/import.rs b/src/commands/import.rs index a5b4e55..933cf88 100644 --- a/src/commands/import.rs +++ b/src/commands/import.rs @@ -1,12 +1,6 @@ use std::io::{self, BufRead}; -use crate::db::{ - ClipboardDb, - Entry, - SqliteClipboardDb, - StashError, - detect_mime, -}; +use crate::db::{ClipboardDb, Entry, SqliteClipboardDb, StashError}; pub trait ImportCommand { /// Import clipboard entries from TSV format. @@ -44,7 +38,7 @@ impl ImportCommand for SqliteClipboardDb { let entry = Entry { contents: val.as_bytes().to_vec(), - mime: detect_mime(val.as_bytes()), + mime: crate::mime::detect_mime(val.as_bytes()), }; self diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 54706bb..ce04495 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -100,6 +100,7 @@ pub trait WatchCommand { max_items: u64, excluded_apps: &[String], expire_after: Option, + mime_type_preference: &str, ); } @@ -110,9 +111,13 @@ impl WatchCommand for SqliteClipboardDb { max_items: u64, excluded_apps: &[String], expire_after: Option, + mime_type_preference: &str, ) { smol::block_on(async { - log::info!("Starting clipboard watch daemon"); + log::info!( + "Starting clipboard watch daemon with MIME type preference: \ + {mime_type_preference}" + ); // Build expiration queue from existing entries let mut exp_queue = ExpirationQueue::new(); @@ -160,12 +165,19 @@ impl WatchCommand for SqliteClipboardDb { hasher.finish() }; + // Convert MIME type preference string to wl_clipboard_rs enum + let mime_type = match mime_type_preference { + "text" => wl_clipboard_rs::paste::MimeType::Text, + "image" => { + wl_clipboard_rs::paste::MimeType::TextWithPriority("image/png") + }, + _ => wl_clipboard_rs::paste::MimeType::Any, + }; + // Initialize with current clipboard - if let Ok((mut reader, _)) = get_contents( - ClipboardType::Regular, - Seat::Unspecified, - wl_clipboard_rs::paste::MimeType::Any, - ) { + if let Ok((mut reader, _)) = + get_contents(ClipboardType::Regular, Seat::Unspecified, mime_type) + { buf.clear(); if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() { last_hash = Some(hash_contents(&buf)); @@ -205,7 +217,7 @@ impl WatchCommand for SqliteClipboardDb { if let Ok((mut reader, _)) = get_contents( ClipboardType::Regular, Seat::Unspecified, - wl_clipboard_rs::paste::MimeType::Any, + mime_type, ) { let mut current_buf = Vec::new(); if reader.read_to_end(&mut current_buf).is_ok() @@ -250,11 +262,8 @@ impl WatchCommand for SqliteClipboardDb { } // Normal clipboard polling - match get_contents( - ClipboardType::Regular, - Seat::Unspecified, - wl_clipboard_rs::paste::MimeType::Any, - ) { + match get_contents(ClipboardType::Regular, Seat::Unspecified, mime_type) + { Ok((mut reader, _mime_type)) => { buf.clear(); if let Err(e) = reader.read_to_end(&mut buf) { diff --git a/src/db/mod.rs b/src/db/mod.rs index f2048cd..8999bd5 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -10,7 +10,6 @@ use std::{ }; use base64::prelude::*; -use imagesize::ImageType; use log::{debug, error, warn}; use regex::Regex; use rusqlite::{Connection, OptionalExtension, params}; @@ -429,7 +428,7 @@ impl ClipboardDb for SqliteClipboardDb { #[allow(clippy::cast_possible_wrap)] let content_hash = hasher.finish() as i64; - let mime = detect_mime_optimized(&buf); + let mime = crate::mime::detect_mime(&buf); // Try to load regex from systemd credential file, then env var let regex = load_sensitive_regex(); @@ -884,51 +883,6 @@ pub fn extract_id(input: &str) -> Result { id_str.parse().map_err(|_| "invalid id") } -pub fn detect_mime_optimized(data: &[u8]) -> Option { - // Check if it's valid UTF-8 first, which most clipboard content are. - // This will be used to return early without unnecessary mimetype detection - // overhead. - if std::str::from_utf8(data).is_ok() { - return Some("text/plain".to_string()); - } - - // Only run image detection on binary data - detect_mime(data) -} - -pub fn detect_mime(data: &[u8]) -> Option { - if let Ok(img_type) = imagesize::image_type(data) { - let mime_str = 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", - }; - Some(mime_str.to_string()) - } else { - None - } -} - pub fn preview_entry(data: &[u8], mime: Option<&str>, width: u32) -> String { if let Some(mime) = mime { if mime.starts_with("image/") { @@ -1239,7 +1193,7 @@ mod tests { assert_eq!( get_schema_version(&db.conn).expect("Failed to get schema version"), - 3 + 5 ); assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); @@ -1290,7 +1244,7 @@ mod tests { assert_eq!( get_schema_version(&db.conn) .expect("Failed to get version after migration"), - 3 + 5 ); assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); @@ -1332,7 +1286,7 @@ mod tests { assert_eq!( get_schema_version(&db.conn) .expect("Failed to get version after migration"), - 3 + 5 ); assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); @@ -1375,7 +1329,7 @@ mod tests { assert_eq!( get_schema_version(&db.conn) .expect("Failed to get version after migration"), - 3 + 5 ); assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); @@ -1411,7 +1365,7 @@ mod tests { get_schema_version(&db2.conn).expect("Failed to get version"); assert_eq!(version_after_first, version_after_second); - assert_eq!(version_after_first, 3); + assert_eq!(version_after_first, 5); } #[test] @@ -1540,7 +1494,7 @@ mod tests { assert_eq!( get_schema_version(&db.conn).expect("Failed to get version"), - 3 + 5 ); let count: i64 = db diff --git a/src/main.rs b/src/main.rs index aca9838..56c2170 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ use inquire::Confirm; mod commands; pub(crate) mod db; +pub(crate) mod mime; mod multicall; #[cfg(feature = "use-toplevel")] mod wayland; @@ -130,6 +131,10 @@ enum Command { /// Expire new entries after duration (e.g., "3s", "500ms", "1h30m"). #[arg(long, value_parser = parse_duration)] expire_after: Option, + + /// MIME type preference for clipboard reading. + #[arg(short = 't', long, default_value = "any")] + mime_type: String, }, } @@ -433,7 +438,10 @@ fn main() -> color_eyre::eyre::Result<()> { } } }, - Some(Command::Watch { expire_after }) => { + Some(Command::Watch { + expire_after, + mime_type, + }) => { db.watch( cli.max_dedupe_search, cli.max_items, @@ -442,6 +450,7 @@ fn main() -> color_eyre::eyre::Result<()> { #[cfg(not(feature = "use-toplevel"))] &[], expire_after, + &mime_type, ); }, diff --git a/src/mime.rs b/src/mime.rs new file mode 100644 index 0000000..fd9c448 --- /dev/null +++ b/src/mime.rs @@ -0,0 +1,149 @@ +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 { + 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); + } +} From 5c8591b2e53eea200eb7a5c2668609292e757105 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 23 Jan 2026 23:10:58 +0300 Subject: [PATCH 072/121] docs: mention MIME preference usage in README Signed-off-by: NotAShelf Change-Id: I3bda3397f0350f27523b419bd079f8756a6a6964 --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 72ea591..faabc1c 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,30 @@ entry has expired from history. > 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. + ### Options Some commands take additional flags to modify Stash's behavior. See each From bb8e88256581d56d9fccfd43268cbfe354dfbff1 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 31 Jan 2026 12:12:19 +0300 Subject: [PATCH 073/121] mime: expand test coverage Signed-off-by: NotAShelf Change-Id: I3f17b98ad68f17ebcf9554e5e88f62676a6a6964 --- src/mime.rs | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/src/mime.rs b/src/mime.rs index fd9c448..3761ab3 100644 --- a/src/mime.rs +++ b/src/mime.rs @@ -146,4 +146,128 @@ mod tests { 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" + ); + } } From b4dd70496157c5d01049d2d56ef6e68ab9313968 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 31 Jan 2026 12:47:26 +0300 Subject: [PATCH 074/121] db: add an in-memory test helper Signed-off-by: NotAShelf Change-Id: I22cc10df47265fa4d08d5c03cadbe9c56a6a6964 --- src/db/mod.rs | 263 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) diff --git a/src/db/mod.rs b/src/db/mod.rs index 8999bd5..4b57ae5 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1161,6 +1161,13 @@ mod tests { use super::*; + /// Create an in-memory test database with full schema. + fn test_db() -> SqliteClipboardDb { + let conn = + Connection::open_in_memory().expect("Failed to open in-memory db"); + SqliteClipboardDb::new(conn).expect("Failed to create test database") + } + fn get_schema_version(conn: &Connection) -> rusqlite::Result { conn.pragma_query_value(None, "user_version", |row| row.get(0)) } @@ -1503,4 +1510,260 @@ mod tests { .expect("Failed to count"); assert_eq!(count, 1, "Existing data should be preserved"); } + + #[test] + fn test_store_uri_list_content() { + let db = test_db(); + let data = b"file:///home/user/document.pdf\nfile:///home/user/image.png"; + let id = db + .store_entry(std::io::Cursor::new(data.to_vec()), 100, 1000, None) + .expect("Failed to store URI list"); + + let mime: Option = db + .conn + .query_row("SELECT mime FROM clipboard WHERE id = ?1", [id], |row| { + row.get(0) + }) + .expect("Failed to get mime"); + assert_eq!(mime, Some("text/uri-list".to_string())); + } + + #[test] + fn test_store_binary_image() { + let db = test_db(); + // Minimal PNG header + let data: Vec = vec![ + 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, 0x00, 0x00, 0x00, // bit depth, color, etc. + 0x90, 0x77, 0x53, 0xDE, // CRC + ]; + let id = db + .store_entry(std::io::Cursor::new(data.clone()), 100, 1000, None) + .expect("Failed to store image"); + + let (contents, mime): (Vec, Option) = db + .conn + .query_row( + "SELECT contents, mime FROM clipboard WHERE id = ?1", + [id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("Failed to get stored entry"); + assert_eq!(contents, data); + assert_eq!(mime, Some("image/png".to_string())); + } + + #[test] + fn test_deduplication() { + let db = test_db(); + let data = b"duplicate content"; + + let id1 = db + .store_entry(std::io::Cursor::new(data.to_vec()), 100, 1000, None) + .expect("Failed to store first"); + let _id2 = db + .store_entry(std::io::Cursor::new(data.to_vec()), 100, 1000, None) + .expect("Failed to store second"); + + // First entry should have been removed by deduplication + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 1, "Deduplication should keep only one copy"); + + // The original id should be gone + let exists: bool = db + .conn + .query_row( + "SELECT COUNT(*) FROM clipboard WHERE id = ?1", + [id1], + |row| row.get::<_, i64>(0), + ) + .map(|c| c > 0) + .unwrap_or(false); + assert!(!exists, "Old entry should be removed"); + } + + #[test] + fn test_trim_excess_entries() { + let db = test_db(); + for i in 0..5 { + let data = format!("entry {i}"); + db.store_entry( + std::io::Cursor::new(data.into_bytes()), + 100, + 3, // max 3 items + None, + ) + .expect("Failed to store"); + } + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert!(count <= 3, "Trim should keep at most max_items entries"); + } + + #[test] + fn test_reject_empty_input() { + let db = test_db(); + let result = + db.store_entry(std::io::Cursor::new(Vec::new()), 100, 1000, None); + assert!(matches!(result, Err(StashError::EmptyOrTooLarge))); + } + + #[test] + fn test_reject_whitespace_input() { + let db = test_db(); + let result = db.store_entry( + std::io::Cursor::new(b" \n\t ".to_vec()), + 100, + 1000, + None, + ); + assert!(matches!(result, Err(StashError::AllWhitespace))); + } + + #[test] + fn test_reject_oversized_input() { + let db = test_db(); + // 5MB + 1 byte + let data = vec![b'a'; 5 * 1_000_000 + 1]; + let result = db.store_entry(std::io::Cursor::new(data), 100, 1000, None); + assert!(matches!(result, Err(StashError::EmptyOrTooLarge))); + } + + #[test] + fn test_delete_entries_by_id() { + let db = test_db(); + let id = db + .store_entry(std::io::Cursor::new(b"to delete".to_vec()), 100, 1000, None) + .expect("Failed to store"); + + let input = format!("{id}\tpreview text\n"); + let deleted = db + .delete_entries(std::io::Cursor::new(input.into_bytes())) + .expect("Failed to delete"); + assert_eq!(deleted, 1); + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 0); + } + + #[test] + fn test_delete_query_matching() { + let db = test_db(); + db.store_entry( + std::io::Cursor::new(b"secret password 123".to_vec()), + 100, + 1000, + None, + ) + .expect("Failed to store"); + db.store_entry( + std::io::Cursor::new(b"normal text".to_vec()), + 100, + 1000, + None, + ) + .expect("Failed to store"); + + let deleted = db + .delete_query("secret password") + .expect("Failed to delete query"); + assert_eq!(deleted, 1); + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 1); + } + + #[test] + fn test_wipe_db() { + let db = test_db(); + for i in 0..3 { + let data = format!("entry {i}"); + db.store_entry(std::io::Cursor::new(data.into_bytes()), 100, 1000, None) + .expect("Failed to store"); + } + + db.wipe_db().expect("Failed to wipe"); + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 0); + } + + #[test] + fn test_extract_id_valid() { + assert_eq!(extract_id("42\tsome preview"), Ok(42)); + assert_eq!(extract_id("1"), Ok(1)); + assert_eq!(extract_id("999\t"), Ok(999)); + } + + #[test] + fn test_extract_id_invalid() { + assert!(extract_id("abc\tpreview").is_err()); + assert!(extract_id("").is_err()); + assert!(extract_id("\tpreview").is_err()); + } + + #[test] + fn test_preview_entry_text() { + let data = b"Hello, world!"; + let preview = preview_entry(data, Some("text/plain"), 100); + assert_eq!(preview, "Hello, world!"); + } + + #[test] + fn test_preview_entry_image() { + let data = vec![0x89, 0x50, 0x4E, 0x47]; // PNG-ish bytes + let preview = preview_entry(&data, Some("image/png"), 100); + assert!(preview.contains("binary data")); + assert!(preview.contains("image/png")); + } + + #[test] + fn test_preview_entry_truncation() { + let data = b"This is a rather long piece of text that should be truncated"; + let preview = preview_entry(data, Some("text/plain"), 10); + assert!(preview.len() <= 15); // 10 chars + ellipsis (multi-byte) + assert!(preview.ends_with('…')); + } + + #[test] + fn test_size_str_formatting() { + assert_eq!(size_str(0), "0 B"); + assert_eq!(size_str(512), "512 B"); + assert_eq!(size_str(1024), "1 KiB"); + assert_eq!(size_str(1024 * 1024), "1 MiB"); + } + + #[test] + fn test_copy_entry_returns_data() { + let db = test_db(); + let data = b"copy me"; + let id = db + .store_entry(std::io::Cursor::new(data.to_vec()), 100, 1000, None) + .expect("Failed to store"); + + let (returned_id, contents, mime) = + db.copy_entry(id).expect("Failed to copy"); + assert_eq!(returned_id, id); + assert_eq!(contents, data.to_vec()); + assert_eq!(mime, Some("text/plain".to_string())); + } } From 3fd48896c122ba7872dbf4a7bbadaf62b053a7cd Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 31 Jan 2026 12:47:56 +0300 Subject: [PATCH 075/121] watch: respect source MIME type order in clipboard polling Signed-off-by: NotAShelf Change-Id: I3da2e187276611579f3686acb20aacf36a6a6964 --- src/commands/watch.rs | 162 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 141 insertions(+), 21 deletions(-) diff --git a/src/commands/watch.rs b/src/commands/watch.rs index ce04495..3a7b7b2 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -8,7 +8,13 @@ use std::{ use smol::Timer; use wl_clipboard_rs::{ copy::{MimeType as CopyMimeType, Options, Source}, - paste::{ClipboardType, Seat, get_contents}, + paste::{ + ClipboardType, + MimeType as PasteMimeType, + Seat, + get_contents, + get_mime_types_ordered, + }, }; use crate::db::{ClipboardDb, SqliteClipboardDb}; @@ -93,6 +99,63 @@ impl ExpirationQueue { } } +/// 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. +fn negotiate_mime_type( + preference: &str, +) -> Result<(Box, String), wl_clipboard_rs::paste::Error> { + if preference == "text" { + let (reader, mime_str) = get_contents( + ClipboardType::Regular, + Seat::Unspecified, + PasteMimeType::Text, + )?; + return Ok((Box::new(reader) as Box, mime_str)); + } + + let offered = + get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified)?; + + 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 { + 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, actual_mime)) + }, + None => Err(wl_clipboard_rs::paste::Error::NoSeats), + } +} + pub trait WatchCommand { fn watch( &self, @@ -165,19 +228,8 @@ impl WatchCommand for SqliteClipboardDb { hasher.finish() }; - // Convert MIME type preference string to wl_clipboard_rs enum - let mime_type = match mime_type_preference { - "text" => wl_clipboard_rs::paste::MimeType::Text, - "image" => { - wl_clipboard_rs::paste::MimeType::TextWithPriority("image/png") - }, - _ => wl_clipboard_rs::paste::MimeType::Any, - }; - - // Initialize with current clipboard - if let Ok((mut reader, _)) = - get_contents(ClipboardType::Regular, Seat::Unspecified, mime_type) - { + // 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)); @@ -214,11 +266,9 @@ impl WatchCommand for SqliteClipboardDb { log::info!("Entry {id} marked as expired"); // Check if this expired entry is currently in the clipboard - if let Ok((mut reader, _)) = get_contents( - ClipboardType::Regular, - Seat::Unspecified, - mime_type, - ) { + 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() @@ -262,8 +312,7 @@ impl WatchCommand for SqliteClipboardDb { } // Normal clipboard polling - match get_contents(ClipboardType::Regular, Seat::Unspecified, mime_type) - { + match negotiate_mime_type(mime_type_preference) { Ok((mut reader, _mime_type)) => { buf.clear(); if let Err(e) = reader.read_to_end(&mut buf) { @@ -328,3 +377,74 @@ impl WatchCommand for SqliteClipboardDb { }); } } + +/// Unit-testable helper: 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 { + 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 = vec![]; + assert!(pick_mime(&offered, "any").is_none()); + } + + #[test] + fn test_pick_source_order_preserved() { + // Firefox typically offers html first, then image, then text + let offered = vec![ + "text/html".to_string(), + "image/png".to_string(), + "text/plain".to_string(), + ]; + // With "any", we trust the source: first offered wins + assert_eq!(pick_mime(&offered, "any").unwrap(), "text/html"); + } + + #[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"); + } +} From 9afbe9ceca9d2830fbff20f34be8f2d20e344ab1 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 1 Feb 2026 12:25:17 +0300 Subject: [PATCH 076/121] 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 (``) 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 Change-Id: I6bd5969344893e15226c27071442475f6a6a6964 --- src/commands/watch.rs | 63 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 3a7b7b2..54dc803 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -140,7 +140,26 @@ fn negotiate_mime_type( .find(|m| m.starts_with("image/")) .or_else(|| offered.first()) } else { - offered.first() + // 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 (), 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 { @@ -392,7 +411,20 @@ fn pick_mime<'a>( .find(|m| m.starts_with("image/")) .or_else(|| offered.first()) } else { - offered.first() + 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() + } } } @@ -430,17 +462,38 @@ mod tests { } #[test] - fn test_pick_source_order_preserved() { - // Firefox typically offers html first, then image, then text + 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(), ]; - // With "any", we trust the source: first offered wins + 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 From cff9f7bbbac538a010208edca82c4c214363aa7c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 1 Feb 2026 18:06:43 +0300 Subject: [PATCH 077/121] chore: bump dependencies Signed-off-by: NotAShelf Change-Id: Ib0445df9b8e5f0d4aabfcd4ff1bc27f16a6a6964 --- Cargo.lock | 12 ++++++------ Cargo.toml | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 26321a7..0164c7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -386,9 +386,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.5.54" +version = "4.5.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e" dependencies = [ "clap_builder", "clap_derive", @@ -406,9 +406,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.5.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0" dependencies = [ "anstream", "anstyle", @@ -418,9 +418,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index cd21891..7e6dade 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ path = "src/main.rs" [dependencies] base64 = "0.22.1" -clap = { version = "4.5.54", features = ["derive", "env"] } +clap = { version = "4.5.56", features = ["derive", "env"] } clap-verbosity-flag = "3.0.4" color-eyre = "0.6.5" crossterm = "0.29.0" @@ -44,7 +44,7 @@ wayland-protocols-wlr = { version = "0.3.10", default-features = false, optional wl-clipboard-rs = "0.9.3" [dev-dependencies] -tempfile = "3.18.0" +tempfile = "3.24.0" [features] default = ["notifications", "use-toplevel"] From 2e086800d0f4fd078a0bc3b455982cf0cec32bd5 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 1 Feb 2026 18:06:57 +0300 Subject: [PATCH 078/121] chore: format TOML with Taplo Signed-off-by: NotAShelf Change-Id: I2ecc8923946ace5288a1c45ca202cb956a6a6964 --- .taplo.toml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .taplo.toml diff --git a/.taplo.toml b/.taplo.toml new file mode 100644 index 0000000..b19e6b9 --- /dev/null +++ b/.taplo.toml @@ -0,0 +1,14 @@ +[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 + From 2227ef7e8992943fcaa00199473fbdb4b4119356 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 1 Feb 2026 18:08:14 +0300 Subject: [PATCH 079/121] chore: format Cargo manifest with Taplo; v0.3.5 Signed-off-by: NotAShelf Change-Id: Id35b12bba16b0e181bb4536154259b5a6a6a6964 --- Cargo.lock | 2 +- Cargo.toml | 78 ++++++++++++++++++++++++++---------------------------- 2 files changed, 39 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0164c7c..c2882f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2124,7 +2124,7 @@ dependencies = [ [[package]] name = "stash-clipboard" -version = "0.3.4" +version = "0.3.5" dependencies = [ "base64", "clap", diff --git a/Cargo.toml b/Cargo.toml index 7e6dade..d2bfe15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ [package] -name = "stash-clipboard" -description = "Wayland clipboard manager with fast persistent history and multi-media support" -version = "0.3.4" -edition = "2024" -authors = ["NotAShelf "] -license = "MPL-2.0" -readme = true -repository = "https://github.com/notashelf/stash" +name = "stash-clipboard" +description = "Wayland clipboard manager with fast persistent history and multi-media support" +version = "0.3.5" +edition = "2024" +authors = [ "NotAShelf " ] +license = "MPL-2.0" +readme = true +repository = "https://github.com/notashelf/stash" rust-version = "1.90" [[bin]] @@ -14,44 +14,42 @@ name = "stash" # actual binary name for Nix, Cargo, etc. path = "src/main.rs" [dependencies] -base64 = "0.22.1" -clap = { version = "4.5.56", features = ["derive", "env"] } -clap-verbosity-flag = "3.0.4" -color-eyre = "0.6.5" -crossterm = "0.29.0" -ctrlc = "3.5.1" -dirs = "6.0.0" -env_logger = "0.11.8" -humantime = "2.3.0" -imagesize = "0.14.0" -inquire = { version = "0.9.2", default-features = false, features = [ - "crossterm", -] } -libc = "0.2.180" -log = "0.4.29" -notify-rust = { version = "4.11.7", optional = true } -ratatui = "0.30.0" -regex = "1.12.2" -rusqlite = { version = "0.38.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.12.0" -unicode-width = "0.2.2" -wayland-client = { version = "0.31.12", features = ["log"], optional = true } +base64 = "0.22.1" +clap = { version = "4.5.56", features = [ "derive", "env" ] } +clap-verbosity-flag = "3.0.4" +color-eyre = "0.6.5" +crossterm = "0.29.0" +ctrlc = "3.5.1" +dirs = "6.0.0" +env_logger = "0.11.8" +humantime = "2.3.0" +imagesize = "0.14.0" +inquire = { version = "0.9.2", default-features = false, features = [ "crossterm" ] } +libc = "0.2.180" +log = "0.4.29" +notify-rust = { version = "4.11.7", optional = true } +ratatui = "0.30.0" +regex = "1.12.2" +rusqlite = { version = "0.38.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.12.0" +unicode-width = "0.2.2" +wayland-client = { version = "0.31.12", features = [ "log" ], optional = true } wayland-protocols-wlr = { version = "0.3.10", default-features = false, optional = true } -wl-clipboard-rs = "0.9.3" +wl-clipboard-rs = "0.9.3" [dev-dependencies] tempfile = "3.24.0" [features] -default = ["notifications", "use-toplevel"] -notifications = ["dep:notify-rust"] -use-toplevel = ["dep:wayland-client", "dep:wayland-protocols-wlr"] +default = [ "notifications", "use-toplevel" ] +notifications = [ "dep:notify-rust" ] +use-toplevel = [ "dep:wayland-client", "dep:wayland-protocols-wlr" ] [profile.release] +lto = true opt-level = "z" -strip = true -lto = true +strip = true From 134da06fd0e4a0713f51d0b8ecfe7987cdb205d9 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 07:53:51 +0300 Subject: [PATCH 080/121] chore: bump dependencies Signed-off-by: NotAShelf Change-Id: I53a5279d1c3e74ae54e2f32a800f83766a6a6964 --- Cargo.lock | 40 ++++++++++++++++++++-------------------- Cargo.toml | 12 ++++++------ 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c2882f3..3c67fbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -386,9 +386,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.5.56" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", "clap_derive", @@ -406,9 +406,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.56" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", @@ -430,9 +430,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "color-eyre" @@ -1056,9 +1056,9 @@ dependencies = [ [[package]] name = "inquire" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae51d5da01ce7039024fbdec477767c102c454dbdb09d4e2a432ece705b1b25d" +checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" dependencies = [ "bitflags 2.10.0", "crossterm", @@ -1160,9 +1160,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libredox" @@ -1196,9 +1196,9 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litrs" @@ -1346,9 +1346,9 @@ dependencies = [ [[package]] name = "notify-rust" -version = "4.11.7" +version = "4.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400" +checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" dependencies = [ "futures-lite", "log", @@ -1860,9 +1860,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -1929,9 +1929,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags 2.10.0", "errno", @@ -2224,9 +2224,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", "getrandom 0.3.4", diff --git a/Cargo.toml b/Cargo.toml index d2bfe15..8ef6a3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ path = "src/main.rs" [dependencies] base64 = "0.22.1" -clap = { version = "4.5.56", features = [ "derive", "env" ] } +clap = { version = "4.5.60", features = [ "derive", "env" ] } clap-verbosity-flag = "3.0.4" color-eyre = "0.6.5" crossterm = "0.29.0" @@ -24,12 +24,12 @@ dirs = "6.0.0" env_logger = "0.11.8" humantime = "2.3.0" imagesize = "0.14.0" -inquire = { version = "0.9.2", default-features = false, features = [ "crossterm" ] } -libc = "0.2.180" +inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] } +libc = "0.2.182" log = "0.4.29" -notify-rust = { version = "4.11.7", optional = true } +notify-rust = { version = "4.12.0", optional = true } ratatui = "0.30.0" -regex = "1.12.2" +regex = "1.12.3" rusqlite = { version = "0.38.0", features = [ "bundled" ] } serde = { version = "1.0.228", features = [ "derive" ] } serde_json = "1.0.149" @@ -42,7 +42,7 @@ wayland-protocols-wlr = { version = "0.3.10", default-features = false, optional wl-clipboard-rs = "0.9.3" [dev-dependencies] -tempfile = "3.24.0" +tempfile = "3.26.0" [features] default = [ "notifications", "use-toplevel" ] From 2edecf4c17a77c93bb858fba5d00f9a78ee9021c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 07:54:18 +0300 Subject: [PATCH 081/121] chore: format with taplo Signed-off-by: NotAShelf Change-Id: I942883a08eccc5decd38a6865b3451496a6a6964 --- .rustfmt.toml | 46 +++++++++++++++++++++++----------------------- .taplo.toml | 1 - 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/.rustfmt.toml b/.rustfmt.toml index 324bf8b..9d5c77e 100644 --- a/.rustfmt.toml +++ b/.rustfmt.toml @@ -1,26 +1,26 @@ -condense_wildcard_suffixes = true +condense_wildcard_suffixes = true doc_comment_code_block_width = 80 -edition = "2024" # Keep in sync with Cargo.toml. +edition = "2024" # Keep in sync with Cargo.toml. enum_discrim_align_threshold = 60 -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 +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 +tab_spaces = 2 +unstable_features = true +use_field_init_shorthand = true +use_try_shorthand = true +wrap_comments = true diff --git a/.taplo.toml b/.taplo.toml index b19e6b9..fae0c57 100644 --- a/.taplo.toml +++ b/.taplo.toml @@ -11,4 +11,3 @@ keys = [ "package" ] [rule.formatting] reorder_keys = false - From d367728b39206fb298bc8b7ef4c6c36d84c59c39 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 07:54:51 +0300 Subject: [PATCH 082/121] chore: set MSRV to 1.91.0 Signed-off-by: NotAShelf Change-Id: Iadde6dfe7e79a365edf4d664b941c0776a6a6964 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 8ef6a3f..51e12ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ authors = [ "NotAShelf " ] license = "MPL-2.0" readme = true repository = "https://github.com/notashelf/stash" -rust-version = "1.90" +rust-version = "1.91.0" [[bin]] name = "stash" # actual binary name for Nix, Cargo, etc. From 2e3c73957a27e544669472e4c6c7d191b2554ca1 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 08:10:52 +0300 Subject: [PATCH 083/121] meta: allow disabling symlinks in build script via env vars Signed-off-by: NotAShelf Change-Id: I07f5d565d26ca527d413edf69857539e6a6a6964 --- build.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/build.rs b/build.rs index f777a7c..b511acb 100644 --- a/build.rs +++ b/build.rs @@ -4,6 +4,9 @@ use std::{env, fs, path::Path}; const MULTICALL_LINKS: &[&str] = &["stash-copy", "stash-paste", "wl-copy", "wl-paste"]; +/// Wayland-specific symlinks that can be disabled separately +const WAYLAND_LINKS: &[&str] = &["wl-copy", "wl-paste"]; + fn main() { // OUT_DIR is something like .../target/debug/build//out // We want .../target/debug or .../target/release @@ -16,8 +19,24 @@ fn main() { // Path to the main stash binary let stash_bin = bin_dir.join("stash"); + // Check for environment variables to disable symlinking + let disable_all_symlinks = env::var("STASH_NO_SYMLINKS").is_ok(); + let disable_wayland_symlinks = env::var("STASH_NO_WL_SYMLINKS").is_ok(); + // Create symlinks for each multicall binary for link in MULTICALL_LINKS { + if disable_all_symlinks { + println!("cargo:warning=Skipping symlink {link} (all symlinks disabled)"); + continue; + } + + if disable_wayland_symlinks && WAYLAND_LINKS.contains(link) { + println!( + "cargo:warning=Skipping symlink {link} (wayland symlinks disabled)" + ); + continue; + } + let link_path = bin_dir.join(link); // Remove existing symlink or file if present let _ = fs::remove_file(&link_path); From 4d58cae50db95c0f5a316cfe64a17f4bd06102cb Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 09:11:43 +0300 Subject: [PATCH 084/121] nix: add platforms to meta; allow overriding symlink behaviour Signed-off-by: NotAShelf Change-Id: Ib6e44abd86bd0e58f290b456680a97236a6a6964 --- nix/package.nix | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/nix/package.nix b/nix/package.nix index 336926a..b068d4a 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -4,9 +4,10 @@ stdenv, mold, versionCheckHook, + 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 = ../.; @@ -36,7 +37,7 @@ in # 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 = '' + 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 @@ -48,7 +49,7 @@ in # 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 = '' + 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 @@ -65,5 +66,6 @@ in license = lib.licenses.mpl20; maintainers = [lib.maintainers.NotAShelf]; mainProgram = "stash"; + platforms = lib.platforms.linux; }; } From 0215ebeb6ce6f56da2597186eccb9948e27d8109 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 11:09:39 +0300 Subject: [PATCH 085/121] chore: recursively bump `time` dep Signed-off-by: NotAShelf Change-Id: I57471a3c88a4cfe2d267f0fa8ceb59946a6a6964 --- Cargo.lock | 76 +++++++++++++++++++++++++++--------------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c67fbf..d3c1ace 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -203,7 +203,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -238,7 +238,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -425,7 +425,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -592,7 +592,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -603,7 +603,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -614,9 +614,9 @@ checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", ] @@ -640,7 +640,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -737,7 +737,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1077,7 +1077,7 @@ dependencies = [ "indoc", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1122,7 +1122,7 @@ checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1360,9 +1360,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-derive" @@ -1372,7 +1372,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1553,7 +1553,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1617,7 +1617,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1725,9 +1725,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.43" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -1991,7 +1991,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2015,7 +2015,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2185,7 +2185,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2201,9 +2201,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -2324,7 +2324,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2335,7 +2335,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2349,9 +2349,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.45" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "libc", @@ -2364,9 +2364,9 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "toml_datetime" @@ -2417,7 +2417,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2487,9 +2487,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" @@ -2607,7 +2607,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2839,7 +2839,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2850,7 +2850,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2997,7 +2997,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "zbus_names", "zvariant", "zvariant_utils", @@ -3043,7 +3043,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "zvariant_utils", ] @@ -3056,6 +3056,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.114", + "syn 2.0.117", "winnow", ] From 88c1f0f1586ac6f45c76775f17bd1566ca8a114a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 10:28:53 +0300 Subject: [PATCH 086/121] commands/list: full TUI rewrite for better perf Signed-off-by: NotAShelf Change-Id: I49009a89542fdeeea31d3755108b53d06a6a6964 --- src/commands/list.rs | 513 +++++++++++++++++++++++++++++-------------- src/db/mod.rs | 70 ++++++ 2 files changed, 418 insertions(+), 165 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index 25903f3..2651370 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -27,6 +27,178 @@ impl ListCommand for SqliteClipboardDb { } } +/// 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, +} + +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, + ) -> Result { + let total = db.count_entries(include_expired)?; + let window = if total > 0 { + db.fetch_entries_window(include_expired, 0, window_size, preview_width)? + } else { + Vec::new() + }; + Ok(Self { + total, + cursor: 0, + viewport_offset: 0, + window, + window_size, + dirty: false, + }) + } + + /// 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 + }; + + self.window = if self.total > 0 { + db.fetch_entries_window( + include_expired, + self.viewport_offset, + self.window_size, + preview_width, + )? + } 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( @@ -63,46 +235,9 @@ impl SqliteClipboardDb { }; use wl_clipboard_rs::copy::{MimeType, Options, Source}; - // Query entries from DB - let query = if include_expired { - "SELECT id, contents, mime FROM clipboard ORDER BY last_accessed DESC, \ - id DESC" - } else { - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0) ORDER BY last_accessed DESC, id DESC" - }; - let mut stmt = self - .conn - .prepare(query) - .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 entries: Vec<(i64, String, String)> = Vec::new(); - let mut max_id_width = 2; - let mut max_mime_width = 8; - while let Some(row) = rows - .next() - .map_err(|e| StashError::ListDecode(e.to_string().into()))? - { - let id: i64 = row - .get(0) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mime: Option = row - .get(2) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let preview = - crate::db::preview_entry(&contents, mime.as_deref(), preview_width); - let mime_str = mime.as_deref().unwrap_or("").to_string(); - let id_str = id.to_string(); - max_id_width = max_id_width.max(id_str.width()); - max_mime_width = max_mime_width.max(mime_str.width()); - entries.push((id, preview, mime_str)); - } + // 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()))?; @@ -113,13 +248,91 @@ impl SqliteClipboardDb { let mut terminal = Terminal::new(backend) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut state = ListState::default(); - if !entries.is_empty() { - state.select(Some(0)); + // 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)?; + + // ratatui ListState; only tracks selection within the *window* slice. + let mut list_state = ListState::default(); + if tui.total > 0 { + list_state.select(Some(0)); } - let res = (|| -> Result<(), StashError> { - loop { + /// Accumulated actions from draining the event queue. + struct EventActions { + quit: bool, + net_down: i64, // positive=down, negative=up, 0=none + copy: bool, + delete: bool, + } + + /// 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() -> Result { + let mut actions = EventActions { + quit: false, + net_down: 0, + copy: false, + delete: 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()))? + { + 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, + _ => {}, + } + } + } + Ok(actions) + } + + let draw_frame = + |terminal: &mut Terminal>, + tui: &mut TuiState, + list_state: &mut ListState, + max_id_width: usize, + max_mime_width: usize| + -> Result<(), StashError> { + let term_height = terminal + .size() + .map(|r| r.height.saturating_sub(2) 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(); @@ -135,13 +348,11 @@ impl SqliteClipboardDb { let highlight_width = 1; let content_width = area.width as usize - border_width; - // Minimum widths for columns let min_id_width = 2; let min_mime_width = 6; let min_preview_width = 4; - let spaces = 3; // [id][ ][preview][ ][mime] + let spaces = 3; - // Dynamically allocate widths 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 @@ -150,7 +361,6 @@ impl SqliteClipboardDb { .saturating_sub(mime_col) .saturating_sub(spaces); - // If not enough space, shrink columns if preview_col < min_preview_width { let needed = min_preview_width - preview_col; if mime_col > min_mime_width { @@ -173,13 +383,13 @@ impl SqliteClipboardDb { preview_col = min_preview_width; } - let selected = state.selected(); + let selected = list_state.selected(); - let list_items: Vec = entries + let list_items: Vec = tui + .window .iter() .enumerate() .map(|(i, entry)| { - // Truncate preview by grapheme clusters and display width let mut preview = String::new(); let mut width = 0; for g in entry.1.graphemes(true) { @@ -191,7 +401,6 @@ impl SqliteClipboardDb { preview.push_str(g); width += g_width; } - // Truncate and pad mimetype let mut mime = String::new(); let mut mwidth = 0; for g in entry.2.graphemes(true) { @@ -204,8 +413,6 @@ impl SqliteClipboardDb { mwidth += g_width; } - // Compose the row as highlight + id + space + preview + space + - // mimetype let mut spans = Vec::new(); let (id, preview, mime) = entry; if Some(i) == selected { @@ -252,133 +459,109 @@ impl SqliteClipboardDb { .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ) - .highlight_symbol(""); // handled manually + .highlight_symbol(""); - f.render_stateful_widget(list, area, &mut state); + 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 Event::Key(key) = event::read() - .map_err(|e| StashError::ListDecode(e.to_string().into()))? { - match (key.code, key.modifiers) { - (KeyCode::Char('q') | KeyCode::Esc, _) => break, - (KeyCode::Down | KeyCode::Char('j'), _) => { - if entries.is_empty() { - state.select(None); - } else { - let i = match state.selected() { - Some(i) => { - if i >= entries.len() - 1 { - 0 - } else { - i + 1 - } - }, - None => 0, + let actions = drain_events()?; + + if actions.quit { + break; + } + + // Apply navigation (capped at ±1 per frame for smooth scrolling). + 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() + { + 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().to_owned()), + None => MimeType::Text, }; - state.select(Some(i)); - } - }, - (KeyCode::Up | KeyCode::Char('k'), _) => { - if entries.is_empty() { - state.select(None); - } else { - let i = match state.selected() { - Some(i) => { - if i == 0 { - entries.len() - 1 - } else { - i - 1 - } - }, - None => 0, - }; - state.select(Some(i)); - } - }, - (KeyCode::Enter, _) => { - if let Some(idx) = state.selected() - && let Some((id, ..)) = entries.get(idx) - { - match self.copy_entry(*id) { - Ok((new_id, contents, mime)) => { - if new_id != *id { - entries[idx] = ( - new_id, - entries[idx].1.clone(), - entries[idx].2.clone(), - ); - } - 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().to_owned()), - 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 copy_result = + opts.copy(Source::Bytes(contents.clone().into()), mime_type); + match copy_result { + Ok(()) => { let _ = Notification::new() .summary("Stash") - .body(&format!("Failed to fetch entry: {e}")) + .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(); }, } - } - }, - (KeyCode::Char('D'), KeyModifiers::SHIFT) => { - if let Some(idx) = state.selected() - && let Some((id, ..)) = entries.get(idx) - { - // Delete entry from DB - self - .conn - .execute( - "DELETE FROM clipboard WHERE id = ?1", - rusqlite::params![id], - ) - .map_err(|e| { - StashError::DeleteEntry(*id, e.to_string().into()) - })?; - // Remove from entries and update selection - entries.remove(idx); - let new_len = entries.len(); - if new_len == 0 { - state.select(None); - } else if idx >= new_len { - state.select(Some(new_len - 1)); - } else { - state.select(Some(idx)); - } - // Show notification + }, + Err(e) => { + log::error!("Failed to fetch entry {id}: {e}"); let _ = Notification::new() .summary("Stash") - .body("Deleted entry") + .body(&format!("Failed to fetch entry: {e}")) .show(); - } - }, - _ => {}, + }, + } } + + // Redraw once after processing all accumulated input. + draw_frame( + &mut terminal, + &mut tui, + &mut list_state, + max_id_width, + max_mime_width, + )?; } } Ok(()) diff --git a/src/db/mod.rs b/src/db/mod.rs index 4b57ae5..23e622e 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -734,6 +734,76 @@ impl ClipboardDb for SqliteClipboardDb { } impl SqliteClipboardDb { + /// Count visible clipboard entries (respects include_expired filter). + pub fn count_entries( + &self, + include_expired: bool, + ) -> Result { + let count: i64 = if include_expired { + self + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |r| r.get(0)) + } else { + self.conn.query_row( + "SELECT COUNT(*) FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0)", + [], + |r| r.get(0), + ) + } + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + Ok(count.max(0) as usize) + } + + /// Fetch a window of entries for TUI virtual scrolling. + /// + /// Returns `(id, preview_string, mime_string)` tuples for at most + /// `limit` rows starting at `offset` (0-indexed) in the canonical + /// display order (most-recently-accessed first, then id DESC). + pub fn fetch_entries_window( + &self, + include_expired: bool, + offset: usize, + limit: usize, + preview_width: u32, + ) -> Result, StashError> { + let query = if include_expired { + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) DESC, id DESC LIMIT ?1 OFFSET ?2" + } else { + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC LIMIT \ + ?1 OFFSET ?2" + }; + let mut stmt = self + .conn + .prepare(query) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let mut rows = stmt + .query(rusqlite::params![limit as i64, offset as i64]) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let mut window = Vec::with_capacity(limit); + while let Some(row) = rows + .next() + .map_err(|e| StashError::ListDecode(e.to_string().into()))? + { + let id: i64 = row + .get(0) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let contents: Vec = row + .get(1) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let mime: Option = row + .get(2) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let preview = preview_entry(&contents, mime.as_deref(), preview_width); + let mime_str = mime.unwrap_or_default(); + window.push((id, preview, mime_str)); + } + Ok(window) + } + /// Get current Unix timestamp with sub-second precision pub fn now() -> f64 { std::time::SystemTime::now() From b850a54f7be40fea8826d6dad3ad86665c05b668 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 11:24:55 +0300 Subject: [PATCH 087/121] commands/list: implement clipboard history search Signed-off-by: NotAShelf Change-Id: I57f00cbd9d02b1981cf3ea5dc908e72c6a6a6964 --- src/commands/list.rs | 343 +++++++++++++++++++++++++++++++------------ src/db/mod.rs | 102 ++++++++++--- 2 files changed, 326 insertions(+), 119 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index 2651370..03309aa 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -46,6 +46,12 @@ struct TuiState { /// 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, } impl TuiState { @@ -56,9 +62,15 @@ impl TuiState { window_size: usize, preview_width: u32, ) -> Result { - let total = db.count_entries(include_expired)?; + let total = db.count_entries(include_expired, None)?; let window = if total > 0 { - db.fetch_entries_window(include_expired, 0, window_size, preview_width)? + db.fetch_entries_window( + include_expired, + 0, + window_size, + preview_width, + None, + )? } else { Vec::new() }; @@ -69,9 +81,56 @@ impl TuiState { window, window_size, dirty: false, + search_query: String::new(), + search_mode: false, }) } + /// 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] @@ -161,12 +220,14 @@ impl TuiState { 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, )? } else { Vec::new() @@ -177,7 +238,7 @@ impl TuiState { } /// 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, +/// all entries. This is pretty damn fast as it touches only index/metadata, /// not blobs. fn global_column_widths( db: &SqliteClipboardDb, @@ -266,21 +327,29 @@ impl SqliteClipboardDb { /// Accumulated actions from draining the event queue. struct EventActions { - quit: bool, - net_down: i64, // positive=down, negative=up, 0=none - copy: bool, - delete: bool, + 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, // 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 + /// Navigation is capped to +-1 per frame to prevent jumpy scrolling when /// the key-repeat rate exceeds the render frame rate. - fn drain_events() -> Result { + fn drain_events(tui: &TuiState) -> Result { let mut actions = EventActions { - quit: false, - net_down: 0, - copy: false, - delete: false, + 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)) @@ -289,23 +358,46 @@ impl SqliteClipboardDb { if let Event::Key(key) = event::read() .map_err(|e| StashError::ListDecode(e.to_string().into()))? { - 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, - _ => {}, + 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, + _ => {}, + } } } } @@ -319,9 +411,11 @@ impl SqliteClipboardDb { 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) as usize) + .map(|r| r.height.saturating_sub(2 + search_bar_height) as usize) .unwrap_or(24) .max(1); tui.resize(term_height); @@ -336,12 +430,23 @@ impl SqliteClipboardDb { terminal .draw(|f| { let area = f.area(); - let block = Block::default() - .title( - "Clipboard Entries (j/k/↑/↓ to move, Enter to copy, Shift+D \ - to delete, q/ESC to quit)", + + // 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 ) - .borders(Borders::ALL); + }; + + let block = Block::default().title(title).borders(Borders::ALL); let border_width = 2; let highlight_symbol = ">"; @@ -482,75 +587,119 @@ impl SqliteClipboardDb { if event::poll(std::time::Duration::from_millis(250)) .map_err(|e| StashError::ListDecode(e.to_string().into()))? { - let actions = drain_events()?; + 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::() + }) + .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 actions.net_down > 0 { - tui.move_down(); - } else if actions.net_down < 0 { - tui.move_up(); - } + 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.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() - { - 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().to_owned()), - 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(); - }, + if actions.copy + && let Some(&(id, ..)) = tui.selected_entry() + { + 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().to_owned()), + 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(); + }, + } } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 23e622e..ca8ed37 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -734,22 +734,50 @@ impl ClipboardDb for SqliteClipboardDb { } impl SqliteClipboardDb { - /// Count visible clipboard entries (respects include_expired filter). + /// Count visible clipboard entries, with respect to `include_expired` and + /// optional search filter. pub fn count_entries( &self, include_expired: bool, + search: Option<&str>, ) -> Result { - let count: i64 = if include_expired { - self - .conn - .query_row("SELECT COUNT(*) FROM clipboard", [], |r| r.get(0)) - } else { - self.conn.query_row( - "SELECT COUNT(*) FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0)", - [], - |r| r.get(0), - ) + let search_pattern = search.map(|s| { + // Avoid backslash escaping issues + let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_"); + format!("%{escaped}%") + }); + + let count: i64 = match (include_expired, search_pattern.as_deref()) { + (true, None) => { + self + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |r| r.get(0)) + }, + (true, Some(pattern)) => { + self.conn.query_row( + "SELECT COUNT(*) FROM clipboard WHERE (LOWER(CAST(contents AS \ + TEXT)) LIKE LOWER(?1) ESCAPE '!')", + [pattern], + |r| r.get(0), + ) + }, + (false, None) => { + self.conn.query_row( + "SELECT COUNT(*) FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0)", + [], + |r| r.get(0), + ) + }, + (false, Some(pattern)) => { + self.conn.query_row( + "SELECT COUNT(*) FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0) AND (LOWER(CAST(contents AS TEXT)) LIKE LOWER(?1) \ + ESCAPE '!')", + [pattern], + |r| r.get(0), + ) + }, } .map_err(|e| StashError::ListDecode(e.to_string().into()))?; Ok(count.max(0) as usize) @@ -760,28 +788,58 @@ impl SqliteClipboardDb { /// Returns `(id, preview_string, mime_string)` tuples for at most /// `limit` rows starting at `offset` (0-indexed) in the canonical /// display order (most-recently-accessed first, then id DESC). + /// Optionally filters by search query in a case-insensitive nabber on text + /// content. pub fn fetch_entries_window( &self, include_expired: bool, offset: usize, limit: usize, preview_width: u32, + search: Option<&str>, ) -> Result, StashError> { - let query = if include_expired { - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) DESC, id DESC LIMIT ?1 OFFSET ?2" - } else { - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC LIMIT \ - ?1 OFFSET ?2" + let search_pattern = search.map(|s| { + let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_"); + format!("%{escaped}%") + }); + + let query = match (include_expired, search_pattern.as_deref()) { + (true, None) => { + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) DESC, id DESC LIMIT ?1 OFFSET ?2" + }, + (true, Some(_)) => { + "SELECT id, contents, mime FROM clipboard WHERE (LOWER(CAST(contents \ + AS TEXT)) LIKE LOWER(?3) ESCAPE '!') ORDER BY COALESCE(last_accessed, \ + 0) DESC, id DESC LIMIT ?1 OFFSET ?2" + }, + (false, None) => { + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC \ + LIMIT ?1 OFFSET ?2" + }, + (false, Some(_)) => { + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ + is_expired = 0) AND (LOWER(CAST(contents AS TEXT)) LIKE LOWER(?3) \ + ESCAPE '!') ORDER BY COALESCE(last_accessed, 0) DESC, id DESC LIMIT \ + ?1 OFFSET ?2" + }, }; + let mut stmt = self .conn .prepare(query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut rows = stmt - .query(rusqlite::params![limit as i64, offset as i64]) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let mut rows = if let Some(pattern) = search_pattern.as_deref() { + stmt + .query(rusqlite::params![limit as i64, offset as i64, pattern]) + .map_err(|e| StashError::ListDecode(e.to_string().into()))? + } else { + stmt + .query(rusqlite::params![limit as i64, offset as i64]) + .map_err(|e| StashError::ListDecode(e.to_string().into()))? + }; let mut window = Vec::with_capacity(limit); while let Some(row) = rows From 117e9d11efb60d6eb5cbfdcab2115e156cb6a1c7 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 16:23:50 +0300 Subject: [PATCH 088/121] docs: add cliphist to attributions section; add motivation section Signed-off-by: NotAShelf Change-Id: Ia3da5b4dc3aeeb98eafc77173ae592596a6a6964 --- README.md | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index faabc1c..775e223 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@
- Lightweight Wayland clipboard "manager" with fast persistent history and + 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.
@@ -28,7 +28,7 @@
@@ -375,6 +375,20 @@ be only copied to the clipboard. > > `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 @@ -549,8 +563,14 @@ My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the powered by [several crates](./Cargo.toml), but none of them were as detrimental in Stash's design process. -Additional thanks to my testers, who have tested earlier versions of Stash and -provided feedback. Thank you :) +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 From 469fccbef6fce5db5cce185a80525229e2658e5d Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 16:24:08 +0300 Subject: [PATCH 089/121] chore: release v0.3.6 Signed-off-by: NotAShelf Change-Id: I2adaf9944a4572dcd15157f32b52eec26a6a6964 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d3c1ace..98e77f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2124,7 +2124,7 @@ dependencies = [ [[package]] name = "stash-clipboard" -version = "0.3.5" +version = "0.3.6" dependencies = [ "base64", "clap", diff --git a/Cargo.toml b/Cargo.toml index 51e12ee..a828573 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stash-clipboard" description = "Wayland clipboard manager with fast persistent history and multi-media support" -version = "0.3.5" +version = "0.3.6" edition = "2024" authors = [ "NotAShelf " ] license = "MPL-2.0" From 02ba05dc955d0b4f535394764ae781eb5f39638c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 17:00:07 +0300 Subject: [PATCH 090/121] db: add new error variants for entries below minimum and above maximum sizes Signed-off-by: NotAShelf Change-Id: Icba2920cfef0ffb0ce6435ab6d7809166a6a6964 --- src/db/mod.rs | 122 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 104 insertions(+), 18 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index ca8ed37..e55f426 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -22,6 +22,10 @@ pub enum StashError { EmptyOrTooLarge, #[error("Input is all whitespace, skipping store.")] AllWhitespace, + #[error("Entry too small (min size: {0} bytes), skipping store.")] + TooSmall(usize), + #[error("Entry too large (max size: {0} bytes), skipping store.")] + TooLarge(usize), #[error("Failed to store entry: {0}")] Store(Box), @@ -65,6 +69,8 @@ pub trait ClipboardDb { max_dedupe_search: u64, max_items: u64, excluded_apps: Option<&[String]>, + min_size: Option, + max_size: Option, ) -> Result; fn deduplicate_by_hash( @@ -410,14 +416,30 @@ impl ClipboardDb for SqliteClipboardDb { max_dedupe_search: u64, max_items: u64, excluded_apps: Option<&[String]>, + min_size: Option, + max_size: Option, ) -> Result { let mut buf = Vec::new(); - if input.read_to_end(&mut buf).is_err() - || buf.is_empty() - || buf.len() > 5 * 1_000_000 - { + if input.read_to_end(&mut buf).is_err() || buf.is_empty() { return Err(StashError::EmptyOrTooLarge); } + + let size = buf.len(); + + if let Some(min) = min_size + && size < min + { + return Err(StashError::TooSmall(min)); + } + + if let Some(max) = max_size { + if size > max { + return Err(StashError::TooLarge(max)); + } + } else if size > 5 * 1_000_000 { + return Err(StashError::TooLarge(5 * 1_000_000)); + } + if buf.iter().all(u8::is_ascii_whitespace) { return Err(StashError::AllWhitespace); } @@ -1514,7 +1536,7 @@ mod tests { let cursor = std::io::Cursor::new(test_data.to_vec()); let id = db - .store_entry(cursor, 100, 1000, None) + .store_entry(cursor, 100, 1000, None, None, None) .expect("Failed to store entry"); let content_hash: Option = db @@ -1549,7 +1571,7 @@ mod tests { let test_data = b"Test content for copy"; let cursor = std::io::Cursor::new(test_data.to_vec()); let id_a = db - .store_entry(cursor, 100, 1000, None) + .store_entry(cursor, 100, 1000, None, None, None) .expect("Failed to store entry A"); let original_last_accessed: i64 = db @@ -1644,7 +1666,14 @@ mod tests { let db = test_db(); let data = b"file:///home/user/document.pdf\nfile:///home/user/image.png"; let id = db - .store_entry(std::io::Cursor::new(data.to_vec()), 100, 1000, None) + .store_entry( + std::io::Cursor::new(data.to_vec()), + 100, + 1000, + None, + None, + None, + ) .expect("Failed to store URI list"); let mime: Option = db @@ -1670,7 +1699,14 @@ mod tests { 0x90, 0x77, 0x53, 0xDE, // CRC ]; let id = db - .store_entry(std::io::Cursor::new(data.clone()), 100, 1000, None) + .store_entry( + std::io::Cursor::new(data.clone()), + 100, + 1000, + None, + None, + None, + ) .expect("Failed to store image"); let (contents, mime): (Vec, Option) = db @@ -1691,10 +1727,24 @@ mod tests { let data = b"duplicate content"; let id1 = db - .store_entry(std::io::Cursor::new(data.to_vec()), 100, 1000, None) + .store_entry( + std::io::Cursor::new(data.to_vec()), + 100, + 1000, + None, + None, + None, + ) .expect("Failed to store first"); let _id2 = db - .store_entry(std::io::Cursor::new(data.to_vec()), 100, 1000, None) + .store_entry( + std::io::Cursor::new(data.to_vec()), + 100, + 1000, + None, + None, + None, + ) .expect("Failed to store second"); // First entry should have been removed by deduplication @@ -1727,6 +1777,8 @@ mod tests { 100, 3, // max 3 items None, + None, + None, ) .expect("Failed to store"); } @@ -1741,8 +1793,14 @@ mod tests { #[test] fn test_reject_empty_input() { let db = test_db(); - let result = - db.store_entry(std::io::Cursor::new(Vec::new()), 100, 1000, None); + let result = db.store_entry( + std::io::Cursor::new(Vec::new()), + 100, + 1000, + None, + None, + None, + ); assert!(matches!(result, Err(StashError::EmptyOrTooLarge))); } @@ -1754,6 +1812,8 @@ mod tests { 100, 1000, None, + None, + None, ); assert!(matches!(result, Err(StashError::AllWhitespace))); } @@ -1763,15 +1823,23 @@ mod tests { let db = test_db(); // 5MB + 1 byte let data = vec![b'a'; 5 * 1_000_000 + 1]; - let result = db.store_entry(std::io::Cursor::new(data), 100, 1000, None); - assert!(matches!(result, Err(StashError::EmptyOrTooLarge))); + let result = + db.store_entry(std::io::Cursor::new(data), 100, 1000, None, None, None); + assert!(matches!(result, Err(StashError::TooLarge(5000000)))); } #[test] fn test_delete_entries_by_id() { let db = test_db(); let id = db - .store_entry(std::io::Cursor::new(b"to delete".to_vec()), 100, 1000, None) + .store_entry( + std::io::Cursor::new(b"to delete".to_vec()), + 100, + 1000, + None, + None, + None, + ) .expect("Failed to store"); let input = format!("{id}\tpreview text\n"); @@ -1795,6 +1863,8 @@ mod tests { 100, 1000, None, + None, + None, ) .expect("Failed to store"); db.store_entry( @@ -1802,6 +1872,8 @@ mod tests { 100, 1000, None, + None, + None, ) .expect("Failed to store"); @@ -1822,8 +1894,15 @@ mod tests { let db = test_db(); for i in 0..3 { let data = format!("entry {i}"); - db.store_entry(std::io::Cursor::new(data.into_bytes()), 100, 1000, None) - .expect("Failed to store"); + db.store_entry( + std::io::Cursor::new(data.into_bytes()), + 100, + 1000, + None, + None, + None, + ) + .expect("Failed to store"); } db.wipe_db().expect("Failed to wipe"); @@ -1885,7 +1964,14 @@ mod tests { let db = test_db(); let data = b"copy me"; let id = db - .store_entry(std::io::Cursor::new(data.to_vec()), 100, 1000, None) + .store_entry( + std::io::Cursor::new(data.to_vec()), + 100, + 1000, + None, + None, + None, + ) .expect("Failed to store"); let (returned_id, contents, mime) = From 3a14860ae18475362d9d2b67a8ddca50be79da80 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 26 Feb 2026 17:02:45 +0300 Subject: [PATCH 091/121] various: validate lower and upper boundaries before storing; add CLI flags Signed-off-by: NotAShelf Change-Id: I6484f9579a8799d952b15adcb47c8eec6a6a6964 --- src/commands/store.rs | 7 ++++++ src/commands/watch.rs | 7 ++++++ src/db/mod.rs | 52 +++++++++++++++++++++++-------------------- src/main.rs | 35 +++++++++++++++++++++-------- 4 files changed, 68 insertions(+), 33 deletions(-) diff --git a/src/commands/store.rs b/src/commands/store.rs index 9e5a6c6..3854b16 100644 --- a/src/commands/store.rs +++ b/src/commands/store.rs @@ -2,6 +2,7 @@ use std::io::Read; use crate::db::{ClipboardDb, SqliteClipboardDb}; +#[allow(clippy::too_many_arguments)] pub trait StoreCommand { fn store( &self, @@ -10,6 +11,8 @@ pub trait StoreCommand { max_items: u64, state: Option, excluded_apps: &[String], + min_size: Option, + max_size: usize, ) -> Result<(), crate::db::StashError>; } @@ -21,6 +24,8 @@ impl StoreCommand for SqliteClipboardDb { max_items: u64, state: Option, excluded_apps: &[String], + min_size: Option, + max_size: usize, ) -> Result<(), crate::db::StashError> { if let Some("sensitive" | "clear") = state.as_deref() { self.delete_last()?; @@ -31,6 +36,8 @@ impl StoreCommand for SqliteClipboardDb { max_dedupe_search, max_items, Some(excluded_apps), + min_size, + max_size, )?; log::info!("Entry stored"); } diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 54dc803..fbc7239 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -175,6 +175,7 @@ fn negotiate_mime_type( } } +#[allow(clippy::too_many_arguments)] pub trait WatchCommand { fn watch( &self, @@ -183,6 +184,8 @@ pub trait WatchCommand { excluded_apps: &[String], expire_after: Option, mime_type_preference: &str, + min_size: Option, + max_size: usize, ); } @@ -194,6 +197,8 @@ impl WatchCommand for SqliteClipboardDb { excluded_apps: &[String], expire_after: Option, mime_type_preference: &str, + min_size: Option, + max_size: usize, ) { smol::block_on(async { log::info!( @@ -349,6 +354,8 @@ impl WatchCommand for SqliteClipboardDb { max_dedupe_search, max_items, Some(excluded_apps), + min_size, + max_size, ) { Ok(id) => { log::info!("Stored new clipboard entry (id: {id})"); diff --git a/src/db/mod.rs b/src/db/mod.rs index e55f426..ae8d814 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -16,6 +16,8 @@ use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; use thiserror::Error; +pub const DEFAULT_MAX_ENTRY_SIZE: usize = 5_000_000; + #[derive(Error, Debug)] pub enum StashError { #[error("Input is empty or too large, skipping store.")] @@ -70,7 +72,7 @@ pub trait ClipboardDb { max_items: u64, excluded_apps: Option<&[String]>, min_size: Option, - max_size: Option, + max_size: usize, ) -> Result; fn deduplicate_by_hash( @@ -417,7 +419,7 @@ impl ClipboardDb for SqliteClipboardDb { max_items: u64, excluded_apps: Option<&[String]>, min_size: Option, - max_size: Option, + max_size: usize, ) -> Result { let mut buf = Vec::new(); if input.read_to_end(&mut buf).is_err() || buf.is_empty() { @@ -432,12 +434,8 @@ impl ClipboardDb for SqliteClipboardDb { return Err(StashError::TooSmall(min)); } - if let Some(max) = max_size { - if size > max { - return Err(StashError::TooLarge(max)); - } - } else if size > 5 * 1_000_000 { - return Err(StashError::TooLarge(5 * 1_000_000)); + if size > max_size { + return Err(StashError::TooLarge(max_size)); } if buf.iter().all(u8::is_ascii_whitespace) { @@ -1536,7 +1534,7 @@ mod tests { let cursor = std::io::Cursor::new(test_data.to_vec()); let id = db - .store_entry(cursor, 100, 1000, None, None, None) + .store_entry(cursor, 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE) .expect("Failed to store entry"); let content_hash: Option = db @@ -1571,7 +1569,7 @@ mod tests { let test_data = b"Test content for copy"; let cursor = std::io::Cursor::new(test_data.to_vec()); let id_a = db - .store_entry(cursor, 100, 1000, None, None, None) + .store_entry(cursor, 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE) .expect("Failed to store entry A"); let original_last_accessed: i64 = db @@ -1672,7 +1670,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ) .expect("Failed to store URI list"); @@ -1705,7 +1703,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ) .expect("Failed to store image"); @@ -1733,7 +1731,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ) .expect("Failed to store first"); let _id2 = db @@ -1743,7 +1741,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ) .expect("Failed to store second"); @@ -1778,7 +1776,7 @@ mod tests { 3, // max 3 items None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ) .expect("Failed to store"); } @@ -1799,7 +1797,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ); assert!(matches!(result, Err(StashError::EmptyOrTooLarge))); } @@ -1813,7 +1811,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ); assert!(matches!(result, Err(StashError::AllWhitespace))); } @@ -1823,8 +1821,14 @@ mod tests { let db = test_db(); // 5MB + 1 byte let data = vec![b'a'; 5 * 1_000_000 + 1]; - let result = - db.store_entry(std::io::Cursor::new(data), 100, 1000, None, None, None); + let result = db.store_entry( + std::io::Cursor::new(data), + 100, + 1000, + None, + None, + DEFAULT_MAX_ENTRY_SIZE, + ); assert!(matches!(result, Err(StashError::TooLarge(5000000)))); } @@ -1838,7 +1842,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ) .expect("Failed to store"); @@ -1864,7 +1868,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ) .expect("Failed to store"); db.store_entry( @@ -1873,7 +1877,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ) .expect("Failed to store"); @@ -1900,7 +1904,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ) .expect("Failed to store"); } @@ -1970,7 +1974,7 @@ mod tests { 1000, None, None, - None, + DEFAULT_MAX_ENTRY_SIZE, ) .expect("Failed to store"); diff --git a/src/main.rs b/src/main.rs index 56c2170..ef12ed1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,15 +15,18 @@ pub(crate) mod mime; mod multicall; #[cfg(feature = "use-toplevel")] mod wayland; -use crate::commands::{ - decode::DecodeCommand, - delete::DeleteCommand, - import::ImportCommand, - list::ListCommand, - query::QueryCommand, - store::StoreCommand, - watch::WatchCommand, - wipe::WipeCommand, +use crate::{ + commands::{ + decode::DecodeCommand, + delete::DeleteCommand, + import::ImportCommand, + list::ListCommand, + query::QueryCommand, + store::StoreCommand, + watch::WatchCommand, + wipe::WipeCommand, + }, + db::DEFAULT_MAX_ENTRY_SIZE, }; #[derive(Parser)] @@ -42,6 +45,16 @@ struct Cli { #[arg(long, default_value_t = 20)] max_dedupe_search: u64, + /// Minimum size (in bytes) for clipboard entries. Entries smaller than this + /// will not be stored. + #[arg(long, env = "STASH_MIN_SIZE")] + min_size: Option, + + /// 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, + /// Maximum width (in characters) for clipboard entry previews in list /// output. #[arg(long, default_value_t = 100)] @@ -226,6 +239,8 @@ fn main() -> color_eyre::eyre::Result<()> { &cli.excluded_apps, #[cfg(not(feature = "use-toplevel"))] &[], + cli.min_size, + cli.max_size, ), "failed to store entry", ); @@ -451,6 +466,8 @@ fn main() -> color_eyre::eyre::Result<()> { &[], expire_after, &mime_type, + cli.min_size, + cli.max_size, ); }, From ba2e29d5b76a33050cf79d9c03ee2affc06e261c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 27 Feb 2026 08:09:18 +0300 Subject: [PATCH 092/121] docs: fix HTML formatting; mention Cliphist's features Signed-off-by: NotAShelf Change-Id: I92716daef01c00bbe8e75426c3662fbb6a6a6964 --- README.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 775e223..ba3cf9e 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ @@ -52,7 +52,19 @@ with many features such as but not necessarily limited to: - Sensitive clipboard filtering via regex (see below) - Sensitive clipboard filtering by application (see below) -See [usage section](#usage) for more details. +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. + - Won’t 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 @@ -554,7 +566,8 @@ your database: 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. +the database compact, especially after deleting many entries. You can, of +course, wipe the database entirely if it has grown too large. ## Attributions From ebf46de99d8ce895410ce2e814ac064d589238d3 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 27 Feb 2026 10:26:45 +0300 Subject: [PATCH 093/121] docs: add installation instructions for crates.io Signed-off-by: NotAShelf Change-Id: Ib9a3fc7ee21324707d046d52a24b50596a6a6964 --- README.md | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ba3cf9e..42dd542 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ with many features such as but not necessarily limited to: - Image preview (shows dimensions and format) - Text previews with customizable width - De-duplication, whitespace prevention and entry limit control -- Automatic clipboard monitoring with `stash watch` +- 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) @@ -70,9 +71,9 @@ features, or conveniences provided by Stash. ### With Nix -Nix is the recommended way of downloading 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 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 { @@ -103,7 +104,8 @@ If you want to give Stash a try before you switch to it, you may also run it one time with `nix run`. ```sh -nix run github:NotAShelf/stash -- watch # start the watch daemon +# Run directly from the git repository; will be garbage collected +$ nix run github:NotAShelf/stash -- watch # start the watch daemon ``` ### Without Nix @@ -122,16 +124,23 @@ releases are made when a version gets tagged, and are available under - Build and install from source with Cargo: ```bash - cargo install --git https://github.com/notashelf/stash + 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 -> [!NOTE] +> [!IMPORTANT] > It is not a priority to provide 1:1 backwards compatibility with Cliphist. -> While the interface is _almost_ identical, Stash chooses to build upon +> While the interface is 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. +> [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, @@ -287,7 +296,7 @@ entry has expired from history. > 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 +#### 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 From 181edcefb1fb38bbd1ca306e91ba493bcf4014d9 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 27 Feb 2026 10:34:32 +0300 Subject: [PATCH 094/121] db: add MIME sniffing for binary clipboard previews Signed-off-by: NotAShelf Change-Id: I70416269dd40496758b6e5431e77a9456a6a6964 --- Cargo.lock | 288 ++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + src/db/mod.rs | 38 ++++--- 3 files changed, 307 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 98e77f7..f18e409 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -686,6 +686,17 @@ dependencies = [ "objc2", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "document-features" version = "0.2.12" @@ -900,6 +911,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.31" @@ -1017,12 +1037,114 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "imagesize" version = "0.14.0" @@ -1200,6 +1322,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "litrs" version = "1.0.0" @@ -1273,6 +1401,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime-sniffer" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b8b2a64cd735f1d5f17ff6701ced3cc3c54851f9448caf454cd9c923d812408" +dependencies = [ + "mime", + "url", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1523,6 +1667,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pest" version = "2.8.5" @@ -1681,6 +1831,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2122,6 +2281,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "stash-clipboard" version = "0.3.6" @@ -2139,6 +2304,7 @@ dependencies = [ "inquire", "libc", "log", + "mime-sniffer", "notify-rust", "ratatui", "regex", @@ -2210,6 +2376,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tauri-winrt-notification" version = "0.7.2" @@ -2368,6 +2545,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -2514,6 +2701,24 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2953,6 +3158,35 @@ dependencies = [ "wayland-protocols-wlr", ] +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "zbus" version = "5.13.2" @@ -3014,6 +3248,60 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.16" diff --git a/Cargo.toml b/Cargo.toml index a828573..0a6abd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ imagesize = "0.14.0" inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] } libc = "0.2.182" log = "0.4.29" +mime-sniffer = "0.1.3" notify-rust = { version = "4.12.0", optional = true } ratatui = "0.30.0" regex = "1.12.3" diff --git a/src/db/mod.rs b/src/db/mod.rs index ae8d814..5bbfffb 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -10,7 +10,8 @@ use std::{ }; use base64::prelude::*; -use log::{debug, error, warn}; +use log::{debug, error, info, warn}; +use mime_sniffer::MimeTypeSniffer; use regex::Regex; use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; @@ -1065,26 +1066,14 @@ pub fn preview_entry(data: &[u8], mime: Option<&str>, width: u32) -> String { } } - // For non-text data, use lossy conversion - let s = String::from_utf8_lossy(data); - truncate(s.trim(), width as usize, "…") -} - -pub fn truncate(s: &str, max: usize, ellip: &str) -> String { - let char_count = s.chars().count(); - if char_count > max { - let mut result = String::with_capacity(max * 4 + ellip.len()); // UTF-8 worst case - let mut char_iter = s.chars(); - for _ in 0..max { - if let Some(c) = char_iter.next() { - result.push(c); - } - } - result.push_str(ellip); - result - } else { - s.to_string() + // For non-text/non-image data, try to sniff the MIME type + if let Some(sniffed) = data.sniff_mime_type() { + return format!("[[ binary data {} {} ]]", size_str(data.len()), sniffed); } + + // Shouldn't reach here if MIME is properly set, but just in case + info!("Mimetype sniffing failed, omitting"); + format!("[[ binary data {} ]]", size_str(data.len())) } pub fn size_str(size: usize) -> String { @@ -1963,6 +1952,15 @@ mod tests { assert_eq!(size_str(1024 * 1024), "1 MiB"); } + #[test] + fn test_preview_entry_binary_sniffed() { + // PDF magic bytes + let data = b"%PDF-1.4 fake pdf content here for testing"; + let preview = preview_entry(data, None, 100); + assert!(preview.contains("binary data")); + assert!(preview.contains("application/pdf")); + } + #[test] fn test_copy_entry_returns_data() { let db = test_db(); From 5e0599dc715f5d7ec5cfed8664a722aac9fb73d4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:28:36 +0000 Subject: [PATCH 095/121] 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] --- Cargo.lock | 10 +++++----- Cargo.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f18e409..2563e3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -563,12 +563,12 @@ dependencies = [ [[package]] name = "ctrlc" -version = "3.5.1" +version = "3.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ "dispatch2", - "nix 0.30.1", + "nix 0.31.2", "windows-sys", ] @@ -1459,9 +1459,9 @@ dependencies = [ [[package]] name = "nix" -version = "0.30.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ "bitflags 2.10.0", "cfg-if", diff --git a/Cargo.toml b/Cargo.toml index 0a6abd5..f1033ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ clap = { version = "4.5.60", features = [ "derive", "env" ] } clap-verbosity-flag = "3.0.4" color-eyre = "0.6.5" crossterm = "0.29.0" -ctrlc = "3.5.1" +ctrlc = "3.5.2" dirs = "6.0.0" env_logger = "0.11.8" humantime = "2.3.0" From ffdc13e8f574c8ef25dcf1766faa396bcc4fd8dc Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 27 Feb 2026 14:53:25 +0300 Subject: [PATCH 096/121] commands/list: allow printing in reversed order with `--reverse` Signed-off-by: NotAShelf Change-Id: I305cfdc68d877dc5d5083a76dccc62db6a6a6964 --- src/commands/list.rs | 21 ++++++++++-- src/db/mod.rs | 76 ++++++++++++++++++++++++++++++-------------- src/main.rs | 18 ++++++++--- 3 files changed, 83 insertions(+), 32 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index 03309aa..3f1fd62 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -11,6 +11,7 @@ pub trait ListCommand { out: impl Write, preview_width: u32, include_expired: bool, + reverse: bool, ) -> Result<(), StashError>; } @@ -20,9 +21,10 @@ impl ListCommand for SqliteClipboardDb { out: impl Write, preview_width: u32, include_expired: bool, + reverse: bool, ) -> Result<(), StashError> { self - .list_entries(out, preview_width, include_expired) + .list_entries(out, preview_width, include_expired, reverse) .map(|_| ()) } } @@ -52,6 +54,9 @@ struct TuiState { /// Whether we're currently in search input mode. search_mode: bool, + + /// Whether to show entries in reverse order (oldest first). + reverse: bool, } impl TuiState { @@ -61,6 +66,7 @@ impl TuiState { include_expired: bool, window_size: usize, preview_width: u32, + reverse: bool, ) -> Result { let total = db.count_entries(include_expired, None)?; let window = if total > 0 { @@ -70,6 +76,7 @@ impl TuiState { window_size, preview_width, None, + reverse, )? } else { Vec::new() @@ -83,6 +90,7 @@ impl TuiState { dirty: false, search_query: String::new(), search_mode: false, + reverse, }) } @@ -228,6 +236,7 @@ impl TuiState { self.window_size, preview_width, search, + self.reverse, )? } else { Vec::new() @@ -266,6 +275,7 @@ impl SqliteClipboardDb { &self, preview_width: u32, include_expired: bool, + reverse: bool, ) -> Result<(), StashError> { use std::io::stdout; @@ -316,8 +326,13 @@ impl SqliteClipboardDb { .unwrap_or(24); let initial_height = initial_height.max(1); - let mut tui = - TuiState::new(self, include_expired, initial_height, preview_width)?; + 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(); diff --git a/src/db/mod.rs b/src/db/mod.rs index 5bbfffb..2c3921f 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -89,6 +89,7 @@ pub trait ClipboardDb { out: impl Write, preview_width: u32, include_expired: bool, + reverse: bool, ) -> Result; fn decode_entry( &self, @@ -362,17 +363,27 @@ impl SqliteClipboardDb { } impl SqliteClipboardDb { - pub fn list_json(&self, include_expired: bool) -> Result { + pub fn list_json( + &self, + include_expired: bool, + reverse: bool, + ) -> Result { + let order = if reverse { "ASC" } else { "DESC" }; let query = if include_expired { - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) DESC, id DESC" + format!( + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) {order}, id {order}" + ) } else { - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC" + format!( + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \ + OR is_expired = 0) ORDER BY COALESCE(last_accessed, 0) {order}, id \ + {order}" + ) }; let mut stmt = self .conn - .prepare(query) + .prepare(&query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -594,17 +605,24 @@ impl ClipboardDb for SqliteClipboardDb { mut out: impl Write, preview_width: u32, include_expired: bool, + reverse: bool, ) -> Result { + let order = if reverse { "ASC" } else { "DESC" }; let query = if include_expired { - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) DESC, id DESC" + format!( + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) {order}, id {order}" + ) } else { - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC" + format!( + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \ + OR is_expired = 0) ORDER BY COALESCE(last_accessed, 0) {order}, id \ + {order}" + ) }; let mut stmt = self .conn - .prepare(query) + .prepare(&query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -818,38 +836,48 @@ impl SqliteClipboardDb { limit: usize, preview_width: u32, search: Option<&str>, + reverse: bool, ) -> Result, StashError> { let search_pattern = search.map(|s| { let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_"); format!("%{escaped}%") }); + let order = if reverse { "ASC" } else { "DESC" }; let query = match (include_expired, search_pattern.as_deref()) { (true, None) => { - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) DESC, id DESC LIMIT ?1 OFFSET ?2" + format!( + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) {order}, id {order} LIMIT ?1 OFFSET ?2" + ) }, (true, Some(_)) => { - "SELECT id, contents, mime FROM clipboard WHERE (LOWER(CAST(contents \ - AS TEXT)) LIKE LOWER(?3) ESCAPE '!') ORDER BY COALESCE(last_accessed, \ - 0) DESC, id DESC LIMIT ?1 OFFSET ?2" + format!( + "SELECT id, contents, mime FROM clipboard WHERE \ + (LOWER(CAST(contents AS TEXT)) LIKE LOWER(?3) ESCAPE '!') ORDER BY \ + COALESCE(last_accessed, 0) {order}, id {order} LIMIT ?1 OFFSET ?2" + ) }, (false, None) => { - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC \ - LIMIT ?1 OFFSET ?2" + format!( + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \ + OR is_expired = 0) ORDER BY COALESCE(last_accessed, 0) {order}, id \ + {order} LIMIT ?1 OFFSET ?2" + ) }, (false, Some(_)) => { - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0) AND (LOWER(CAST(contents AS TEXT)) LIKE LOWER(?3) \ - ESCAPE '!') ORDER BY COALESCE(last_accessed, 0) DESC, id DESC LIMIT \ - ?1 OFFSET ?2" + format!( + "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \ + OR is_expired = 0) AND (LOWER(CAST(contents AS TEXT)) LIKE \ + LOWER(?3) ESCAPE '!') ORDER BY COALESCE(last_accessed, 0) {order}, \ + id {order} LIMIT ?1 OFFSET ?2" + ) }, }; let mut stmt = self .conn - .prepare(query) + .prepare(&query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = if let Some(pattern) = search_pattern.as_deref() { diff --git a/src/main.rs b/src/main.rs index ef12ed1..fd74b1f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -91,6 +91,10 @@ enum Command { /// Show only expired entries (diagnostic, does not remove them) #[arg(long)] expired: bool, + + /// Reverse the order of entries (oldest first instead of newest first) + #[arg(long)] + reverse: bool, }, /// Decode and output clipboard entry by id @@ -245,16 +249,20 @@ fn main() -> color_eyre::eyre::Result<()> { "failed to store entry", ); }, - Some(Command::List { format, expired }) => { + Some(Command::List { + format, + expired, + reverse, + }) => { match format.as_deref() { Some("tsv") => { report_error( - db.list(io::stdout(), cli.preview_width, expired), + db.list(io::stdout(), cli.preview_width, expired, reverse), "failed to list entries", ); }, Some("json") => { - match db.list_json(expired) { + match db.list_json(expired, reverse) { Ok(json) => { println!("{json}"); }, @@ -269,12 +277,12 @@ fn main() -> color_eyre::eyre::Result<()> { None => { if std::io::stdout().is_terminal() { report_error( - db.list_tui(cli.preview_width, expired), + 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), + db.list(io::stdout(), cli.preview_width, expired, reverse), "failed to list entries", ); } From 7184c8b68281e0a19828a7fbd3ac8c0191a78960 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 5 Mar 2026 10:03:26 +0300 Subject: [PATCH 097/121] db: consolidate duplicated SQL queries Signed-off-by: NotAShelf Change-Id: I8b6889d1e420865d0a8d3b8da916d8086a6a6964 --- src/db/mod.rs | 210 ++++++++++++++++++++++++++------------------------ 1 file changed, 109 insertions(+), 101 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index 2c3921f..61d3351 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -19,6 +19,97 @@ use thiserror::Error; pub const DEFAULT_MAX_ENTRY_SIZE: usize = 5_000_000; +/// Query builder helper for list operations. +/// Centralizes WHERE clause and ORDER BY generation to avoid duplication. +struct ListQueryBuilder { + include_expired: bool, + reverse: bool, + search_pattern: Option, + limit: Option, + offset: Option, +} + +impl ListQueryBuilder { + fn new(include_expired: bool, reverse: bool) -> Self { + Self { + include_expired, + reverse, + search_pattern: None, + limit: None, + offset: None, + } + } + + fn with_search(mut self, pattern: Option<&str>) -> Self { + self.search_pattern = pattern.map(|s| { + let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_"); + format!("%{escaped}%") + }); + self + } + + fn with_pagination(mut self, offset: usize, limit: usize) -> Self { + self.offset = Some(offset); + self.limit = Some(limit); + self + } + + fn where_clause(&self) -> String { + let mut conditions = Vec::new(); + + if !self.include_expired { + conditions.push("(is_expired IS NULL OR is_expired = 0)"); + } + + if self.search_pattern.is_some() { + conditions + .push("(LOWER(CAST(contents AS TEXT)) LIKE LOWER(?1) ESCAPE '!')"); + } + + if conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", conditions.join(" AND ")) + } + } + + fn order_clause(&self) -> String { + let order = if self.reverse { "ASC" } else { "DESC" }; + format!("ORDER BY COALESCE(last_accessed, 0) {order}, id {order}") + } + + fn pagination_clause(&self) -> String { + match (self.limit, self.offset) { + (Some(limit), Some(offset)) => format!("LIMIT {limit} OFFSET {offset}"), + _ => String::new(), + } + } + + fn select_star_query(&self) -> String { + let where_clause = self.where_clause(); + let order_clause = self.order_clause(); + let pagination = self.pagination_clause(); + + format!( + "SELECT id, contents, mime FROM clipboard {where_clause} {order_clause} \ + {pagination}" + ) + .trim() + .to_string() + } + + fn count_query(&self) -> String { + let where_clause = self.where_clause(); + format!("SELECT COUNT(*) FROM clipboard {where_clause}") + .trim() + .to_string() + } + + fn search_param(&self) -> Option<&str> { + self.search_pattern.as_deref() + } +} + #[derive(Error, Debug)] pub enum StashError { #[error("Input is empty or too large, skipping store.")] @@ -368,19 +459,8 @@ impl SqliteClipboardDb { include_expired: bool, reverse: bool, ) -> Result { - let order = if reverse { "ASC" } else { "DESC" }; - let query = if include_expired { - format!( - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) {order}, id {order}" - ) - } else { - format!( - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \ - OR is_expired = 0) ORDER BY COALESCE(last_accessed, 0) {order}, id \ - {order}" - ) - }; + let builder = ListQueryBuilder::new(include_expired, reverse); + let query = builder.select_star_query(); let mut stmt = self .conn .prepare(&query) @@ -607,19 +687,8 @@ impl ClipboardDb for SqliteClipboardDb { include_expired: bool, reverse: bool, ) -> Result { - let order = if reverse { "ASC" } else { "DESC" }; - let query = if include_expired { - format!( - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) {order}, id {order}" - ) - } else { - format!( - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \ - OR is_expired = 0) ORDER BY COALESCE(last_accessed, 0) {order}, id \ - {order}" - ) - }; + let builder = ListQueryBuilder::new(include_expired, reverse); + let query = builder.select_star_query(); let mut stmt = self .conn .prepare(&query) @@ -780,43 +849,14 @@ impl SqliteClipboardDb { include_expired: bool, search: Option<&str>, ) -> Result { - let search_pattern = search.map(|s| { - // Avoid backslash escaping issues - let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_"); - format!("%{escaped}%") - }); + let builder = + ListQueryBuilder::new(include_expired, false).with_search(search); + let query = builder.count_query(); - let count: i64 = match (include_expired, search_pattern.as_deref()) { - (true, None) => { - self - .conn - .query_row("SELECT COUNT(*) FROM clipboard", [], |r| r.get(0)) - }, - (true, Some(pattern)) => { - self.conn.query_row( - "SELECT COUNT(*) FROM clipboard WHERE (LOWER(CAST(contents AS \ - TEXT)) LIKE LOWER(?1) ESCAPE '!')", - [pattern], - |r| r.get(0), - ) - }, - (false, None) => { - self.conn.query_row( - "SELECT COUNT(*) FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0)", - [], - |r| r.get(0), - ) - }, - (false, Some(pattern)) => { - self.conn.query_row( - "SELECT COUNT(*) FROM clipboard WHERE (is_expired IS NULL OR \ - is_expired = 0) AND (LOWER(CAST(contents AS TEXT)) LIKE LOWER(?1) \ - ESCAPE '!')", - [pattern], - |r| r.get(0), - ) - }, + let count: i64 = if let Some(pattern) = builder.search_param() { + self.conn.query_row(&query, [pattern], |r| r.get(0)) + } else { + self.conn.query_row(&query, [], |r| r.get(0)) } .map_err(|e| StashError::ListDecode(e.to_string().into()))?; Ok(count.max(0) as usize) @@ -838,55 +878,23 @@ impl SqliteClipboardDb { search: Option<&str>, reverse: bool, ) -> Result, StashError> { - let search_pattern = search.map(|s| { - let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_"); - format!("%{escaped}%") - }); - - let order = if reverse { "ASC" } else { "DESC" }; - let query = match (include_expired, search_pattern.as_deref()) { - (true, None) => { - format!( - "SELECT id, contents, mime FROM clipboard ORDER BY \ - COALESCE(last_accessed, 0) {order}, id {order} LIMIT ?1 OFFSET ?2" - ) - }, - (true, Some(_)) => { - format!( - "SELECT id, contents, mime FROM clipboard WHERE \ - (LOWER(CAST(contents AS TEXT)) LIKE LOWER(?3) ESCAPE '!') ORDER BY \ - COALESCE(last_accessed, 0) {order}, id {order} LIMIT ?1 OFFSET ?2" - ) - }, - (false, None) => { - format!( - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \ - OR is_expired = 0) ORDER BY COALESCE(last_accessed, 0) {order}, id \ - {order} LIMIT ?1 OFFSET ?2" - ) - }, - (false, Some(_)) => { - format!( - "SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \ - OR is_expired = 0) AND (LOWER(CAST(contents AS TEXT)) LIKE \ - LOWER(?3) ESCAPE '!') ORDER BY COALESCE(last_accessed, 0) {order}, \ - id {order} LIMIT ?1 OFFSET ?2" - ) - }, - }; + let builder = ListQueryBuilder::new(include_expired, reverse) + .with_search(search) + .with_pagination(offset, limit); + let query = builder.select_star_query(); let mut stmt = self .conn .prepare(&query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut rows = if let Some(pattern) = search_pattern.as_deref() { + let mut rows = if let Some(pattern) = builder.search_param() { stmt - .query(rusqlite::params![limit as i64, offset as i64, pattern]) + .query(rusqlite::params![pattern]) .map_err(|e| StashError::ListDecode(e.to_string().into()))? } else { stmt - .query(rusqlite::params![limit as i64, offset as i64]) + .query([]) .map_err(|e| StashError::ListDecode(e.to_string().into()))? }; From 95bf1766cef9424ea753238cc2b824e95b53a4b5 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 5 Mar 2026 11:13:53 +0300 Subject: [PATCH 098/121] stash: async db operations; make hashes deterministic Signed-off-by: NotAShelf Change-Id: Iccc9980fa13a752e0e6c9fb630c28ba96a6a6964 --- Cargo.lock | 541 +++++++++++++++++++++++++++++++----------- Cargo.toml | 2 + src/commands/watch.rs | 391 ++++++++++++++++-------------- src/db/mod.rs | 97 +++++--- src/db/nonblocking.rs | 141 +++++++++++ src/main.rs | 5 +- 6 files changed, 815 insertions(+), 362 deletions(-) create mode 100644 src/db/nonblocking.rs diff --git a/Cargo.lock b/Cargo.lock index f18e409..30d0945 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,9 +84,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "async-broadcast" @@ -114,9 +114,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", @@ -306,9 +306,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block-buffer" @@ -343,15 +343,15 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "castaway" @@ -364,9 +364,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.53" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "shlex", @@ -520,7 +520,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "crossterm_winapi", "derive_more", "document-features", @@ -563,12 +563,12 @@ dependencies = [ [[package]] name = "ctrlc" -version = "3.5.1" +version = "3.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ "dispatch2", - "nix 0.30.1", + "nix 0.31.2", "windows-sys", ] @@ -676,11 +676,11 @@ dependencies = [ [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", "libc", "objc2", @@ -753,9 +753,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "0.1.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" dependencies = [ "log", "regex", @@ -763,9 +763,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" dependencies = [ "anstream", "anstyle", @@ -871,9 +871,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "finl_unicode" @@ -921,16 +921,52 @@ dependencies = [ ] [[package]] -name = "futures-core" -version = "0.3.31" +name = "futures" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -945,6 +981,46 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -974,10 +1050,23 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "gimli" version = "0.32.3" @@ -1118,6 +1207,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1165,6 +1260,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -1182,7 +1279,7 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "crossterm", "dyn-clone", "unicode-segmentation", @@ -1225,9 +1322,9 @@ checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" -version = "0.2.18" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" dependencies = [ "jiff-static", "log", @@ -1238,9 +1335,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.18" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", @@ -1249,9 +1346,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", @@ -1280,6 +1377,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.182" @@ -1288,11 +1391,10 @@ checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ - "bitflags 2.10.0", "libc", ] @@ -1313,7 +1415,7 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -1382,9 +1484,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmem" @@ -1450,7 +1552,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -1459,11 +1561,11 @@ dependencies = [ [[package]] name = "nix" -version = "0.30.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -1539,9 +1641,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -1552,7 +1654,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "dispatch2", "objc2", ] @@ -1569,7 +1671,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", "libc", "objc2", @@ -1634,9 +1736,9 @@ dependencies = [ [[package]] name = "owo-colors" -version = "4.2.3" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "parking" @@ -1675,9 +1777,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.5" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" dependencies = [ "memchr", "ucd-trie", @@ -1685,9 +1787,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.5" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" dependencies = [ "pest", "pest_generator", @@ -1695,9 +1797,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.5" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" dependencies = [ "pest", "pest_meta", @@ -1708,9 +1810,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.5" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", "sha2", @@ -1781,15 +1883,15 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -1818,15 +1920,15 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" dependencies = [ "portable-atomic", ] @@ -1847,10 +1949,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] -name = "proc-macro-crate" -version = "3.4.0" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ "toml_edit", ] @@ -1875,18 +1987,18 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" dependencies = [ "memchr", ] [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -1897,6 +2009,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -1932,7 +2050,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "compact_str", "hashbrown 0.16.1", "indoc", @@ -1984,7 +2102,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "hashbrown 0.16.1", "indoc", "instability", @@ -2003,7 +2121,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -2031,9 +2149,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -2042,9 +2160,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "rsqlite-vfs" @@ -2062,7 +2180,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -2092,7 +2210,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -2107,9 +2225,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "scopeguard" @@ -2236,15 +2354,15 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -2292,6 +2410,7 @@ name = "stash-clipboard" version = "0.3.6" dependencies = [ "base64", + "blocking", "clap", "clap-verbosity-flag", "color-eyre", @@ -2299,6 +2418,7 @@ dependencies = [ "ctrlc", "dirs", "env_logger", + "futures", "humantime", "imagesize", "inquire", @@ -2406,7 +2526,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys", @@ -2441,7 +2561,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.10.0", + "bitflags 2.11.0", "fancy-regex", "filedescriptor", "finl_unicode", @@ -2557,18 +2677,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.0.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap", "toml_datetime", @@ -2578,9 +2698,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ "winnow", ] @@ -2663,13 +2783,13 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "uds_windows" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" dependencies = [ "memoffset", "tempfile", - "winapi", + "windows-sys", ] [[package]] @@ -2701,6 +2821,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "url" version = "2.5.8" @@ -2727,12 +2853,12 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.19.0" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ "atomic", - "getrandom 0.3.4", + "getrandom 0.4.2", "js-sys", "serde_core", "wasm-bindgen", @@ -2773,18 +2899,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -2795,9 +2930,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2805,9 +2940,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", @@ -2818,18 +2953,52 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] [[package]] -name = "wayland-backend" -version = "0.3.12" +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wayland-backend" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaa6143502b9a87f759cb6a649ca801a226f77740eb54f3951cba2227790afeb" dependencies = [ "cc", "downcast-rs", @@ -2840,11 +3009,11 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.12" +version = "0.31.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" +checksum = "ab51d9f7c071abeee76007e2b742499e535148035bb835f97aaed1338cf516c3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "log", "rustix", "wayland-backend", @@ -2853,11 +3022,11 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.10" +version = "0.32.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" +checksum = "b23b5df31ceff1328f06ac607591d5ba360cf58f90c8fad4ac8d3a55a3c4aec7" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -2865,11 +3034,11 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" +checksum = "78248e4cc0eff8163370ba5c158630dcae1f3497a586b826eca2ef5f348d6235" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -2878,20 +3047,20 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.8" +version = "0.31.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +checksum = "c86287151a309799b821ca709b7345a048a2956af05957c89cb824ab919fa4e3" dependencies = [ "proc-macro2", - "quick-xml 0.38.4", + "quick-xml 0.39.2", "quote", ] [[package]] name = "wayland-sys" -version = "0.31.8" +version = "0.31.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" +checksum = "81d2bd69b1dadd601d0e98ef2fc9339a1b1e00cec5ee7545a77b5a0f52a90394" dependencies = [ "pkg-config", ] @@ -3136,9 +3305,91 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "wl-clipboard-rs" @@ -3189,9 +3440,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.13.2" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" dependencies = [ "async-broadcast", "async-executor", @@ -3224,9 +3475,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.13.2" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -3304,15 +3555,15 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.16" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zvariant" -version = "5.9.2" +version = "5.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" dependencies = [ "endi", "enumflags2", @@ -3324,9 +3575,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.9.2" +version = "5.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" dependencies = [ "proc-macro-crate", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 0a6abd5..709673f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" [dependencies] base64 = "0.22.1" +blocking = "1.6.2" clap = { version = "4.5.60", features = [ "derive", "env" ] } clap-verbosity-flag = "3.0.4" color-eyre = "0.6.5" @@ -43,6 +44,7 @@ wayland-protocols-wlr = { version = "0.3.10", default-features = false, optional wl-clipboard-rs = "0.9.3" [dev-dependencies] +futures = "0.3.32" tempfile = "3.26.0" [features] diff --git a/src/commands/watch.rs b/src/commands/watch.rs index fbc7239..9ac82cc 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -1,9 +1,32 @@ -use std::{ - collections::{BinaryHeap, hash_map::DefaultHasher}, - hash::{Hash, Hasher}, - io::Read, - time::Duration, -}; +use std::{collections::BinaryHeap, io::Read, time::Duration}; + +/// FNV-1a hasher for deterministic hashing across process runs. +/// Unlike DefaultHasher (SipHash), this produces stable hashes. +struct Fnv1aHasher { + state: u64, +} + +impl Fnv1aHasher { + const FNV_OFFSET: u64 = 0xCBF29CE484222325; + const FNV_PRIME: u64 = 0x100000001B3; + + fn new() -> Self { + Self { + state: Self::FNV_OFFSET, + } + } + + fn write(&mut self, bytes: &[u8]) { + for byte in bytes { + self.state ^= *byte as u64; + self.state = self.state.wrapping_mul(Self::FNV_PRIME); + } + } + + fn finish(&self) -> u64 { + self.state + } +} use smol::Timer; use wl_clipboard_rs::{ @@ -17,7 +40,7 @@ use wl_clipboard_rs::{ }, }; -use crate::db::{ClipboardDb, SqliteClipboardDb}; +use crate::db::{SqliteClipboardDb, nonblocking::AsyncClipboardDb}; /// Wrapper to provide [`Ord`] implementation for `f64` by negating values. /// This allows [`BinaryHeap`], which is a max-heap, to function as a min-heap. @@ -97,6 +120,16 @@ impl ExpirationQueue { } 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. @@ -177,7 +210,7 @@ fn negotiate_mime_type( #[allow(clippy::too_many_arguments)] pub trait WatchCommand { - fn watch( + async fn watch( &self, max_dedupe_search: u64, max_items: u64, @@ -190,7 +223,7 @@ pub trait WatchCommand { } impl WatchCommand for SqliteClipboardDb { - fn watch( + async fn watch( &self, max_dedupe_search: u64, max_items: u64, @@ -200,207 +233,203 @@ impl WatchCommand for SqliteClipboardDb { min_size: Option, max_size: usize, ) { - smol::block_on(async { - log::info!( - "Starting clipboard watch daemon with MIME type preference: \ - {mime_type_preference}" - ); + let async_db = AsyncClipboardDb::new(self.db_path.clone()); + log::info!( + "Starting clipboard watch daemon with MIME type preference: \ + {mime_type_preference}" + ); - // Build expiration queue from existing entries - let mut exp_queue = ExpirationQueue::new(); - if let Ok(Some((expires_at, id))) = self.get_next_expiration() { - exp_queue.push(expires_at, id); - // Load remaining expirations (exclude already-marked expired entries) - let mut stmt = self - .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", - ) - .ok(); - if let Some(ref mut stmt) = stmt { - let mut rows = stmt.query([]).ok(); - if let Some(ref mut rows) = rows { - while let Ok(Some(row)) = rows.next() { - if let (Ok(exp), Ok(row_id)) = - (row.get::<_, f64>(0), row.get::<_, i64>(1)) - { - // Skip first entry which is already added - if exp_queue - .heap - .iter() - .any(|(_, existing_id)| *existing_id == row_id) - { - continue; - } - exp_queue.push(exp, row_id); - } - } - } + // 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); } - } - - // We use hashes for comparison instead of storing full contents - let mut last_hash: Option = None; - let mut buf = Vec::with_capacity(4096); - - // Helper to hash clipboard contents - let hash_contents = |data: &[u8]| -> u64 { - let mut hasher = DefaultHasher::new(); - data.hash(&mut hasher); - 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)); + 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 = 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)); } + } - loop { - // Process any pending expirations - 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 = self - .conn - .query_row( - "SELECT content_hash FROM clipboard WHERE id = ?1", - [id], - |row| row.get(0), - ) - .ok(); + let poll_interval = Duration::from_millis(500); - if let Some(stored_hash) = expired_hash { - // Mark as expired - self - .conn - .execute( - "UPDATE clipboard SET is_expired = 1 WHERE id = ?1", - [id], - ) - .ok(); + 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 = + 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) + // 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 mut current_buf = Vec::new(); - if reader.read_to_end(&mut current_buf).is_ok() - && !current_buf.is_empty() - { - let current_hash = hash_contents(¤t_buf); - // Compare as i64 (database stores as i64) - if current_hash as i64 == stored_hash { - // Clear the clipboard since expired content is still - // there - let mut opts = Options::new(); - opts.clipboard( - wl_clipboard_rs::copy::ClipboardType::Regular, + let current_hash = hash_contents(¤t_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}" ); - 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}" - ); - } } } } } } - } else { - // Sleep *precisely* until next expiration - let sleep_duration = next_exp - now; - Timer::after(Duration::from_secs_f64(sleep_duration)).await; - continue; // skip normal poll, process expirations first } } + } - // Normal clipboard polling - match negotiate_mime_type(mime_type_preference) { - 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; - } + // Normal clipboard polling (always run, even when expirations are + // pending) + match negotiate_mime_type(mime_type_preference) { + 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() { - let current_hash = hash_contents(&buf); - if last_hash != Some(current_hash) { - match self.store_entry( - &buf[..], + // 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(); + match async_db + .store_entry( + buf_clone, max_dedupe_search, max_items, - Some(excluded_apps), + Some(excluded_apps.to_vec()), min_size, max_size, - ) { - Ok(id) => { - log::info!("Stored new clipboard entry (id: {id})"); - last_hash = Some(current_hash); + ) + .await + { + Ok(id) => { + log::info!("Stored new clipboard entry (id: {id})"); + last_hash = Some(current_hash); - // Set expiration if configured - if let Some(duration) = expire_after { - let expires_at = - SqliteClipboardDb::now() + duration.as_secs_f64(); - self.set_expiration(id, expires_at).ok(); + // 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(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}"); - } - }, - } - - // Normal poll interval (only if no expirations pending) - if exp_queue.peek_next().is_none() { - Timer::after(Duration::from_millis(500)).await; - } + } + }, + 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; + } } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 61d3351..1f58cdf 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,14 +1,44 @@ use std::{ - collections::hash_map::DefaultHasher, env, fmt, fs, - hash::{Hash, Hasher}, io::{BufRead, BufReader, Read, Write}, + path::PathBuf, str, sync::OnceLock, }; +pub mod nonblocking; + +/// FNV-1a hasher for deterministic hashing across process runs. +/// Unlike DefaultHasher (SipHash with random seed), this produces stable +/// hashes. +pub struct Fnv1aHasher { + state: u64, +} + +impl Fnv1aHasher { + const FNV_OFFSET: u64 = 0xCBF29CE484222325; + const FNV_PRIME: u64 = 0x100000001B3; + + pub fn new() -> Self { + Self { + state: Self::FNV_OFFSET, + } + } + + pub fn write(&mut self, bytes: &[u8]) { + for byte in bytes { + self.state ^= *byte as u64; + self.state = self.state.wrapping_mul(Self::FNV_PRIME); + } + } + + pub fn finish(&self) -> u64 { + self.state + } +} + use base64::prelude::*; use log::{debug, error, info, warn}; use mime_sniffer::MimeTypeSniffer; @@ -210,11 +240,15 @@ impl fmt::Display for Entry { } pub struct SqliteClipboardDb { - pub conn: Connection, + pub conn: Connection, + pub db_path: PathBuf, } impl SqliteClipboardDb { - pub fn new(mut conn: Connection) -> Result { + pub fn new( + mut conn: Connection, + db_path: PathBuf, + ) -> Result { conn .pragma_update(None, "synchronous", "OFF") .map_err(|e| { @@ -449,7 +483,7 @@ impl SqliteClipboardDb { // focused window state. #[cfg(feature = "use-toplevel")] crate::wayland::init_wayland_state(); - Ok(Self { conn }) + Ok(Self { conn, db_path }) } } @@ -535,8 +569,8 @@ impl ClipboardDb for SqliteClipboardDb { } // Calculate content hash for deduplication - let mut hasher = DefaultHasher::new(); - buf.hash(&mut hasher); + let mut hasher = Fnv1aHasher::new(); + hasher.write(&buf); #[allow(clippy::cast_possible_wrap)] let content_hash = hasher.finish() as i64; @@ -940,20 +974,6 @@ impl SqliteClipboardDb { .map_err(|e| StashError::Trim(e.to_string().into())) } - /// Get the earliest expiration (timestamp, id) for heap initialization - pub fn get_next_expiration(&self) -> Result, StashError> { - match self.conn.query_row( - "SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT NULL \ - ORDER BY expires_at ASC LIMIT 1", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) { - Ok(result) => Ok(Some(result)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(StashError::Store(e.to_string().into())), - } - } - /// Set expiration timestamp for an entry pub fn set_expiration( &self, @@ -1338,7 +1358,8 @@ mod tests { fn test_db() -> SqliteClipboardDb { let conn = Connection::open_in_memory().expect("Failed to open in-memory db"); - SqliteClipboardDb::new(conn).expect("Failed to create test database") + SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create test database") } fn get_schema_version(conn: &Connection) -> rusqlite::Result { @@ -1369,7 +1390,8 @@ mod tests { let db_path = temp_dir.path().join("test_fresh.db"); let conn = Connection::open(&db_path).expect("Failed to open database"); - let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create database"); assert_eq!( get_schema_version(&db.conn).expect("Failed to get schema version"), @@ -1419,7 +1441,8 @@ mod tests { assert_eq!(get_schema_version(&conn).expect("Failed to get version"), 0); - let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create database"); assert_eq!( get_schema_version(&db.conn) @@ -1461,7 +1484,8 @@ mod tests { ) .expect("Failed to insert data"); - let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create database"); assert_eq!( get_schema_version(&db.conn) @@ -1504,7 +1528,8 @@ mod tests { ) .expect("Failed to insert data"); - let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create database"); assert_eq!( get_schema_version(&db.conn) @@ -1535,12 +1560,13 @@ mod tests { ) .expect("Failed to create table"); - let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create database"); let version_after_first = get_schema_version(&db.conn).expect("Failed to get version"); - let db2 = - SqliteClipboardDb::new(db.conn).expect("Failed to create database again"); + let db2 = SqliteClipboardDb::new(db.conn, db.db_path) + .expect("Failed to create database again"); let version_after_second = get_schema_version(&db2.conn).expect("Failed to get version"); @@ -1553,7 +1579,8 @@ mod tests { let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let db_path = temp_dir.path().join("test_store.db"); let conn = Connection::open(&db_path).expect("Failed to open database"); - let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create database"); let test_data = b"Hello, World!"; let cursor = std::io::Cursor::new(test_data.to_vec()); @@ -1589,7 +1616,8 @@ mod tests { let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let db_path = temp_dir.path().join("test_copy.db"); let conn = Connection::open(&db_path).expect("Failed to open database"); - let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create database"); let test_data = b"Test content for copy"; let cursor = std::io::Cursor::new(test_data.to_vec()); @@ -1608,8 +1636,8 @@ mod tests { std::thread::sleep(std::time::Duration::from_millis(1100)); - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - test_data.hash(&mut hasher); + let mut hasher = Fnv1aHasher::new(); + hasher.write(test_data); let content_hash = hasher.finish() as i64; let now = std::time::SystemTime::now() @@ -1670,7 +1698,8 @@ mod tests { ) .expect("Failed to insert data"); - let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); + let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create database"); assert_eq!( get_schema_version(&db.conn).expect("Failed to get version"), diff --git a/src/db/nonblocking.rs b/src/db/nonblocking.rs new file mode 100644 index 0000000..9640e26 --- /dev/null +++ b/src/db/nonblocking.rs @@ -0,0 +1,141 @@ +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 } + } + + pub async fn store_entry( + &self, + data: Vec, + max_dedupe_search: u64, + max_items: u64, + excluded_apps: Option>, + min_size: Option, + max_size: usize, + ) -> Result { + 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, + ) + }) + .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, 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, StashError> { + let path = self.db_path.clone(); + blocking::unblock(move || { + let db = Self::open_db_internal(&path)?; + let result: Option = 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 { + 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(), + } + } +} diff --git a/src/main.rs b/src/main.rs index fd74b1f..2c2f6e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -228,7 +228,7 @@ fn main() -> color_eyre::eyre::Result<()> { } let conn = rusqlite::Connection::open(&db_path)?; - let db = db::SqliteClipboardDb::new(conn)?; + let db = db::SqliteClipboardDb::new(conn, db_path)?; match cli.command { Some(Command::Store) => { @@ -476,7 +476,8 @@ fn main() -> color_eyre::eyre::Result<()> { &mime_type, cli.min_size, cli.max_size, - ); + ) + .await; }, None => { From cf5b1e82055d1f58d673a04e98a110adb04cfe1c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 5 Mar 2026 13:04:31 +0300 Subject: [PATCH 099/121] db: tests for determinism & async ops Signed-off-by: NotAShelf Change-Id: I2591e607a945c0aaa28a75247fc638436a6a6964 --- src/db/mod.rs | 106 +++++++++++++++++++++++ src/db/nonblocking.rs | 190 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 292 insertions(+), 4 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index 1f58cdf..62c2756 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -2047,4 +2047,110 @@ mod tests { assert_eq!(contents, data.to_vec()); assert_eq!(mime, Some("text/plain".to_string())); } + + #[test] + fn test_fnv1a_hasher_deterministic() { + // Same input should produce same hash + let data = b"test data"; + + let mut hasher1 = Fnv1aHasher::new(); + hasher1.write(data); + let hash1 = hasher1.finish(); + + let mut hasher2 = Fnv1aHasher::new(); + hasher2.write(data); + let hash2 = hasher2.finish(); + + assert_eq!(hash1, hash2, "FNV-1a should produce deterministic hashes"); + } + + #[test] + fn test_fnv1a_hasher_different_input() { + // Different inputs should (almost certainly) produce different hashes + let data1 = b"test data 1"; + let data2 = b"test data 2"; + + let mut hasher1 = Fnv1aHasher::new(); + hasher1.write(data1); + let hash1 = hasher1.finish(); + + let mut hasher2 = Fnv1aHasher::new(); + hasher2.write(data2); + let hash2 = hasher2.finish(); + + assert_ne!( + hash1, hash2, + "Different data should produce different hashes" + ); + } + + #[test] + fn test_fnv1a_hasher_known_values() { + // Test against known FNV-1a hash values + let mut hasher = Fnv1aHasher::new(); + hasher.write(b""); + assert_eq!( + hasher.finish(), + 0xCBF29CE484222325, + "Empty string hash mismatch" + ); + + let mut hasher = Fnv1aHasher::new(); + hasher.write(b"a"); + assert_eq!( + hasher.finish(), + 0xAF63DC4C8601EC8C, + "Single byte hash mismatch" + ); + + let mut hasher = Fnv1aHasher::new(); + hasher.write(b"hello"); + assert_eq!(hasher.finish(), 0xA430D84680AABD0B, "Hello hash mismatch"); + } + + #[test] + fn test_fnv1a_hash_stored_in_db() { + // Verify hash is stored correctly and can be retrieved + let db = test_db(); + let data = b"test content for hashing"; + + let id = db + .store_entry( + std::io::Cursor::new(data.to_vec()), + 100, + 1000, + None, + None, + DEFAULT_MAX_ENTRY_SIZE, + ) + .expect("Failed to store"); + + // Retrieve the stored hash + let stored_hash: i64 = db + .conn + .query_row( + "SELECT content_hash FROM clipboard WHERE id = ?1", + [id], + |row| row.get(0), + ) + .expect("Failed to get hash"); + + // Calculate hash independently + let mut hasher = Fnv1aHasher::new(); + hasher.write(data); + let calculated_hash = hasher.finish() as i64; + + assert_eq!( + stored_hash, calculated_hash, + "Stored hash should match calculated hash" + ); + + // Verify round-trip: convert back to u64 and compare + let stored_hash_u64 = stored_hash as u64; + let calculated_hash_u64 = hasher.finish(); + assert_eq!( + stored_hash_u64, calculated_hash_u64, + "Bit pattern should be preserved in i64/u64 conversion" + ); + } } diff --git a/src/db/nonblocking.rs b/src/db/nonblocking.rs index 9640e26..bdcc596 100644 --- a/src/db/nonblocking.rs +++ b/src/db/nonblocking.rs @@ -5,10 +5,9 @@ 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. +/// 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, } @@ -139,3 +138,186 @@ impl Clone for AsyncClipboardDb { } } } + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use tempfile::tempdir; + + use super::*; + + 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) + .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 = crate::db::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) + .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) + .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) + .await + .expect("Failed with original"); + + let id2 = cloned + .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000) + .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).await + }) + }) + .collect(); + + let results: Result, _> = 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"); + }); + } +} From 0865a1f1393f14eb6676e89d86e5552367f78d27 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 5 Mar 2026 14:06:12 +0300 Subject: [PATCH 100/121] 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 Change-Id: If8e8fe56bf6dc35960e47decf59636116a6a6964 --- src/commands/list.rs | 85 +++++++++++++++++++++++++------------------- 1 file changed, 49 insertions(+), 36 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index 3f1fd62..e9da836 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -57,6 +57,9 @@ struct TuiState { /// Whether to show entries in reverse order (oldest first). reverse: bool, + + /// ID of entry currently being copied. + copying_entry: Option, } impl TuiState { @@ -91,6 +94,7 @@ impl TuiState { search_query: String::new(), search_mode: false, reverse, + copying_entry: None, }) } @@ -678,42 +682,51 @@ impl SqliteClipboardDb { if actions.copy && let Some(&(id, ..)) = tui.selected_entry() { - 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().to_owned()), - 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(); - }, + 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().to_owned()), + 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; } } } From 373affabee8ca562a14cdc3634ea7bf52923fa56 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 5 Mar 2026 14:27:10 +0300 Subject: [PATCH 101/121] db: improve content hashing; cache only positive scan result Signed-off-by: NotAShelf Change-Id: If8035bf1dcd598a992762b9c714253406a6a6964 --- src/commands/store.rs | 1 + src/commands/watch.rs | 5 +- src/db/mod.rs | 113 +++++++++++++++++++++++++++++++++++++----- src/db/nonblocking.rs | 15 +++--- 4 files changed, 115 insertions(+), 19 deletions(-) diff --git a/src/commands/store.rs b/src/commands/store.rs index 3854b16..af683d7 100644 --- a/src/commands/store.rs +++ b/src/commands/store.rs @@ -38,6 +38,7 @@ impl StoreCommand for SqliteClipboardDb { Some(excluded_apps), min_size, max_size, + None, // no pre-computed hash for CLI store )?; log::info!("Entry stored"); } diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 9ac82cc..133cf68 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -363,6 +363,8 @@ impl WatchCommand for SqliteClipboardDb { 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); match async_db .store_entry( buf_clone, @@ -371,6 +373,7 @@ impl WatchCommand for SqliteClipboardDb { Some(excluded_apps.to_vec()), min_size, max_size, + content_hash, ) .await { @@ -433,7 +436,7 @@ impl WatchCommand for SqliteClipboardDb { } } -/// Unit-testable helper: given ordered offers and a preference, return the +/// 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)] diff --git a/src/db/mod.rs b/src/db/mod.rs index 62c2756..facaa99 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -5,11 +5,67 @@ use std::{ io::{BufRead, BufReader, Read, Write}, path::PathBuf, str, - sync::OnceLock, + sync::{Mutex, OnceLock}, + time::{Duration, Instant}, }; pub mod nonblocking; +/// Cache for process scanning results to avoid expensive `/proc` reads on every +/// store operation. TTL of 5 seconds balances freshness with performance. +struct ProcessCache { + last_scan: Instant, + excluded_app: Option, +} + +impl ProcessCache { + const TTL: Duration = Duration::from_secs(5); + + /// Check cache for recently active excluded app. + /// Only caches positive results (when an excluded app IS found). + /// Negative results (no excluded apps) are never cached to ensure + /// we don't miss exclusions when users switch apps. + fn get(excluded_apps: &[String]) -> Option { + static CACHE: OnceLock> = OnceLock::new(); + let cache = CACHE.get_or_init(|| { + Mutex::new(ProcessCache { + last_scan: Instant::now() - Self::TTL, /* Expire immediately on + * first use */ + excluded_app: None, + }) + }); + + if let Ok(mut cache) = cache.lock() { + // Check if we have a valid cached positive result + if cache.last_scan.elapsed() < Self::TTL + && let Some(ref app) = cache.excluded_app + { + // Verify the cached app is still in the exclusion list + if app_matches_exclusion(app, excluded_apps) { + return Some(app.clone()); + } + } + + // No valid cache, scan and only cache positive results + let result = get_recently_active_excluded_app_uncached(excluded_apps); + if result.is_some() { + cache.last_scan = Instant::now(); + cache.excluded_app = result.clone(); + } else { + // Don't cache negative results. We expire cache immediately so next + // call will rescan. This ensures we don't miss exclusions when user + // switches from non-excluded to excluded app. + cache.last_scan = Instant::now() - Self::TTL; + cache.excluded_app = None; + } + result + } else { + // Lock poisoned - fall back to uncached + get_recently_active_excluded_app_uncached(excluded_apps) + } + } +} + /// FNV-1a hasher for deterministic hashing across process runs. /// Unlike DefaultHasher (SipHash with random seed), this produces stable /// hashes. @@ -187,6 +243,18 @@ pub enum StashError { } pub trait ClipboardDb { + /// Store a new clipboard entry. + /// + /// # Arguments + /// * `input` - Reader for the clipboard content + /// * `max_dedupe_search` - Maximum number of recent entries to check for + /// duplicates + /// * `max_items` - Maximum total entries to keep in database + /// * `excluded_apps` - List of app names to exclude + /// * `min_size` - Minimum content size (None for no minimum) + /// * `max_size` - Maximum content size + /// * `content_hash` - Optional pre-computed content hash (avoids re-hashing) + #[allow(clippy::too_many_arguments)] fn store_entry( &self, input: impl Read, @@ -195,6 +263,7 @@ pub trait ClipboardDb { excluded_apps: Option<&[String]>, min_size: Option, max_size: usize, + content_hash: Option, ) -> Result; fn deduplicate_by_hash( @@ -308,8 +377,8 @@ impl SqliteClipboardDb { })?; } - // Add content_hash column if it doesn't exist - // Migration MUST be done to avoid breaking existing installations. + // Add content_hash column if it doesn't exist. Migration MUST be done to + // avoid breaking existing installations. if schema_version < 2 { let has_content_hash: bool = tx .query_row( @@ -546,6 +615,7 @@ impl ClipboardDb for SqliteClipboardDb { excluded_apps: Option<&[String]>, min_size: Option, max_size: usize, + content_hash: Option, ) -> Result { let mut buf = Vec::new(); if input.read_to_end(&mut buf).is_err() || buf.is_empty() { @@ -568,11 +638,14 @@ impl ClipboardDb for SqliteClipboardDb { return Err(StashError::AllWhitespace); } - // Calculate content hash for deduplication - let mut hasher = Fnv1aHasher::new(); - hasher.write(&buf); - #[allow(clippy::cast_possible_wrap)] - let content_hash = hasher.finish() as i64; + // Use pre-computed hash if provided, otherwise calculate it + let content_hash = content_hash.unwrap_or_else(|| { + let mut hasher = Fnv1aHasher::new(); + hasher.write(&buf); + #[allow(clippy::cast_possible_wrap)] + let hash = hasher.finish() as i64; + hash + }); let mime = crate::mime::detect_mime(&buf); @@ -1181,7 +1254,8 @@ fn detect_excluded_app_activity(excluded_apps: &[String]) -> bool { } // Strategy 2: Check recently active processes (timing correlation) - if let Some(active_app) = get_recently_active_excluded_app(excluded_apps) { + // Use cached results to avoid expensive /proc scanning + if let Some(active_app) = ProcessCache::get(excluded_apps) { debug!("Clipboard excluded: recent activity from {active_app}"); return true; } @@ -1212,7 +1286,8 @@ fn get_focused_window_app() -> Option { } /// Check for recently active excluded apps using CPU and I/O activity. -fn get_recently_active_excluded_app( +/// This is the uncached version - use `ProcessCache::get()` for cached access. +fn get_recently_active_excluded_app_uncached( excluded_apps: &[String], ) -> Option { let proc_dir = std::path::Path::new("/proc"); @@ -1586,7 +1661,7 @@ mod tests { let cursor = std::io::Cursor::new(test_data.to_vec()); let id = db - .store_entry(cursor, 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE) + .store_entry(cursor, 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None) .expect("Failed to store entry"); let content_hash: Option = db @@ -1622,7 +1697,7 @@ mod tests { let test_data = b"Test content for copy"; let cursor = std::io::Cursor::new(test_data.to_vec()); let id_a = db - .store_entry(cursor, 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE) + .store_entry(cursor, 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None) .expect("Failed to store entry A"); let original_last_accessed: i64 = db @@ -1725,6 +1800,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store URI list"); @@ -1758,6 +1834,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store image"); @@ -1786,6 +1863,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store first"); let _id2 = db @@ -1796,6 +1874,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store second"); @@ -1831,6 +1910,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store"); } @@ -1852,6 +1932,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ); assert!(matches!(result, Err(StashError::EmptyOrTooLarge))); } @@ -1866,6 +1947,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ); assert!(matches!(result, Err(StashError::AllWhitespace))); } @@ -1882,6 +1964,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ); assert!(matches!(result, Err(StashError::TooLarge(5000000)))); } @@ -1897,6 +1980,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store"); @@ -1923,6 +2007,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store"); db.store_entry( @@ -1932,6 +2017,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store"); @@ -1959,6 +2045,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store"); } @@ -2038,6 +2125,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store"); @@ -2122,6 +2210,7 @@ mod tests { None, None, DEFAULT_MAX_ENTRY_SIZE, + None, ) .expect("Failed to store"); diff --git a/src/db/nonblocking.rs b/src/db/nonblocking.rs index bdcc596..d45d905 100644 --- a/src/db/nonblocking.rs +++ b/src/db/nonblocking.rs @@ -25,6 +25,7 @@ impl AsyncClipboardDb { excluded_apps: Option>, min_size: Option, max_size: usize, + content_hash: Option, ) -> Result { let path = self.db_path.clone(); blocking::unblock(move || { @@ -36,6 +37,7 @@ impl AsyncClipboardDb { excluded_apps.as_deref(), min_size, max_size, + content_hash, ) }) .await @@ -170,7 +172,7 @@ mod tests { let data = b"async test data"; let id = async_db - .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000) + .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None) .await .expect("Failed to store entry"); @@ -199,7 +201,7 @@ mod tests { let data = b"expiring entry"; let id = async_db - .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000) + .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None) .await .expect("Failed to store entry"); @@ -231,7 +233,7 @@ mod tests { let data = b"entry to expire"; let id = async_db - .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000) + .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None) .await .expect("Failed to store entry"); @@ -278,12 +280,12 @@ mod tests { let data = b"clone test"; let id1 = async_db - .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000) + .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None) .await .expect("Failed with original"); let id2 = cloned - .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000) + .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None) .await .expect("Failed with clone"); @@ -302,7 +304,8 @@ mod tests { 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).await + db.store_entry(data, 100, 1000, None, None, 5_000_000, None) + .await }) }) .collect(); From b1f43bdf7fd348d1cde18accc6ffa01cb432831d Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 5 Mar 2026 15:14:02 +0300 Subject: [PATCH 102/121] db: replace \`CHECKED\` atomic flag with pattern-keyed regex cache Signed-off-by: NotAShelf Change-Id: I9d5fa5212c5418ce6bca02d05149e1356a6a6964 --- src/commands/list.rs | 4 +- src/commands/watch.rs | 6 +- src/db/mod.rs | 115 ++++++++++++++++++++++++++++++-------- src/main.rs | 4 +- src/multicall/wl_paste.rs | 12 ++-- 5 files changed, 104 insertions(+), 37 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index e9da836..7d289ad 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -412,7 +412,7 @@ impl SqliteClipboardDb { }, (KeyCode::Enter, _) => actions.copy = true, (KeyCode::Char('D'), KeyModifiers::SHIFT) => { - actions.delete = true + actions.delete = true; }, (KeyCode::Char('/'), _) => actions.toggle_search = true, _ => {}, @@ -697,7 +697,7 @@ impl SqliteClipboardDb { 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().to_owned()), + Some(ref m) => MimeType::Specific(m.clone().clone()), None => MimeType::Text, }; let copy_result = opts diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 133cf68..c5ae423 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -1,7 +1,7 @@ use std::{collections::BinaryHeap, io::Read, time::Duration}; /// FNV-1a hasher for deterministic hashing across process runs. -/// Unlike DefaultHasher (SipHash), this produces stable hashes. +/// Unlike `DefaultHasher` (`SipHash`), this produces stable hashes. struct Fnv1aHasher { state: u64, } @@ -18,7 +18,7 @@ impl Fnv1aHasher { fn write(&mut self, bytes: &[u8]) { for byte in bytes { - self.state ^= *byte as u64; + self.state ^= u64::from(*byte); self.state = self.state.wrapping_mul(Self::FNV_PRIME); } } @@ -82,7 +82,7 @@ impl std::cmp::Ord for Neg { } /// Min-heap for tracking entry expirations with sub-second precision. -/// Uses Neg wrapper to turn BinaryHeap (max-heap) into min-heap behavior. +/// Uses Neg wrapper to turn `BinaryHeap` (max-heap) into min-heap behavior. #[derive(Debug, Default)] struct ExpirationQueue { heap: BinaryHeap<(Neg, i64)>, diff --git a/src/db/mod.rs b/src/db/mod.rs index facaa99..6e32381 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -29,7 +29,7 @@ impl ProcessCache { static CACHE: OnceLock> = OnceLock::new(); let cache = CACHE.get_or_init(|| { Mutex::new(ProcessCache { - last_scan: Instant::now() - Self::TTL, /* Expire immediately on + last_scan: Instant::now().checked_sub(Self::TTL).unwrap(), /* Expire immediately on * first use */ excluded_app: None, }) @@ -55,7 +55,7 @@ impl ProcessCache { // Don't cache negative results. We expire cache immediately so next // call will rescan. This ensures we don't miss exclusions when user // switches from non-excluded to excluded app. - cache.last_scan = Instant::now() - Self::TTL; + cache.last_scan = Instant::now().checked_sub(Self::TTL).unwrap(); cache.excluded_app = None; } result @@ -67,7 +67,7 @@ impl ProcessCache { } /// FNV-1a hasher for deterministic hashing across process runs. -/// Unlike DefaultHasher (SipHash with random seed), this produces stable +/// Unlike `DefaultHasher` (`SipHash` with random seed), this produces stable /// hashes. pub struct Fnv1aHasher { state: u64, @@ -85,7 +85,7 @@ impl Fnv1aHasher { pub fn write(&mut self, bytes: &[u8]) { for byte in bytes { - self.state ^= *byte as u64; + self.state ^= u64::from(*byte); self.state = self.state.wrapping_mul(Self::FNV_PRIME); } } @@ -1129,31 +1129,41 @@ impl SqliteClipboardDb { /// # Returns /// /// `Some(Regex)` if present and valid, `None` otherwise. +/// +/// # Note +/// +/// This function checks environment variables on every call to pick up +/// changes made after daemon startup. Regex compilation is cached by +/// pattern to avoid recompilation. fn load_sensitive_regex() -> Option { - static REGEX_CACHE: OnceLock> = OnceLock::new(); - static CHECKED: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); + // Get the current pattern from env vars + let pattern = if let Ok(regex_path) = env::var("CREDENTIALS_DIRECTORY") { + let file = format!("{regex_path}/clipboard_filter"); + fs::read_to_string(&file).ok().map(|s| s.trim().to_string()) + } else { + env::var("STASH_SENSITIVE_REGEX").ok() + }?; - if !CHECKED.load(std::sync::atomic::Ordering::Relaxed) { - CHECKED.store(true, std::sync::atomic::Ordering::Relaxed); + // Cache compiled regexes by pattern to avoid recompilation + static REGEX_CACHE: OnceLock< + Mutex>, + > = OnceLock::new(); + let cache = + REGEX_CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new())); - let regex = if let Ok(regex_path) = env::var("CREDENTIALS_DIRECTORY") { - let file = format!("{regex_path}/clipboard_filter"); - if let Ok(contents) = fs::read_to_string(&file) { - Regex::new(contents.trim()).ok() - } else { - None - } - } else if let Ok(pattern) = env::var("STASH_SENSITIVE_REGEX") { - Regex::new(&pattern).ok() - } else { - None - }; - - let _ = REGEX_CACHE.set(regex); + // Check cache first + if let Ok(cache) = cache.lock() + && let Some(regex) = cache.get(&pattern) + { + return Some(regex.clone()); } - REGEX_CACHE.get().and_then(std::clone::Clone::clone) + // Compile and cache + Regex::new(&pattern).ok().inspect(|regex| { + if let Ok(mut cache) = cache.lock() { + cache.insert(pattern.clone(), regex.clone()); + } + }) } pub fn extract_id(input: &str) -> Result { @@ -2242,4 +2252,61 @@ mod tests { "Bit pattern should be preserved in i64/u64 conversion" ); } + + /// Verify that regex loading picks up env var changes. This was broken + /// because CHECKED flag prevented re-checking after first call + #[test] + fn test_sensitive_regex_env_var_change_detection() { + // XXX: This test manipulates environment variables which affects + // parallel tests. We use a unique pattern to avoid conflicts. + use std::sync::atomic::{AtomicUsize, Ordering}; + + static TEST_COUNTER: AtomicUsize = AtomicUsize::new(0); + let test_id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); + + // Test 1: No env var set initially + let var_name = format!("STASH_SENSITIVE_REGEX_TEST_{}", test_id); + unsafe { + env::remove_var(&var_name); + } + + // Temporarily override the function to use our test var + // Since we can't easily mock env::var, we test the logic indirectly + // by verifying the new implementation checks every time + + // Call multiple times, ensure no panic and behavior is + // consistent + let _ = load_sensitive_regex(); + let _ = load_sensitive_regex(); + let _ = load_sensitive_regex(); + + // If we got here without deadlocks or panics, the caching logic works + // The actual env var change detection is verified by the implementation: + // - Preivously CHECKED atomic prevented re-checking + // - Now we check env vars every call, only caches compiled Regex objects + } + + /// Test that regex compilation is cached by pattern + #[test] + fn test_sensitive_regex_caching_by_pattern() { + // This test verifies that the regex cache works correctly + // by ensuring multiple calls don't cause issues. + + // Call multiple times, should use cache after first compilation + let result1 = load_sensitive_regex(); + let result2 = load_sensitive_regex(); + let result3 = load_sensitive_regex(); + + // All results should be consistent + assert_eq!( + result1.is_some(), + result2.is_some(), + "Regex loading should be deterministic" + ); + assert_eq!( + result2.is_some(), + result3.is_some(), + "Regex loading should be deterministic" + ); + } } diff --git a/src/main.rs b/src/main.rs index 2c2f6e0..e2602aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -397,7 +397,7 @@ fn main() -> color_eyre::eyre::Result<()> { if expired { match db.cleanup_expired() { Ok(count) => { - log::info!("Wiped {} expired entries", count); + log::info!("Wiped {count} expired entries"); }, Err(e) => { log::error!("failed to wipe expired entries: {e}"); @@ -421,7 +421,7 @@ fn main() -> color_eyre::eyre::Result<()> { DbAction::Stats => { match db.stats() { Ok(stats) => { - println!("{}", stats); + println!("{stats}"); }, Err(e) => { log::error!("failed to get database stats: {e}"); diff --git a/src/multicall/wl_paste.rs b/src/multicall/wl_paste.rs index af686c4..4b828b5 100644 --- a/src/multicall/wl_paste.rs +++ b/src/multicall/wl_paste.rs @@ -360,7 +360,7 @@ fn execute_watch_command( /// 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). +/// text representations (TEXT, STRING, `UTF8_STRING`). fn select_best_mime_type( types: &std::collections::HashSet, ) -> Option { @@ -421,7 +421,7 @@ fn handle_regular_paste( 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); + log::debug!("Auto-selecting MIME type: {best}"); PasteMimeType::Specific(best) } else { get_paste_mime_type(args.mime_type.as_deref()) @@ -461,14 +461,14 @@ fn handle_regular_paste( // 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() { + 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" - } else { - // If no MIME type, check if content is valid UTF-8 - std::str::from_utf8(&buf).is_ok() }; if !args.no_newline From 3faadd709f15829aab09daf88be020cbd75be0f2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:36:04 +0000 Subject: [PATCH 103/121] 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] --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 30d0945..b28140e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1385,9 +1385,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libredox" diff --git a/Cargo.toml b/Cargo.toml index dfc08e7..5167fd4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ env_logger = "0.11.8" humantime = "2.3.0" imagesize = "0.14.0" inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] } -libc = "0.2.182" +libc = "0.2.183" log = "0.4.29" mime-sniffer = "0.1.3" notify-rust = { version = "4.12.0", optional = true } From 909bb53afaa680155baf4a7784d12612aa30ee74 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:55:19 +0000 Subject: [PATCH 104/121] 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] --- .github/workflows/nix-cache.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/nix-cache.yaml b/.github/workflows/nix-cache.yaml index 9a9b4dc..8936c67 100644 --- a/.github/workflows/nix-cache.yaml +++ b/.github/workflows/nix-cache.yaml @@ -20,7 +20,7 @@ jobs: with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v16 + - uses: cachix/cachix-action@v17 with: name: nyx authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' From aabf40ac6ec3e7022374b4789566ec7422955eaf Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 31 Mar 2026 09:00:05 +0300 Subject: [PATCH 105/121] build: bump dependencies Signed-off-by: NotAShelf Change-Id: I7a974572e4e36c9013e5c1c808677eaf6a6a6964 --- Cargo.lock | 197 ++++++++++++++++++++++++++++------------------------- Cargo.toml | 14 ++-- 2 files changed, 110 insertions(+), 101 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b28140e..fe1039d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,9 +34,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -49,15 +49,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -364,9 +364,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "shlex", @@ -386,9 +386,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -406,9 +406,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -418,9 +418,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", @@ -430,9 +430,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "color-eyre" @@ -463,9 +463,9 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compact_str" @@ -753,9 +753,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", "regex", @@ -763,9 +763,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.9" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ "anstream", "anstyle", @@ -792,9 +792,9 @@ dependencies = [ [[package]] name = "euclid" -version = "0.22.13" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" dependencies = [ "num-traits", ] @@ -1288,9 +1288,9 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ "darling", "indoc", @@ -1316,9 +1316,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" @@ -1346,9 +1346,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" dependencies = [ "once_cell", "wasm-bindgen", @@ -1356,9 +1356,9 @@ dependencies = [ [[package]] name = "kasuari" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", @@ -1391,18 +1391,18 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "libc", ] [[package]] name = "libsqlite3-sys" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", "pkg-config", @@ -1411,9 +1411,9 @@ dependencies = [ [[package]] name = "line-clipping" -version = "0.3.5" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ "bitflags 2.11.0", ] @@ -1462,9 +1462,9 @@ dependencies = [ [[package]] name = "mac-notification-sys" -version = "0.6.9" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" +checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" dependencies = [ "cc", "objc2", @@ -1536,9 +1536,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -1606,9 +1606,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-derive" @@ -1689,9 +1689,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -1926,9 +1926,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" dependencies = [ "portable-atomic", ] @@ -2176,9 +2176,9 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ "bitflags 2.11.0", "fallible-iterator", @@ -2521,9 +2521,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.4.2", @@ -2677,32 +2677,32 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ "indexmap", "toml_datetime", "toml_parser", - "winnow", + "winnow 1.0.1", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ - "winnow", + "winnow 1.0.1", ] [[package]] @@ -2749,9 +2749,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "sharded-slab", "thread_local", @@ -2783,9 +2783,9 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "uds_windows" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", @@ -2800,9 +2800,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-truncate" @@ -2853,9 +2853,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.21.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "atomic", "getrandom 0.4.2", @@ -2917,9 +2917,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631" dependencies = [ "cfg-if", "once_cell", @@ -2930,9 +2930,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2940,9 +2940,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac" dependencies = [ "bumpalo", "proc-macro2", @@ -2953,9 +2953,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" dependencies = [ "unicode-ident", ] @@ -2996,9 +2996,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.13" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaa6143502b9a87f759cb6a649ca801a226f77740eb54f3951cba2227790afeb" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", "downcast-rs", @@ -3009,9 +3009,9 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.13" +version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab51d9f7c071abeee76007e2b742499e535148035bb835f97aaed1338cf516c3" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ "bitflags 2.11.0", "log", @@ -3022,9 +3022,9 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.11" +version = "0.32.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b23b5df31ceff1328f06ac607591d5ba360cf58f90c8fad4ac8d3a55a3c4aec7" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" dependencies = [ "bitflags 2.11.0", "wayland-backend", @@ -3034,9 +3034,9 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78248e4cc0eff8163370ba5c158630dcae1f3497a586b826eca2ef5f348d6235" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ "bitflags 2.11.0", "wayland-backend", @@ -3047,9 +3047,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.9" +version = "0.31.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c86287151a309799b821ca709b7345a048a2956af05957c89cb824ab919fa4e3" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", "quick-xml 0.39.2", @@ -3058,9 +3058,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.9" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81d2bd69b1dadd601d0e98ef2fc9339a1b1e00cec5ee7545a77b5a0f52a90394" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "pkg-config", ] @@ -3296,9 +3296,18 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" dependencies = [ "memchr", ] @@ -3467,7 +3476,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys", - "winnow", + "winnow 0.7.15", "zbus_macros", "zbus_names", "zvariant", @@ -3495,7 +3504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "winnow", + "winnow 0.7.15", "zvariant", ] @@ -3568,7 +3577,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 0.7.15", "zvariant_derive", "zvariant_utils", ] @@ -3596,5 +3605,5 @@ dependencies = [ "quote", "serde", "syn 2.0.117", - "winnow", + "winnow 0.7.15", ] diff --git a/Cargo.toml b/Cargo.toml index 5167fd4..bfc3800 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,13 +16,13 @@ path = "src/main.rs" [dependencies] base64 = "0.22.1" blocking = "1.6.2" -clap = { version = "4.5.60", features = [ "derive", "env" ] } +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.8" +env_logger = "0.11.10" humantime = "2.3.0" imagesize = "0.14.0" inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] } @@ -32,20 +32,20 @@ mime-sniffer = "0.1.3" notify-rust = { version = "4.12.0", optional = true } ratatui = "0.30.0" regex = "1.12.3" -rusqlite = { version = "0.38.0", features = [ "bundled" ] } +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.12.0" +unicode-segmentation = "1.13.2" unicode-width = "0.2.2" -wayland-client = { version = "0.31.12", features = [ "log" ], optional = true } -wayland-protocols-wlr = { version = "0.3.10", default-features = false, optional = true } +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.26.0" +tempfile = "3.27.0" [features] default = [ "notifications", "use-toplevel" ] From fe86356399138973f6d85e900f729e4709343310 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 27 Mar 2026 08:39:49 +0300 Subject: [PATCH 106/121] wayland: use arc-swap over Mutex for `FOCUSED_APP` for better concurrency Signed-off-by: NotAShelf Change-Id: Id6b40d5c533c35dda5bce7b852b836f26a6a6964 --- Cargo.lock | 10 ++++++++++ Cargo.toml | 3 ++- src/wayland/mod.rs | 19 +++++++++---------- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fe1039d..8ea168d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +dependencies = [ + "rustversion", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -2409,6 +2418,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" name = "stash-clipboard" version = "0.3.6" dependencies = [ + "arc-swap", "base64", "blocking", "clap", diff --git a/Cargo.toml b/Cargo.toml index bfc3800..bae39c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ name = "stash" # actual binary name for Nix, Cargo, etc. path = "src/main.rs" [dependencies] +arc-swap = { version = "1.9.0", optional = true } base64 = "0.22.1" blocking = "1.6.2" clap = { version = "4.6.0", features = [ "derive", "env" ] } @@ -50,7 +51,7 @@ tempfile = "3.27.0" [features] default = [ "notifications", "use-toplevel" ] notifications = [ "dep:notify-rust" ] -use-toplevel = [ "dep:wayland-client", "dep:wayland-protocols-wlr" ] +use-toplevel = [ "dep:arc-swap", "dep:wayland-client", "dep:wayland-protocols-wlr" ] [profile.release] lto = true diff --git a/src/wayland/mod.rs b/src/wayland/mod.rs index 9cfa765..38f6ff5 100644 --- a/src/wayland/mod.rs +++ b/src/wayland/mod.rs @@ -1,8 +1,9 @@ use std::{ collections::HashMap, - sync::{LazyLock, Mutex}, + sync::{Arc, LazyLock, Mutex}, }; +use arc_swap::ArcSwapOption; use log::debug; use wayland_client::{ Connection as WaylandConnection, @@ -17,7 +18,7 @@ use wayland_protocols_wlr::foreign_toplevel::v1::client::{ zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1}, }; -static FOCUSED_APP: Mutex> = Mutex::new(None); +static FOCUSED_APP: ArcSwapOption = ArcSwapOption::const_empty(); static TOPLEVEL_APPS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); @@ -32,12 +33,11 @@ pub fn init_wayland_state() { /// Get the currently focused window application name using Wayland protocols pub fn get_focused_window_app() -> Option { - // Try Wayland protocol first - if let Ok(focused) = FOCUSED_APP.lock() - && let Some(ref app) = *focused - { + // 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.clone()); + return Some(app.to_string()); } debug!("No focused window detection method worked"); @@ -152,12 +152,11 @@ impl Dispatch for AppState { }) { debug!("Toplevel activated"); // Update focused app to the `app_id` of this handle - if let (Ok(apps), Ok(mut focused)) = - (TOPLEVEL_APPS.lock(), FOCUSED_APP.lock()) + if let Ok(apps) = TOPLEVEL_APPS.lock() && let Some(app_id) = apps.get(&handle_id) { debug!("Setting focused app to: {app_id}"); - *focused = Some(app_id.clone()); + FOCUSED_APP.store(Some(Arc::new(app_id.clone()))); } } }, From 030be21ea5f3e6f36a944cd7cd38fadb2160db08 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 27 Mar 2026 09:20:54 +0300 Subject: [PATCH 107/121] 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 Change-Id: I14fbcf8cbc47c40bfa1da7f8b09245936a6a6964 --- src/clipboard/mod.rs | 3 + src/clipboard/persist.rs | 262 +++++++++++++++++++++++++++++++++++++++ src/commands/store.rs | 1 + src/commands/watch.rs | 237 +++++++++++++++++++++++++++++++---- src/db/mod.rs | 208 ++++++++++++------------------- src/db/nonblocking.rs | 60 ++++++++- src/main.rs | 1 + 7 files changed, 616 insertions(+), 156 deletions(-) create mode 100644 src/clipboard/mod.rs create mode 100644 src/clipboard/persist.rs diff --git a/src/clipboard/mod.rs b/src/clipboard/mod.rs new file mode 100644 index 0000000..2648ce5 --- /dev/null +++ b/src/clipboard/mod.rs @@ -0,0 +1,3 @@ +pub mod persist; + +pub use persist::{ClipboardData, get_serving_pid, persist_clipboard}; diff --git a/src/clipboard/persist.rs b/src/clipboard/persist.rs new file mode 100644 index 0000000..df73fc8 --- /dev/null +++ b/src/clipboard/persist.rs @@ -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 { + let pid = SERVING_PID.load(Ordering::SeqCst); + if pid != 0 { Some(pid) } else { None } +} + +/// Result type for persistence operations. +pub type PersistenceResult = Result; + +/// 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, + + /// All MIME types offered by the source. Preserves order. + pub mime_types: Vec, + + /// The MIME type that was selected for storage. + pub selected_mime: String, +} + +impl ClipboardData { + /// Create new clipboard data. + pub fn new( + content: Vec, + mime_types: Vec, + 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 { + 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"); + } +} diff --git a/src/commands/store.rs b/src/commands/store.rs index af683d7..0b7e23c 100644 --- a/src/commands/store.rs +++ b/src/commands/store.rs @@ -39,6 +39,7 @@ impl StoreCommand for SqliteClipboardDb { min_size, max_size, None, // no pre-computed hash for CLI store + None, // no mime types for CLI store )?; log::info!("Entry stored"); } diff --git a/src/commands/watch.rs b/src/commands/watch.rs index c5ae423..ddfdbea 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -1,5 +1,22 @@ use std::{collections::BinaryHeap, 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}, +}; + /// FNV-1a hasher for deterministic hashing across process runs. /// Unlike `DefaultHasher` (`SipHash`), this produces stable hashes. struct Fnv1aHasher { @@ -28,20 +45,6 @@ impl Fnv1aHasher { } } -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::db::{SqliteClipboardDb, nonblocking::AsyncClipboardDb}; - /// 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: @@ -151,21 +154,29 @@ impl ExpirationQueue { /// 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, String), wl_clipboard_rs::paste::Error> { +) -> Result<(Box, String, Vec), 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, mime_str)); + return Ok((Box::new(reader) as Box, mime_str, offered)); } - let offered = - get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified)?; - let chosen = if preference == "image" { // Pick the first offered image type, fall back to first overall offered @@ -202,7 +213,8 @@ fn negotiate_mime_type( Seat::Unspecified, PasteMimeType::Specific(mime_str), )?; - Ok((Box::new(reader) as Box, actual_mime)) + + Ok((Box::new(reader) as Box, actual_mime, offered)) }, None => Err(wl_clipboard_rs::paste::Error::NoSeats), } @@ -270,7 +282,7 @@ impl WatchCommand for SqliteClipboardDb { }; // Initialize with current clipboard using smart MIME negotiation - if let Ok((mut reader, _)) = negotiate_mime_type(mime_type_preference) { + 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)); @@ -306,7 +318,7 @@ impl WatchCommand for SqliteClipboardDb { } // Check if this expired entry is currently in the clipboard - if let Ok((mut reader, _)) = + if let Ok((mut reader, ..)) = negotiate_mime_type(mime_type_preference) { let mut current_buf = Vec::new(); @@ -349,7 +361,7 @@ impl WatchCommand for SqliteClipboardDb { // Normal clipboard polling (always run, even when expirations are // pending) match negotiate_mime_type(mime_type_preference) { - Ok((mut reader, _mime_type)) => { + 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}"); @@ -365,6 +377,12 @@ impl WatchCommand for SqliteClipboardDb { 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, @@ -374,6 +392,7 @@ impl WatchCommand for SqliteClipboardDb { min_size, max_size, content_hash, + Some(mime_types_for_persist.clone()), ) .await { @@ -381,6 +400,37 @@ impl WatchCommand for SqliteClipboardDb { 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 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 { + log::trace!( + "Already serving clipboard, skipping persistence fork" + ); + } + // Set expiration if configured if let Some(duration) = expire_after { let expires_at = @@ -539,4 +589,145 @@ mod tests { 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 = 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"); + } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 6e32381..441495f 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -254,6 +254,7 @@ pub trait ClipboardDb { /// * `min_size` - Minimum content size (None for no minimum) /// * `max_size` - Maximum content size /// * `content_hash` - Optional pre-computed content hash (avoids re-hashing) + /// * `mime_types` - Optional list of all MIME types offered (for persistence) #[allow(clippy::too_many_arguments)] fn store_entry( &self, @@ -264,6 +265,7 @@ pub trait ClipboardDb { min_size: Option, max_size: usize, content_hash: Option, + mime_types: Option<&[String]>, ) -> Result; fn deduplicate_by_hash( @@ -542,6 +544,36 @@ impl SqliteClipboardDb { })?; } + // Add mime_types column if it doesn't exist (v6) + // Stores all MIME types offered by the source application as JSON array. + // Needed for clipboard persistence to re-offer the same types. + if schema_version < 6 { + let has_mime_types: bool = tx + .query_row( + "SELECT sql FROM sqlite_master WHERE type='table' AND \ + name='clipboard'", + [], + |row| { + let sql: String = row.get(0)?; + Ok(sql.to_lowercase().contains("mime_types")) + }, + ) + .unwrap_or(false); + + if !has_mime_types { + tx.execute("ALTER TABLE clipboard ADD COLUMN mime_types TEXT", []) + .map_err(|e| { + StashError::Store( + format!("Failed to add mime_types column: {e}").into(), + ) + })?; + } + + tx.execute("PRAGMA user_version = 6", []).map_err(|e| { + StashError::Store(format!("Failed to set schema version: {e}").into()) + })?; + } + tx.commit().map_err(|e| { StashError::Store( format!("Failed to commit migration transaction: {e}").into(), @@ -616,6 +648,7 @@ impl ClipboardDb for SqliteClipboardDb { min_size: Option, max_size: usize, content_hash: Option, + mime_types: Option<&[String]>, ) -> Result { let mut buf = Vec::new(); if input.read_to_end(&mut buf).is_err() || buf.is_empty() { @@ -671,11 +704,21 @@ impl ClipboardDb for SqliteClipboardDb { self.deduplicate_by_hash(content_hash, max_dedupe_search)?; + let mime_types_json: Option = match mime_types { + Some(types) => { + Some( + serde_json::to_string(&types) + .map_err(|e| StashError::Store(e.to_string().into()))?, + ) + }, + None => None, + }; + self .conn .execute( - "INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \ - VALUES (?1, ?2, ?3, ?4)", + "INSERT INTO clipboard (contents, mime, content_hash, last_accessed, \ + mime_types) VALUES (?1, ?2, ?3, ?4, ?5)", params![ buf, mime, @@ -683,7 +726,8 @@ impl ClipboardDb for SqliteClipboardDb { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("Time went backwards") - .as_secs() as i64 + .as_secs() as i64, + mime_types_json ], ) .map_err(|e| StashError::Store(e.to_string().into()))?; @@ -1480,11 +1524,12 @@ mod tests { assert_eq!( get_schema_version(&db.conn).expect("Failed to get schema version"), - 5 + 6 ); assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); + assert!(table_column_exists(&db.conn, "clipboard", "mime_types")); assert!(index_exists(&db.conn, "idx_content_hash")); assert!(index_exists(&db.conn, "idx_last_accessed")); @@ -1532,11 +1577,12 @@ mod tests { assert_eq!( get_schema_version(&db.conn) .expect("Failed to get version after migration"), - 5 + 6 ); assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); + assert!(table_column_exists(&db.conn, "clipboard", "mime_types")); let count: i64 = db .conn @@ -1575,11 +1621,12 @@ mod tests { assert_eq!( get_schema_version(&db.conn) .expect("Failed to get version after migration"), - 5 + 6 ); assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); + assert!(table_column_exists(&db.conn, "clipboard", "mime_types")); let count: i64 = db .conn @@ -1619,11 +1666,12 @@ mod tests { assert_eq!( get_schema_version(&db.conn) .expect("Failed to get version after migration"), - 5 + 6 ); assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); assert!(index_exists(&db.conn, "idx_last_accessed")); + assert!(table_column_exists(&db.conn, "clipboard", "mime_types")); let count: i64 = db .conn @@ -1656,7 +1704,7 @@ mod tests { get_schema_version(&db2.conn).expect("Failed to get version"); assert_eq!(version_after_first, version_after_second); - assert_eq!(version_after_first, 5); + assert_eq!(version_after_first, 6); } #[test] @@ -1670,127 +1718,19 @@ mod tests { let test_data = b"Hello, World!"; let cursor = std::io::Cursor::new(test_data.to_vec()); - let id = db - .store_entry(cursor, 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None) + let _id = db + .store_entry( + cursor, + 100, + 1000, + None, + None, + DEFAULT_MAX_ENTRY_SIZE, + None, + None, + ) .expect("Failed to store entry"); - let content_hash: Option = db - .conn - .query_row( - "SELECT content_hash FROM clipboard WHERE id = ?1", - [id], - |row| row.get(0), - ) - .expect("Failed to get content_hash"); - - let last_accessed: Option = db - .conn - .query_row( - "SELECT last_accessed FROM clipboard WHERE id = ?1", - [id], - |row| row.get(0), - ) - .expect("Failed to get last_accessed"); - - assert!(content_hash.is_some(), "content_hash should be set"); - assert!(last_accessed.is_some(), "last_accessed should be set"); - } - - #[test] - fn test_last_accessed_updated_on_copy() { - let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); - let db_path = temp_dir.path().join("test_copy.db"); - let conn = Connection::open(&db_path).expect("Failed to open database"); - let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) - .expect("Failed to create database"); - - let test_data = b"Test content for copy"; - let cursor = std::io::Cursor::new(test_data.to_vec()); - let id_a = db - .store_entry(cursor, 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None) - .expect("Failed to store entry A"); - - let original_last_accessed: i64 = db - .conn - .query_row( - "SELECT last_accessed FROM clipboard WHERE id = ?1", - [id_a], - |row| row.get(0), - ) - .expect("Failed to get last_accessed"); - - std::thread::sleep(std::time::Duration::from_millis(1100)); - - let mut hasher = Fnv1aHasher::new(); - hasher.write(test_data); - let content_hash = hasher.finish() as i64; - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("Time went backwards") - .as_secs() as i64; - - db.conn - .execute( - "INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \ - VALUES (?1, ?2, ?3, ?4)", - params![test_data as &[u8], "text/plain", content_hash, now], - ) - .expect("Failed to insert entry B directly"); - - std::thread::sleep(std::time::Duration::from_millis(1100)); - - let (..) = db.copy_entry(id_a).expect("Failed to copy entry"); - - let new_last_accessed: i64 = db - .conn - .query_row( - "SELECT last_accessed FROM clipboard WHERE id = ?1", - [id_a], - |row| row.get(0), - ) - .expect("Failed to get updated last_accessed"); - - assert!( - new_last_accessed > original_last_accessed, - "last_accessed should be updated when copying an entry that is not the \ - most recent" - ); - } - - #[test] - fn test_migration_with_existing_columns_but_v0() { - let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); - let db_path = temp_dir.path().join("test_v0_with_cols.db"); - let conn = Connection::open(&db_path).expect("Failed to open database"); - - conn - .execute_batch( - "CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \ - AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT, content_hash \ - INTEGER, last_accessed INTEGER);", - ) - .expect("Failed to create table with all columns"); - - conn - .pragma_update(None, "user_version", 0i64) - .expect("Failed to set version to 0"); - - conn - .execute_batch( - "INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \ - VALUES (x'010203', 'text/plain', 12345, 1704067200)", - ) - .expect("Failed to insert data"); - - let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) - .expect("Failed to create database"); - - assert_eq!( - get_schema_version(&db.conn).expect("Failed to get version"), - 5 - ); - let count: i64 = db .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) @@ -1811,6 +1751,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store URI list"); @@ -1845,6 +1786,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store image"); @@ -1874,6 +1816,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store first"); let _id2 = db @@ -1885,6 +1828,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store second"); @@ -1921,6 +1865,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store"); } @@ -1943,6 +1888,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ); assert!(matches!(result, Err(StashError::EmptyOrTooLarge))); } @@ -1958,6 +1904,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ); assert!(matches!(result, Err(StashError::AllWhitespace))); } @@ -1975,6 +1922,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ); assert!(matches!(result, Err(StashError::TooLarge(5000000)))); } @@ -1991,6 +1939,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store"); @@ -2018,6 +1967,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store"); db.store_entry( @@ -2028,6 +1978,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store"); @@ -2056,6 +2007,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store"); } @@ -2136,6 +2088,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store"); @@ -2221,6 +2174,7 @@ mod tests { None, DEFAULT_MAX_ENTRY_SIZE, None, + None, ) .expect("Failed to store"); diff --git a/src/db/nonblocking.rs b/src/db/nonblocking.rs index d45d905..c1e57cd 100644 --- a/src/db/nonblocking.rs +++ b/src/db/nonblocking.rs @@ -17,6 +17,7 @@ impl AsyncClipboardDb { Self { db_path } } + #[expect(clippy::too_many_arguments)] pub async fn store_entry( &self, data: Vec, @@ -26,6 +27,7 @@ impl AsyncClipboardDb { min_size: Option, max_size: usize, content_hash: Option, + mime_types: Option>, ) -> Result { let path = self.db_path.clone(); blocking::unblock(move || { @@ -38,6 +40,7 @@ impl AsyncClipboardDb { min_size, max_size, content_hash, + mime_types.as_deref(), ) }) .await @@ -172,7 +175,16 @@ mod tests { let data = b"async test data"; let id = async_db - .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None) + .store_entry( + data.to_vec(), + 100, + 1000, + None, + None, + 5_000_000, + None, + None, + ) .await .expect("Failed to store entry"); @@ -201,7 +213,16 @@ mod tests { let data = b"expiring entry"; let id = async_db - .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None) + .store_entry( + data.to_vec(), + 100, + 1000, + None, + None, + 5_000_000, + None, + None, + ) .await .expect("Failed to store entry"); @@ -233,7 +254,16 @@ mod tests { let data = b"entry to expire"; let id = async_db - .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None) + .store_entry( + data.to_vec(), + 100, + 1000, + None, + None, + 5_000_000, + None, + None, + ) .await .expect("Failed to store entry"); @@ -280,12 +310,30 @@ mod tests { let data = b"clone test"; let id1 = async_db - .store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None) + .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) + .store_entry( + data.to_vec(), + 100, + 1000, + None, + None, + 5_000_000, + None, + None, + ) .await .expect("Failed with clone"); @@ -304,7 +352,7 @@ mod tests { 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) + db.store_entry(data, 100, 1000, None, None, 5_000_000, None, None) .await }) }) diff --git a/src/main.rs b/src/main.rs index e2602aa..fd8c8cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ use clap::{CommandFactory, Parser, Subcommand}; use humantime::parse_duration; use inquire::Confirm; +mod clipboard; mod commands; pub(crate) mod db; pub(crate) mod mime; From d9bee33aba7a6cdd289717edf28aa952b032c38b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Tue, 31 Mar 2026 12:47:31 +0300 Subject: [PATCH 108/121] stash: consolidate confirmation prompts; install color_eyre hook Signed-off-by: NotAShelf Change-Id: I7fb4ba67098f897849fc9b317c7fde646a6a6964 --- src/main.rs | 77 ++++++++++++++++++++++++++++++----------------------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/src/main.rs b/src/main.rs index fd8c8cc..53ed1c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,9 @@ +mod clipboard; +mod commands; +mod db; +mod mime; +mod multicall; + use std::{ env, io::{self, IsTerminal}, @@ -6,14 +12,14 @@ use std::{ }; use clap::{CommandFactory, Parser, Subcommand}; +use color_eyre::eyre; use humantime::parse_duration; use inquire::Confirm; -mod clipboard; -mod commands; -pub(crate) mod db; -pub(crate) mod mime; -mod multicall; +// 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::{ @@ -189,8 +195,20 @@ fn report_error( } } +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() -> color_eyre::eyre::Result<()> { +fn main() -> eyre::Result<()> { + color_eyre::install()?; + // Check if we're being called as a multicall binary let program_name = env::args().next().map(|s| { PathBuf::from(s) @@ -217,12 +235,18 @@ fn main() -> color_eyre::eyre::Result<()> { .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() { std::fs::create_dir_all(parent)?; @@ -300,10 +324,7 @@ fn main() -> color_eyre::eyre::Result<()> { let mut should_proceed = true; if ask { should_proceed = - Confirm::new("Are you sure you want to delete clipboard entries?") - .with_default(false) - .prompt() - .unwrap_or(false); + confirm("Are you sure you want to delete clipboard entries?"); if !should_proceed { log::info!("aborted by user."); @@ -361,12 +382,8 @@ fn main() -> color_eyre::eyre::Result<()> { ); let mut should_proceed = true; if ask { - should_proceed = Confirm::new( - "Are you sure you want to wipe all clipboard history?", - ) - .with_default(false) - .prompt() - .unwrap_or(false); + should_proceed = + confirm("Are you sure you want to wipe all clipboard history?"); if !should_proceed { log::info!("wipe command aborted by user."); } @@ -386,10 +403,7 @@ fn main() -> color_eyre::eyre::Result<()> { } else { "Are you sure you want to wipe ALL clipboard history?" }; - should_proceed = Confirm::new(message) - .with_default(false) - .prompt() - .unwrap_or(false); + should_proceed = confirm(message); if !should_proceed { log::info!("db wipe command aborted by user."); } @@ -398,7 +412,7 @@ fn main() -> color_eyre::eyre::Result<()> { if expired { match db.cleanup_expired() { Ok(count) => { - log::info!("Wiped {count} expired entries"); + log::info!("wiped {count} expired entries"); }, Err(e) => { log::error!("failed to wipe expired entries: {e}"); @@ -412,7 +426,7 @@ fn main() -> color_eyre::eyre::Result<()> { DbAction::Vacuum => { match db.vacuum() { Ok(()) => { - log::info!("Database optimized successfully"); + log::info!("database optimized successfully"); }, Err(e) => { log::error!("failed to vacuum database: {e}"); @@ -435,13 +449,10 @@ fn main() -> color_eyre::eyre::Result<()> { Some(Command::Import { r#type, ask }) => { let mut should_proceed = true; if ask { - should_proceed = Confirm::new( + should_proceed = confirm( "Are you sure you want to import clipboard data? This may \ overwrite existing entries.", - ) - .with_default(false) - .prompt() - .unwrap_or(false); + ); if !should_proceed { log::info!("import command aborted by user."); } From d643376cd7ca4cb30ffbf2a90fa13d11d0c9bcd0 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 1 Apr 2026 14:38:47 +0300 Subject: [PATCH 109/121] stash: deduplicate Fnv1aHasher; add derive for u64 wrapper Signed-off-by: NotAShelf Change-Id: Ic2886815721f6eefc66a8ddacd44fb286a6a6964 --- src/commands/watch.rs | 31 +------------ src/db/mod.rs | 33 ++------------ src/hash.rs | 101 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 4 files changed, 108 insertions(+), 58 deletions(-) create mode 100644 src/hash.rs diff --git a/src/commands/watch.rs b/src/commands/watch.rs index ddfdbea..542937d 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -1,4 +1,4 @@ -use std::{collections::BinaryHeap, io::Read, time::Duration}; +use std::{collections::BinaryHeap, hash::Hasher, io::Read, time::Duration}; use smol::Timer; use wl_clipboard_rs::{ @@ -15,36 +15,9 @@ use wl_clipboard_rs::{ use crate::{ clipboard::{self, ClipboardData, get_serving_pid}, db::{SqliteClipboardDb, nonblocking::AsyncClipboardDb}, + hash::Fnv1aHasher, }; -/// FNV-1a hasher for deterministic hashing across process runs. -/// Unlike `DefaultHasher` (`SipHash`), this produces stable hashes. -struct Fnv1aHasher { - state: u64, -} - -impl Fnv1aHasher { - const FNV_OFFSET: u64 = 0xCBF29CE484222325; - const FNV_PRIME: u64 = 0x100000001B3; - - fn new() -> Self { - Self { - state: Self::FNV_OFFSET, - } - } - - 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 - } -} - /// 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: diff --git a/src/db/mod.rs b/src/db/mod.rs index 441495f..f907b3b 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -11,6 +11,10 @@ use std::{ pub mod nonblocking; +use std::hash::Hasher; + +use crate::hash::Fnv1aHasher; + /// Cache for process scanning results to avoid expensive `/proc` reads on every /// store operation. TTL of 5 seconds balances freshness with performance. struct ProcessCache { @@ -66,35 +70,6 @@ impl ProcessCache { } } -/// FNV-1a hasher for deterministic hashing across process runs. -/// Unlike `DefaultHasher` (`SipHash` with random seed), this produces stable -/// hashes. -pub struct Fnv1aHasher { - state: u64, -} - -impl Fnv1aHasher { - const FNV_OFFSET: u64 = 0xCBF29CE484222325; - const FNV_PRIME: u64 = 0x100000001B3; - - pub fn new() -> Self { - Self { - state: Self::FNV_OFFSET, - } - } - - pub fn write(&mut self, bytes: &[u8]) { - for byte in bytes { - self.state ^= u64::from(*byte); - self.state = self.state.wrapping_mul(Self::FNV_PRIME); - } - } - - pub fn finish(&self) -> u64 { - self.state - } -} - use base64::prelude::*; use log::{debug, error, info, warn}; use mime_sniffer::MimeTypeSniffer; diff --git a/src/hash.rs b/src/hash.rs new file mode 100644 index 0000000..f017a51 --- /dev/null +++ b/src/hash.rs @@ -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()); + } +} diff --git a/src/main.rs b/src/main.rs index 53ed1c9..32c271d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod clipboard; mod commands; mod db; +mod hash; mod mime; mod multicall; From 77ac70f0d354bdfedf1898aeb8ba550f3ea95853 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 1 Apr 2026 16:23:08 +0300 Subject: [PATCH 110/121] db/nonblocking: add test-only imports for the `Fnv1aHasher` Signed-off-by: NotAShelf Change-Id: I66effd259c6654bd4efac2f4e6bc4e176a6a6964 --- src/db/nonblocking.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/db/nonblocking.rs b/src/db/nonblocking.rs index c1e57cd..d62e0dd 100644 --- a/src/db/nonblocking.rs +++ b/src/db/nonblocking.rs @@ -146,11 +146,12 @@ impl Clone for AsyncClipboardDb { #[cfg(test)] mod tests { - use std::collections::HashSet; + 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"); @@ -198,7 +199,7 @@ mod tests { .expect("Hash should exist"); // Calculate expected hash - let mut hasher = crate::db::Fnv1aHasher::new(); + let mut hasher = Fnv1aHasher::new(); hasher.write(data); let expected_hash = hasher.finish() as i64; From 9702e67599cdebb9ef0cdb03e80c27e89cdd8f4f Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 3 Apr 2026 13:55:58 +0300 Subject: [PATCH 111/121] build: get rid of the overzealous build script; leave symlinking to packagers Signed-off-by: NotAShelf Change-Id: I39c590f0a703ab71d3cb5a8df9b095a46a6a6964 --- build.rs | 65 ------------------------------------------------- nix/package.nix | 3 ++- 2 files changed, 2 insertions(+), 66 deletions(-) delete mode 100644 build.rs diff --git a/build.rs b/build.rs deleted file mode 100644 index b511acb..0000000 --- a/build.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::{env, fs, path::Path}; - -/// List of multicall symlinks to create (name, target) -const MULTICALL_LINKS: &[&str] = - &["stash-copy", "stash-paste", "wl-copy", "wl-paste"]; - -/// Wayland-specific symlinks that can be disabled separately -const WAYLAND_LINKS: &[&str] = &["wl-copy", "wl-paste"]; - -fn main() { - // OUT_DIR is something like .../target/debug/build//out - // We want .../target/debug or .../target/release - let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set"); - let bin_dir = Path::new(&out_dir) - .ancestors() - .nth(3) - .expect("Failed to find binary dir"); - - // Path to the main stash binary - let stash_bin = bin_dir.join("stash"); - - // Check for environment variables to disable symlinking - let disable_all_symlinks = env::var("STASH_NO_SYMLINKS").is_ok(); - let disable_wayland_symlinks = env::var("STASH_NO_WL_SYMLINKS").is_ok(); - - // Create symlinks for each multicall binary - for link in MULTICALL_LINKS { - if disable_all_symlinks { - println!("cargo:warning=Skipping symlink {link} (all symlinks disabled)"); - continue; - } - - if disable_wayland_symlinks && WAYLAND_LINKS.contains(link) { - println!( - "cargo:warning=Skipping symlink {link} (wayland symlinks disabled)" - ); - continue; - } - - let link_path = bin_dir.join(link); - // Remove existing symlink or file if present - let _ = fs::remove_file(&link_path); - #[cfg(unix)] - { - use std::os::unix::fs::symlink; - match symlink(&stash_bin, &link_path) { - Ok(()) => { - println!( - "cargo:warning=Created symlink: {} -> {}", - link_path.display(), - stash_bin.display() - ); - }, - Err(e) => { - println!( - "cargo:warning=Failed to create symlink {} -> {}: {}", - link_path.display(), - stash_bin.display(), - e - ); - }, - } - } - } -} diff --git a/nix/package.nix b/nix/package.nix index b068d4a..ba9573d 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -4,6 +4,7 @@ stdenv, mold, versionCheckHook, + useMold ? stdenv.isLinux, createSymlinks ? true, }: let pname = "stash"; @@ -55,7 +56,7 @@ in done ''; - env = lib.optionalAttrs (stdenv.isLinux && !stdenv.hostPlatform.isAarch) { + env = lib.optionalAttrs useMold { CARGO_LINKER = "clang"; CARGO_RUSTFLAGS = "-Clink-arg=-fuse-ld=${mold}/bin/mold"; }; From da9bf5ea3e3c9d08a52ef32a8a8f52bbcd0c3da5 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 3 Apr 2026 13:59:09 +0300 Subject: [PATCH 112/121] treewide: make logging format more consistent; make clipboard persistence opt-in Signed-off-by: NotAShelf Change-Id: I9092f93c29fcbe99c90483875f4acd0c6a6a6964 --- src/clipboard/persist.rs | 8 ++++---- src/commands/decode.rs | 2 +- src/commands/delete.rs | 2 +- src/commands/import.rs | 4 ++-- src/commands/list.rs | 4 ++-- src/commands/store.rs | 4 ++-- src/commands/watch.rs | 38 ++++++++++++++++++++++---------------- src/commands/wipe.rs | 2 +- src/db/mod.rs | 2 +- src/main.rs | 8 +++++++- src/multicall/wl_paste.rs | 2 +- 11 files changed, 44 insertions(+), 32 deletions(-) diff --git a/src/clipboard/persist.rs b/src/clipboard/persist.rs index df73fc8..a677f50 100644 --- a/src/clipboard/persist.rs +++ b/src/clipboard/persist.rs @@ -175,7 +175,7 @@ unsafe fn fork_and_serve(prepared: PreparedCopy) -> PersistenceResult<()> { pid => { // Parent process, store child PID for loop detection - log::debug!("Forked clipboard persistence process (pid: {pid})"); + log::debug!("forked clipboard persistence process (pid: {pid})"); SERVING_PID.store(pid, Ordering::SeqCst); Ok(()) }, @@ -185,18 +185,18 @@ unsafe fn fork_and_serve(prepared: PreparedCopy) -> PersistenceResult<()> { /// 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})"); + 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"); + log::debug!("clipboard persistence: serve completed normally"); }, Err(e) => { - log::error!("Clipboard persistence: serve failed: {e}"); + log::error!("clipboard persistence: serve failed: {e}"); exit(1); }, } diff --git a/src/commands/decode.rs b/src/commands/decode.rs index 8f414a1..f989a18 100644 --- a/src/commands/decode.rs +++ b/src/commands/decode.rs @@ -32,7 +32,7 @@ impl DecodeCommand for SqliteClipboardDb { // 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"); + 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) { diff --git a/src/commands/delete.rs b/src/commands/delete.rs index dd84989..ba358ad 100644 --- a/src/commands/delete.rs +++ b/src/commands/delete.rs @@ -9,7 +9,7 @@ pub trait DeleteCommand { impl DeleteCommand for SqliteClipboardDb { fn delete(&self, input: impl Read) -> Result { let deleted = self.delete_entries(input)?; - log::info!("Deleted {deleted} entries"); + log::info!("deleted {deleted} entries"); Ok(deleted) } } diff --git a/src/commands/import.rs b/src/commands/import.rs index 933cf88..4a3a2a7 100644 --- a/src/commands/import.rs +++ b/src/commands/import.rs @@ -55,11 +55,11 @@ impl ImportCommand for SqliteClipboardDb { imported += 1; } - log::info!("Imported {imported} records from TSV into SQLite database."); + 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}"); + log::info!("trimmed clipboard database to max_items = {max_items}"); Ok(()) } diff --git a/src/commands/list.rs b/src/commands/list.rs index 7d289ad..b3041e5 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -710,7 +710,7 @@ impl SqliteClipboardDb { .show(); }, Err(e) => { - log::error!("Failed to copy entry to clipboard: {e}"); + log::error!("failed to copy entry to clipboard: {e}"); let _ = Notification::new() .summary("Stash") .body(&format!("Failed to copy to clipboard: {e}")) @@ -719,7 +719,7 @@ impl SqliteClipboardDb { } }, Err(e) => { - log::error!("Failed to fetch entry {id}: {e}"); + log::error!("failed to fetch entry {id}: {e}"); let _ = Notification::new() .summary("Stash") .body(&format!("Failed to fetch entry: {e}")) diff --git a/src/commands/store.rs b/src/commands/store.rs index 0b7e23c..4495754 100644 --- a/src/commands/store.rs +++ b/src/commands/store.rs @@ -29,7 +29,7 @@ impl StoreCommand for SqliteClipboardDb { ) -> Result<(), crate::db::StashError> { if let Some("sensitive" | "clear") = state.as_deref() { self.delete_last()?; - log::info!("Entry deleted"); + log::info!("entry deleted"); } else { self.store_entry( input, @@ -41,7 +41,7 @@ impl StoreCommand for SqliteClipboardDb { None, // no pre-computed hash for CLI store None, // no mime types for CLI store )?; - log::info!("Entry stored"); + log::info!("entry stored"); } Ok(()) } diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 542937d..71cdc17 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -204,6 +204,7 @@ pub trait WatchCommand { mime_type_preference: &str, min_size: Option, max_size: usize, + persist: bool, ); } @@ -217,6 +218,7 @@ impl WatchCommand for SqliteClipboardDb { mime_type_preference: &str, min_size: Option, max_size: usize, + persist: bool, ) { let async_db = AsyncClipboardDb::new(self.db_path.clone()); log::info!( @@ -224,6 +226,10 @@ impl WatchCommand for SqliteClipboardDb { {mime_type_preference}" ); + if persist { + log::info!("clipboard persistence enabled"); + } + // Build expiration queue from existing entries let mut exp_queue = ExpirationQueue::new(); @@ -234,11 +240,11 @@ impl WatchCommand for SqliteClipboardDb { exp_queue.push(expires_at, id); } if !exp_queue.is_empty() { - log::info!("Loaded {} expirations from database", exp_queue.len()); + log::info!("loaded {} expirations from database", exp_queue.len()); } }, Err(e) => { - log::warn!("Failed to load expirations: {e}"); + log::warn!("failed to load expirations: {e}"); }, } @@ -277,7 +283,7 @@ impl WatchCommand for SqliteClipboardDb { match async_db.get_content_hash(id).await { Ok(hash) => hash, Err(e) => { - log::warn!("Failed to get content hash for entry {id}: {e}"); + log::warn!("failed to get content hash for entry {id}: {e}"); None }, }; @@ -285,9 +291,9 @@ impl WatchCommand for SqliteClipboardDb { 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}"); + log::warn!("failed to mark entry {id} as expired: {e}"); } else { - log::info!("Entry {id} marked as expired"); + log::info!("entry {id} marked as expired"); } // Check if this expired entry is currently in the clipboard @@ -315,12 +321,12 @@ impl WatchCommand for SqliteClipboardDb { .is_ok() { log::info!( - "Cleared clipboard containing expired entry {id}" + "cleared clipboard containing expired entry {id}" ); last_hash = None; // reset tracked hash } else { log::warn!( - "Failed to clear clipboard for expired entry {id}" + "failed to clear clipboard for expired entry {id}" ); } } @@ -337,7 +343,7 @@ impl WatchCommand for SqliteClipboardDb { 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}"); + log::error!("failed to read clipboard contents: {e}"); Timer::after(Duration::from_millis(500)).await; continue; } @@ -370,13 +376,13 @@ impl WatchCommand for SqliteClipboardDb { .await { Ok(id) => { - log::info!("Stored new clipboard entry (id: {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 get_serving_pid().is_none() { + if persist && get_serving_pid().is_none() { let clipboard_data = ClipboardData::new( buf_for_persist, mime_types_for_persist, @@ -393,12 +399,12 @@ impl WatchCommand for SqliteClipboardDb { .await; if let Err(e) = result { - log::debug!("Clipboard persistence failed: {e}"); + log::debug!("clipboard persistence failed: {e}"); } }) .detach(); } - } else { + } else if persist { log::trace!( "Already serving clipboard, skipping persistence fork" ); @@ -420,17 +426,17 @@ impl WatchCommand for SqliteClipboardDb { } }, Err(crate::db::StashError::ExcludedByApp(_)) => { - log::info!("Clipboard entry excluded by app filter"); + 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"); + log::info!("clipboard entry excluded by app filter"); last_hash = Some(current_hash); }, Err(e) => { - log::error!("Failed to store clipboard entry: {e}"); + log::error!("failed to store clipboard entry: {e}"); last_hash = Some(current_hash); }, } @@ -440,7 +446,7 @@ impl WatchCommand for SqliteClipboardDb { Err(e) => { let error_msg = e.to_string(); if !error_msg.contains("empty") { - log::error!("Failed to get clipboard contents: {e}"); + log::error!("failed to get clipboard contents: {e}"); } }, } diff --git a/src/commands/wipe.rs b/src/commands/wipe.rs index c0bb9ee..2126347 100644 --- a/src/commands/wipe.rs +++ b/src/commands/wipe.rs @@ -7,7 +7,7 @@ pub trait WipeCommand { impl WipeCommand for SqliteClipboardDb { fn wipe(&self) -> Result<(), StashError> { self.wipe_db()?; - log::info!("Database wiped"); + log::info!("database wiped"); Ok(()) } } diff --git a/src/db/mod.rs b/src/db/mod.rs index f907b3b..65eb097 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -875,7 +875,7 @@ impl ClipboardDb for SqliteClipboardDb { out .write_all(&contents) .map_err(|e| StashError::DecodeWrite(e.to_string().into()))?; - log::info!("Decoded entry with id {id}"); + log::info!("decoded entry with id {id}"); Ok(()) } diff --git a/src/main.rs b/src/main.rs index 32c271d..3075e20 100644 --- a/src/main.rs +++ b/src/main.rs @@ -160,6 +160,10 @@ enum Command { /// 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, }, } @@ -201,7 +205,7 @@ fn confirm(prompt: &str) -> bool { .with_default(false) .prompt() .unwrap_or_else(|e| { - log::error!("Confirmation prompt failed: {e}"); + log::error!("confirmation prompt failed: {e}"); false }) } @@ -477,6 +481,7 @@ fn main() -> eyre::Result<()> { Some(Command::Watch { expire_after, mime_type, + persist, }) => { db.watch( cli.max_dedupe_search, @@ -489,6 +494,7 @@ fn main() -> eyre::Result<()> { &mime_type, cli.min_size, cli.max_size, + persist, ) .await; }, diff --git a/src/multicall/wl_paste.rs b/src/multicall/wl_paste.rs index 4b828b5..5daa1fd 100644 --- a/src/multicall/wl_paste.rs +++ b/src/multicall/wl_paste.rs @@ -421,7 +421,7 @@ fn handle_regular_paste( 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}"); + log::debug!("auto-selecting MIME type: {best}"); PasteMimeType::Specific(best) } else { get_paste_mime_type(args.mime_type.as_deref()) From 5cb6c84f0897ad9e836b25a885723fd9aa4a166e Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 3 Apr 2026 14:07:45 +0300 Subject: [PATCH 113/121] docs: document clipboard persistence opt-in behaviour Signed-off-by: NotAShelf Change-Id: Ie0830d547ba0e4fcbd620290b3d314b16a6a6964 --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 42dd542..d29b4f4 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,25 @@ 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 Some commands take additional flags to modify Stash's behavior. See each From 75ca501e29fe74d5e0b35782b15c50d4e15ff31b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 3 Apr 2026 14:09:39 +0300 Subject: [PATCH 114/121] chore: bump dependencies Signed-off-by: NotAShelf Change-Id: Ibecde757e509c21ad612fc9b8e0fb5876a6a6964 --- Cargo.lock | 113 +++++++++++++++++++++++++++-------------------------- 1 file changed, 57 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8ea168d..3bc9a63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1137,12 +1137,13 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1150,9 +1151,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1163,9 +1164,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1177,15 +1178,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1197,15 +1198,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1263,9 +1264,9 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -1355,9 +1356,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.93" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ "once_cell", "wasm-bindgen", @@ -1394,9 +1395,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.183" +version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libredox" @@ -1435,9 +1436,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "litrs" @@ -1944,9 +1945,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -2677,9 +2678,9 @@ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -2687,18 +2688,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.8+spec-1.1.0" +version = "0.25.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" dependencies = [ "indexmap", "toml_datetime", @@ -2708,9 +2709,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.0+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow 1.0.1", ] @@ -2927,9 +2928,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.116" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -2940,9 +2941,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.116" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2950,9 +2951,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.116" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", @@ -2963,9 +2964,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.116" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] @@ -3430,15 +3431,15 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3447,9 +3448,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -3520,18 +3521,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -3541,9 +3542,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -3552,9 +3553,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -3563,9 +3564,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", From b0ee7f59a3804ae7672e622c1b924a9cfe091df1 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 3 Apr 2026 14:44:44 +0300 Subject: [PATCH 115/121] commands: deprecate plain `wipe` command in favor of `db wipe` Signed-off-by: NotAShelf Change-Id: I62dbcc00b6b79f160318f9704fab001b6a6a6964 --- src/commands/mod.rs | 1 - src/commands/wipe.rs | 13 ------------- src/main.rs | 32 ++------------------------------ 3 files changed, 2 insertions(+), 44 deletions(-) delete mode 100644 src/commands/wipe.rs diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 67e9950..86b8c99 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,4 +5,3 @@ pub mod list; pub mod query; pub mod store; pub mod watch; -pub mod wipe; diff --git a/src/commands/wipe.rs b/src/commands/wipe.rs deleted file mode 100644 index 2126347..0000000 --- a/src/commands/wipe.rs +++ /dev/null @@ -1,13 +0,0 @@ -use crate::db::{ClipboardDb, SqliteClipboardDb, 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(()) - } -} diff --git a/src/main.rs b/src/main.rs index 3075e20..f6359b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,9 +32,8 @@ use crate::{ query::QueryCommand, store::StoreCommand, watch::WatchCommand, - wipe::WipeCommand, }, - db::DEFAULT_MAX_ENTRY_SIZE, + db::{ClipboardDb, DEFAULT_MAX_ENTRY_SIZE}, }; #[derive(Parser)] @@ -124,16 +123,6 @@ enum Command { ask: bool, }, - /// Wipe all clipboard history - /// - /// DEPRECATED: Use `stash db wipe` instead - #[command(hide = true)] - Wipe { - /// Ask for confirmation before wiping - #[arg(long)] - ask: bool, - }, - /// Database management operations Db { #[command(subcommand)] @@ -380,23 +369,6 @@ fn main() -> eyre::Result<()> { } } }, - Some(Command::Wipe { ask }) => { - eprintln!( - "Warning: The 'stash wipe' command is deprecated. Use 'stash db \ - wipe' instead." - ); - let mut should_proceed = true; - if ask { - should_proceed = - confirm("Are you sure you want to wipe all clipboard history?"); - if !should_proceed { - log::info!("wipe command aborted by user."); - } - } - if should_proceed { - report_error(db.wipe(), "failed to wipe database"); - } - }, Some(Command::Db { action }) => { match action { @@ -424,7 +396,7 @@ fn main() -> eyre::Result<()> { }, } } else { - report_error(db.wipe(), "failed to wipe database"); + report_error(db.wipe_db(), "failed to wipe database"); } } }, From 32cf1936b6a2e148ec66a4330e7f11a199fe9784 Mon Sep 17 00:00:00 2001 From: Fazzi Date: Fri, 3 Apr 2026 20:08:31 +0100 Subject: [PATCH 116/121] nix: don't source old build script --- nix/package.nix | 1 - 1 file changed, 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index ba9573d..b27a730 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -19,7 +19,6 @@ (fs.fileFilter (file: builtins.any file.hasExt ["rs"]) (s + /src)) (s + /Cargo.lock) (s + /Cargo.toml) - (s + /build.rs) ]; }; From 20504a6e8ba2766fd9eeaba1798c3d584517da8a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 12 Apr 2026 22:42:19 +0300 Subject: [PATCH 117/121] ci: update flake inputs with dependabot; add cooldown to Rust deps Signed-off-by: NotAShelf Change-Id: Iac735278f32f323106314eb9d94159f06a6a6964 --- .github/dependabot.yaml | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index aa30540..4bbfe7c 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -1,13 +1,23 @@ version: 2 updates: - # Update Cargo deps - - package-ecosystem: cargo - directory: "/" - schedule: - interval: "weekly" - # 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 From 81683ded038add7374cb67d66b935cbc39421015 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 12 Apr 2026 22:44:53 +0300 Subject: [PATCH 118/121] nix: bump inputs Signed-off-by: NotAShelf Change-Id: I4ae530fc33a1d4033600801193a2566d6a6a6964 --- flake.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/flake.lock b/flake.lock index 62a0021..e50ffba 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1766194365, - "narHash": "sha256-4AFsUZ0kl6MXSm4BaQgItD0VGlEKR3iq7gIaL7TjBvc=", + "lastModified": 1775839657, + "narHash": "sha256-SPm9ck7jh3Un9nwPuMGbRU04UroFmOHjLP56T10MOeM=", "owner": "ipetkov", "repo": "crane", - "rev": "7d8ec2c71771937ab99790b45e6d9b93d15d9379", + "rev": "7cf72d978629469c4bd4206b95c402514c1f6000", "type": "github" }, "original": { @@ -17,11 +17,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1766309749, - "narHash": "sha256-3xY8CZ4rSnQ0NqGhMKAy5vgC+2IVK0NoVEzDoOh4DA4=", + "lastModified": 1775710090, + "narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "a6531044f6d0bef691ea18d4d4ce44d0daa6e816", + "rev": "4c1018dae018162ec878d42fec712642d214fdfa", "type": "github" }, "original": { From 84cf1b46adc94d66b4309f19b209d6247572cc07 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 12 Apr 2026 22:45:02 +0300 Subject: [PATCH 119/121] stash: add a note about Clap's multicall handling Signed-off-by: NotAShelf Change-Id: I4aec7f38ab24a6cd6310630f2169690c6a6a6964 --- src/main.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main.rs b/src/main.rs index f6359b1..f006d36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -204,6 +204,12 @@ 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() From ac7fbe293bbf9f80f494729584f965e012af0921 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 12 Apr 2026 22:55:56 +0300 Subject: [PATCH 120/121] build: bump dependencies Signed-off-by: NotAShelf Change-Id: If7985aa26f98a6aac1a994118df886046a6a6964 --- Cargo.lock | 68 +++++++++++++++++++++++++++++------------------------- Cargo.toml | 6 ++--- 2 files changed, 40 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3bc9a63..3c753bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,9 +90,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arc-swap" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ "rustversion", ] @@ -217,9 +217,9 @@ dependencies = [ [[package]] name = "async-signal" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ "async-io", "async-lock", @@ -373,9 +373,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.58" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "shlex", @@ -863,9 +863,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filedescriptor" @@ -1102,6 +1102,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "hashlink" version = "0.11.0" @@ -1264,12 +1270,12 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" -version = "2.13.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -1356,9 +1362,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ "once_cell", "wasm-bindgen", @@ -1401,9 +1407,9 @@ checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libredox" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "libc", ] @@ -1602,9 +1608,9 @@ dependencies = [ [[package]] name = "notify-rust" -version = "4.12.0" +version = "4.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" +checksum = "1b2c9bc1689653cfbc04400b8719f2562638ff9c545bbd48cc58c657a14526df" dependencies = [ "futures-lite", "log", @@ -1910,9 +1916,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "polling" @@ -2247,9 +2253,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -2697,9 +2703,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.10+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", "toml_datetime", @@ -2928,9 +2934,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -2941,9 +2947,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2951,9 +2957,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", @@ -2964,9 +2970,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] diff --git a/Cargo.toml b/Cargo.toml index bae39c5..e3467ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ name = "stash" # actual binary name for Nix, Cargo, etc. path = "src/main.rs" [dependencies] -arc-swap = { version = "1.9.0", optional = true } +arc-swap = { version = "1.9.1", optional = true } base64 = "0.22.1" blocking = "1.6.2" clap = { version = "4.6.0", features = [ "derive", "env" ] } @@ -27,10 +27,10 @@ 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.183" +libc = "0.2.184" log = "0.4.29" mime-sniffer = "0.1.3" -notify-rust = { version = "4.12.0", optional = true } +notify-rust = { version = "4.14.0", optional = true } ratatui = "0.30.0" regex = "1.12.3" rusqlite = { version = "0.39.0", features = [ "bundled" ] } From cd692ba00247cfebc1686a202ffd1505dfb95faf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:00:34 +0000 Subject: [PATCH 121/121] 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] --- .github/workflows/release.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 62bfdd3..62cfe82 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -40,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 @@ -98,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 }} @@ -120,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