diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 4bbfe7c..aa30540 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -1,23 +1,13 @@ 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 diff --git a/.github/workflows/nix-cache.yaml b/.github/workflows/nix-cache.yaml index 8936c67..9a9b4dc 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@v17 + - uses: cachix/cachix-action@v16 with: name: nyx authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 62cfe82..62bfdd3 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@v3 + uses: softprops/action-gh-release@v2 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@v3 + uses: softprops/action-gh-release@v2 with: files: ${{ matrix.name }} @@ -120,7 +120,7 @@ jobs: sha256sum stash-* > SHA256SUMS - name: Upload Checksums - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@v2 with: token: ${{ secrets.GITHUB_TOKEN }} files: SHA256SUMS diff --git a/Cargo.lock b/Cargo.lock index 113cb91..98e77f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,9 +34,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "anstream" -version = "1.0.0" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -49,15 +49,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.14" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" -version = "1.0.0" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] @@ -84,18 +84,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "arc-swap" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" -dependencies = [ - "rustversion", -] +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "async-broadcast" @@ -123,9 +114,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.14.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" dependencies = [ "async-task", "concurrent-queue", @@ -217,9 +208,9 @@ dependencies = [ [[package]] name = "async-signal" -version = "0.2.14" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" dependencies = [ "async-io", "async-lock", @@ -315,9 +306,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "block-buffer" @@ -352,15 +343,15 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" [[package]] name = "castaway" @@ -373,9 +364,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" dependencies = [ "find-msvc-tools", "shlex", @@ -395,9 +386,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.6.0" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", "clap_derive", @@ -415,9 +406,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", @@ -427,9 +418,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", @@ -439,9 +430,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "color-eyre" @@ -472,9 +463,9 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.5" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "compact_str" @@ -529,7 +520,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.10.0", "crossterm_winapi", "derive_more", "document-features", @@ -572,12 +563,12 @@ dependencies = [ [[package]] name = "ctrlc" -version = "3.5.2" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790" dependencies = [ "dispatch2", - "nix 0.31.2", + "nix 0.30.1", "windows-sys", ] @@ -685,27 +676,16 @@ dependencies = [ [[package]] name = "dispatch2" -version = "0.3.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.10.0", "block2", "libc", "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" @@ -762,9 +742,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "1.0.1" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" dependencies = [ "log", "regex", @@ -772,9 +752,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.10" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" dependencies = [ "anstream", "anstyle", @@ -801,9 +781,9 @@ dependencies = [ [[package]] name = "euclid" -version = "0.22.14" +version = "0.22.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" dependencies = [ "num-traits", ] @@ -863,9 +843,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "filedescriptor" @@ -880,9 +860,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" [[package]] name = "finl_unicode" @@ -920,62 +900,17 @@ 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" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -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" +version = "0.3.31" 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", -] +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-lite" @@ -990,46 +925,6 @@ 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" @@ -1059,23 +954,10 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi 5.3.0", + "r-efi", "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" @@ -1102,12 +984,6 @@ 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" @@ -1141,121 +1017,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "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" 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" @@ -1270,14 +1037,12 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" -version = "2.14.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.17.0", - "serde", - "serde_core", + "hashbrown 0.16.1", ] [[package]] @@ -1295,7 +1060,7 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.10.0", "crossterm", "dyn-clone", "unicode-segmentation", @@ -1304,9 +1069,9 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.12" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" dependencies = [ "darling", "indoc", @@ -1332,15 +1097,15 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.18" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jiff" -version = "0.2.23" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +checksum = "e67e8da4c49d6d9909fe03361f9b620f58898859f5c7aded68351e85e71ecf50" dependencies = [ "jiff-static", "log", @@ -1351,9 +1116,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.23" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +checksum = "e0c84ee7f197eca9a86c6fd6cb771e55eb991632f15f2bc3ca6ec838929e6e78" dependencies = [ "proc-macro2", "quote", @@ -1362,9 +1127,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -1372,9 +1137,9 @@ dependencies = [ [[package]] name = "kasuari" -version = "0.4.12" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" dependencies = [ "hashbrown 0.16.1", "portable-atomic", @@ -1393,32 +1158,27 @@ 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.185" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ + "bitflags 2.10.0", "libc", ] [[package]] name = "libsqlite3-sys" -version = "0.37.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" dependencies = [ "cc", "pkg-config", @@ -1427,11 +1187,11 @@ dependencies = [ [[package]] name = "line-clipping" -version = "0.3.7" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.10.0", ] [[package]] @@ -1440,12 +1200,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - [[package]] name = "litrs" version = "1.0.0" @@ -1478,9 +1232,9 @@ dependencies = [ [[package]] name = "mac-notification-sys" -version = "0.6.12" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" +checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" dependencies = [ "cc", "objc2", @@ -1500,9 +1254,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memmem" @@ -1519,22 +1273,6 @@ 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" @@ -1552,9 +1290,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "log", @@ -1568,7 +1306,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.10.0", "cfg-if", "cfg_aliases", "libc", @@ -1577,11 +1315,11 @@ dependencies = [ [[package]] name = "nix" -version = "0.31.2" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.10.0", "cfg-if", "cfg_aliases", "libc", @@ -1608,9 +1346,9 @@ dependencies = [ [[package]] name = "notify-rust" -version = "4.14.0" +version = "4.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b2c9bc1689653cfbc04400b8719f2562638ff9c545bbd48cc58c657a14526df" +checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" dependencies = [ "futures-lite", "log", @@ -1622,9 +1360,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-derive" @@ -1657,9 +1395,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" dependencies = [ "objc2-encode", ] @@ -1670,7 +1408,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.10.0", "dispatch2", "objc2", ] @@ -1687,7 +1425,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.10.0", "block2", "libc", "objc2", @@ -1705,9 +1443,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.4" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" @@ -1752,9 +1490,9 @@ dependencies = [ [[package]] name = "owo-colors" -version = "4.3.0" +version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" [[package]] name = "parking" @@ -1785,17 +1523,11 @@ 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.6" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" dependencies = [ "memchr", "ucd-trie", @@ -1803,9 +1535,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.6" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" dependencies = [ "pest", "pest_generator", @@ -1813,9 +1545,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.6" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" dependencies = [ "pest", "pest_meta", @@ -1826,9 +1558,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.6" +version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" dependencies = [ "pest", "sha2", @@ -1899,15 +1631,15 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.17" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "piper" -version = "0.2.5" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" dependencies = [ "atomic-waker", "fastrand", @@ -1916,9 +1648,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "polling" @@ -1936,49 +1668,30 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" [[package]] name = "portable-atomic-util" -version = "0.2.6" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" dependencies = [ "portable-atomic", ] -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - [[package]] name = "powerfmt" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "proc-macro-crate" -version = "3.5.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ "toml_edit", ] @@ -2003,18 +1716,18 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.39.2" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" dependencies = [ "memchr", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -2025,12 +1738,6 @@ 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" @@ -2066,7 +1773,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.10.0", "compact_str", "hashbrown 0.16.1", "indoc", @@ -2118,7 +1825,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.10.0", "hashbrown 0.16.1", "indoc", "instability", @@ -2137,7 +1844,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.10.0", ] [[package]] @@ -2165,9 +1872,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", @@ -2176,9 +1883,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "rsqlite-vfs" @@ -2192,11 +1899,11 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.39.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.10.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -2226,7 +1933,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", @@ -2241,9 +1948,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.23" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "scopeguard" @@ -2253,9 +1960,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "semver" -version = "1.0.28" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" @@ -2370,15 +2077,15 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "slab" -version = "0.4.12" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "smallvec" @@ -2415,19 +2122,11 @@ 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" dependencies = [ - "arc-swap", "base64", - "blocking", "clap", "clap-verbosity-flag", "color-eyre", @@ -2435,13 +2134,11 @@ dependencies = [ "ctrlc", "dirs", "env_logger", - "futures", "humantime", "imagesize", "inquire", "libc", "log", - "mime-sniffer", "notify-rust", "ratatui", "regex", @@ -2513,17 +2210,6 @@ 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" @@ -2538,12 +2224,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.27.0" +version = "3.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys", @@ -2578,7 +2264,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.11.0", + "bitflags 2.10.0", "fancy-regex", "filedescriptor", "finl_unicode", @@ -2682,44 +2368,34 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap", "toml_datetime", "toml_parser", - "winnow 1.0.1", + "winnow", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ - "winnow 1.0.1", + "winnow", ] [[package]] @@ -2766,9 +2442,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.23" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "sharded-slab", "thread_local", @@ -2800,13 +2476,13 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "uds_windows" -version = "1.2.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" dependencies = [ "memoffset", "tempfile", - "windows-sys", + "winapi", ] [[package]] @@ -2817,9 +2493,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-truncate" @@ -2838,30 +2514,6 @@ 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" -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" @@ -2870,12 +2522,12 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "atomic", - "getrandom 0.4.2", + "getrandom 0.3.4", "js-sys", "serde_core", "wasm-bindgen", @@ -2916,27 +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" -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" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", @@ -2947,9 +2590,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2957,9 +2600,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ "bumpalo", "proc-macro2", @@ -2970,52 +2613,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -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.15" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9" dependencies = [ "cc", "downcast-rs", @@ -3026,11 +2635,11 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.14" +version = "0.31.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.10.0", "log", "rustix", "wayland-backend", @@ -3039,11 +2648,11 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.12" +version = "0.32.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.10.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -3051,11 +2660,11 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.12" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.10.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -3064,20 +2673,20 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.10" +version = "0.31.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" dependencies = [ "proc-macro2", - "quick-xml 0.39.2", + "quick-xml 0.38.4", "quote", ] [[package]] name = "wayland-sys" -version = "0.31.11" +version = "0.31.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd" dependencies = [ "pkg-config", ] @@ -3313,109 +2922,18 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.15" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -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", -] +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "wl-clipboard-rs" @@ -3435,40 +2953,11 @@ dependencies = [ "wayland-protocols-wlr", ] -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - [[package]] name = "zbus" -version = "5.14.0" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" dependencies = [ "async-broadcast", "async-executor", @@ -3493,7 +2982,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys", - "winnow 0.7.15", + "winnow", "zbus_macros", "zbus_names", "zvariant", @@ -3501,9 +2990,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.14.0" +version = "5.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -3521,89 +3010,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "winnow 0.7.15", + "winnow", "zvariant", ] -[[package]] -name = "zerofrom" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" [[package]] name = "zvariant" -version = "5.10.0" +version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" dependencies = [ "endi", "enumflags2", "serde", - "winnow 0.7.15", + "winnow", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.10.0" +version = "5.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -3622,5 +3057,5 @@ dependencies = [ "quote", "serde", "syn 2.0.117", - "winnow 0.7.15", + "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 2aae609..a828573 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,44 +14,40 @@ name = "stash" # actual binary name for Nix, Cargo, etc. path = "src/main.rs" [dependencies] -arc-swap = { version = "1.9.1", optional = true } base64 = "0.22.1" -blocking = "1.6.2" -clap = { version = "4.6.0", features = [ "derive", "env" ] } +clap = { 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.2" +ctrlc = "3.5.1" dirs = "6.0.0" -env_logger = "0.11.10" +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.185" +libc = "0.2.182" log = "0.4.29" -mime-sniffer = "0.1.3" -notify-rust = { version = "4.14.0", optional = true } +notify-rust = { version = "4.12.0", optional = true } ratatui = "0.30.0" regex = "1.12.3" -rusqlite = { version = "0.39.0", features = [ "bundled" ] } +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.13.2" +unicode-segmentation = "1.12.0" unicode-width = "0.2.2" -wayland-client = { version = "0.31.14", features = [ "log" ], optional = true } -wayland-protocols-wlr = { version = "0.3.12", default-features = false, optional = true } +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] -futures = "0.3.32" -tempfile = "3.27.0" +tempfile = "3.26.0" [features] default = [ "notifications", "use-toplevel" ] notifications = [ "dep:notify-rust" ] -use-toplevel = [ "dep:arc-swap", "dep:wayland-client", "dep:wayland-protocols-wlr" ] +use-toplevel = [ "dep:wayland-client", "dep:wayland-protocols-wlr" ] [profile.release] lto = true diff --git a/README.md b/README.md index d29b4f4..775e223 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@

Features
- Installation | Usage | Motivation
+ Installation | Usage | Motivation Tips and Tricks
@@ -46,34 +46,21 @@ 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`](#watch-clipboard-for-changes-and-store-automatically) +- 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) -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. +See [usage section](#usage) for more details. ## Installation ### With Nix -Nix is the recommended way of downloading (and developing!) Stash. You can -install it using Nix flakes using `nix profile add` if on non-nixos or add Stash -as a flake input if you are on NixOS. +Nix 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 { @@ -104,8 +91,7 @@ If you want to give Stash a try before you switch to it, you may also run it one time with `nix run`. ```sh -# Run directly from the git repository; will be garbage collected -$ nix run github:NotAShelf/stash -- watch # start the watch daemon +nix run github:NotAShelf/stash -- watch # start the watch daemon ``` ### Without Nix @@ -124,23 +110,16 @@ releases are made when a version gets tagged, and are available under - Build and install from source with Cargo: ```bash - cargo install stash --locked + cargo install --git https://github.com/notashelf/stash ``` -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 -> [!IMPORTANT] +> [!NOTE] > It is not a priority to provide 1:1 backwards compatibility with Cliphist. -> While the interface is generally similar, Stash chooses to build upon +> While the interface is _almost_ identical, Stash chooses to build upon > Cliphist's design and extend existing design choices. See -> [Migrating from Cliphist](#migrating-from-cliphist) for more details. Refer to -> help text if confused. +> [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, @@ -296,7 +275,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 @@ -320,25 +299,6 @@ 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 @@ -594,8 +554,7 @@ 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. You can, of -course, wipe the database entirely if it has grown too large. +the database compact, especially after deleting many entries. ## Attributions diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..b511acb --- /dev/null +++ b/build.rs @@ -0,0 +1,65 @@ +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/flake.lock b/flake.lock index d437322..62a0021 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1776635034, - "narHash": "sha256-OEOJrT3ZfwbChzODfIH4GzlNTtOFuZFWPtW7jIeR8xU=", + "lastModified": 1766194365, + "narHash": "sha256-4AFsUZ0kl6MXSm4BaQgItD0VGlEKR3iq7gIaL7TjBvc=", "owner": "ipetkov", "repo": "crane", - "rev": "dc7496d8ea6e526b1254b55d09b966e94673750f", + "rev": "7d8ec2c71771937ab99790b45e6d9b93d15d9379", "type": "github" }, "original": { @@ -17,11 +17,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1775710090, - "narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=", + "lastModified": 1766309749, + "narHash": "sha256-3xY8CZ4rSnQ0NqGhMKAy5vgC+2IVK0NoVEzDoOh4DA4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "4c1018dae018162ec878d42fec712642d214fdfa", + "rev": "a6531044f6d0bef691ea18d4d4ce44d0daa6e816", "type": "github" }, "original": { diff --git a/nix/package.nix b/nix/package.nix index b27a730..b068d4a 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -4,7 +4,6 @@ stdenv, mold, versionCheckHook, - useMold ? stdenv.isLinux, createSymlinks ? true, }: let pname = "stash"; @@ -19,6 +18,7 @@ (fs.fileFilter (file: builtins.any file.hasExt ["rs"]) (s + /src)) (s + /Cargo.lock) (s + /Cargo.toml) + (s + /build.rs) ]; }; @@ -55,7 +55,7 @@ in done ''; - env = lib.optionalAttrs useMold { + env = lib.optionalAttrs (stdenv.isLinux && !stdenv.hostPlatform.isAarch) { CARGO_LINKER = "clang"; CARGO_RUSTFLAGS = "-Clink-arg=-fuse-ld=${mold}/bin/mold"; }; diff --git a/src/clipboard/mod.rs b/src/clipboard/mod.rs deleted file mode 100644 index 2648ce5..0000000 --- a/src/clipboard/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod persist; - -pub use persist::{ClipboardData, get_serving_pid, persist_clipboard}; diff --git a/src/clipboard/persist.rs b/src/clipboard/persist.rs deleted file mode 100644 index a677f50..0000000 --- a/src/clipboard/persist.rs +++ /dev/null @@ -1,262 +0,0 @@ -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/decode.rs b/src/commands/decode.rs index f989a18..8f414a1 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 ba358ad..dd84989 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 4a3a2a7..933cf88 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 b3041e5..03309aa 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -11,7 +11,6 @@ pub trait ListCommand { out: impl Write, preview_width: u32, include_expired: bool, - reverse: bool, ) -> Result<(), StashError>; } @@ -21,10 +20,9 @@ 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, reverse) + .list_entries(out, preview_width, include_expired) .map(|_| ()) } } @@ -54,12 +52,6 @@ struct TuiState { /// Whether we're currently in search input mode. search_mode: bool, - - /// Whether to show entries in reverse order (oldest first). - reverse: bool, - - /// ID of entry currently being copied. - copying_entry: Option, } impl TuiState { @@ -69,7 +61,6 @@ 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 { @@ -79,7 +70,6 @@ impl TuiState { window_size, preview_width, None, - reverse, )? } else { Vec::new() @@ -93,8 +83,6 @@ impl TuiState { dirty: false, search_query: String::new(), search_mode: false, - reverse, - copying_entry: None, }) } @@ -240,7 +228,6 @@ impl TuiState { self.window_size, preview_width, search, - self.reverse, )? } else { Vec::new() @@ -279,7 +266,6 @@ impl SqliteClipboardDb { &self, preview_width: u32, include_expired: bool, - reverse: bool, ) -> Result<(), StashError> { use std::io::stdout; @@ -330,13 +316,8 @@ impl SqliteClipboardDb { .unwrap_or(24); let initial_height = initial_height.max(1); - let mut tui = TuiState::new( - self, - include_expired, - initial_height, - preview_width, - reverse, - )?; + 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(); @@ -412,7 +393,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, _ => {}, @@ -682,51 +663,42 @@ impl SqliteClipboardDb { if actions.copy && let Some(&(id, ..)) = tui.selected_entry() { - if tui.copying_entry == Some(id) { - log::debug!( - "Skipping duplicate copy for entry {id} (already in \ - progress)" - ); - } else { - tui.copying_entry = Some(id); - match self.copy_entry(id) { - Ok((new_id, contents, mime)) => { - if new_id != id { - tui.dirty = true; - } - let opts = Options::new(); - let mime_type = match mime { - Some(ref m) if m == "text/plain" => MimeType::Text, - Some(ref m) => MimeType::Specific(m.clone().clone()), - None => MimeType::Text, - }; - let copy_result = opts - .copy(Source::Bytes(contents.clone().into()), mime_type); - match copy_result { - Ok(()) => { - let _ = Notification::new() - .summary("Stash") - .body("Copied entry to clipboard") - .show(); - }, - Err(e) => { - log::error!("failed to copy entry to clipboard: {e}"); - let _ = Notification::new() - .summary("Stash") - .body(&format!("Failed to copy to clipboard: {e}")) - .show(); - }, - } - }, - Err(e) => { - log::error!("failed to fetch entry {id}: {e}"); - let _ = Notification::new() - .summary("Stash") - .body(&format!("Failed to fetch entry: {e}")) - .show(); - }, - } - tui.copying_entry = None; + 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/commands/mod.rs b/src/commands/mod.rs index 86b8c99..67e9950 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,3 +5,4 @@ pub mod list; pub mod query; pub mod store; pub mod watch; +pub mod wipe; diff --git a/src/commands/store.rs b/src/commands/store.rs index 4495754..9e5a6c6 100644 --- a/src/commands/store.rs +++ b/src/commands/store.rs @@ -2,7 +2,6 @@ use std::io::Read; use crate::db::{ClipboardDb, SqliteClipboardDb}; -#[allow(clippy::too_many_arguments)] pub trait StoreCommand { fn store( &self, @@ -11,8 +10,6 @@ pub trait StoreCommand { max_items: u64, state: Option, excluded_apps: &[String], - min_size: Option, - max_size: usize, ) -> Result<(), crate::db::StashError>; } @@ -24,24 +21,18 @@ 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()?; - log::info!("entry deleted"); + log::info!("Entry deleted"); } else { self.store_entry( input, max_dedupe_search, max_items, Some(excluded_apps), - min_size, - max_size, - None, // no pre-computed hash for CLI store - None, // no mime types for CLI store )?; - log::info!("entry stored"); + log::info!("Entry stored"); } Ok(()) } diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 71cdc17..54dc803 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -1,4 +1,9 @@ -use std::{collections::BinaryHeap, hash::Hasher, io::Read, time::Duration}; +use std::{ + collections::{BinaryHeap, hash_map::DefaultHasher}, + hash::{Hash, Hasher}, + io::Read, + time::Duration, +}; use smol::Timer; use wl_clipboard_rs::{ @@ -12,11 +17,7 @@ use wl_clipboard_rs::{ }, }; -use crate::{ - clipboard::{self, ClipboardData, get_serving_pid}, - db::{SqliteClipboardDb, nonblocking::AsyncClipboardDb}, - hash::Fnv1aHasher, -}; +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. @@ -58,7 +59,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)>, @@ -96,16 +97,6 @@ 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. @@ -127,29 +118,21 @@ 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, 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)?; - +) -> 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, offered)); + 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 @@ -186,286 +169,235 @@ fn negotiate_mime_type( Seat::Unspecified, PasteMimeType::Specific(mime_str), )?; - - Ok((Box::new(reader) as Box, actual_mime, offered)) + Ok((Box::new(reader) as Box, actual_mime)) }, None => Err(wl_clipboard_rs::paste::Error::NoSeats), } } -#[allow(clippy::too_many_arguments)] pub trait WatchCommand { - async fn watch( + fn watch( &self, max_dedupe_search: u64, max_items: u64, excluded_apps: &[String], expire_after: Option, mime_type_preference: &str, - min_size: Option, - max_size: usize, - persist: bool, ); } impl WatchCommand for SqliteClipboardDb { - async fn watch( + fn watch( &self, max_dedupe_search: u64, max_items: u64, excluded_apps: &[String], expire_after: Option, mime_type_preference: &str, - min_size: Option, - max_size: usize, - persist: bool, ) { - let async_db = AsyncClipboardDb::new(self.db_path.clone()); - log::info!( - "Starting clipboard watch daemon with MIME type preference: \ - {mime_type_preference}" - ); + smol::block_on(async { + log::info!( + "Starting clipboard watch daemon with MIME type preference: \ + {mime_type_preference}" + ); - if persist { - log::info!("clipboard persistence enabled"); - } - - // Build expiration queue from existing entries - let mut exp_queue = ExpirationQueue::new(); - - // Load all expirations from database asynchronously - match async_db.load_all_expirations().await { - Ok(expirations) => { - for (expires_at, id) in expirations { - exp_queue.push(expires_at, id); - } - if !exp_queue.is_empty() { - log::info!("loaded {} expirations from database", exp_queue.len()); - } - }, - Err(e) => { - log::warn!("failed to load expirations: {e}"); - }, - } - - // We use hashes for comparison instead of storing full contents - let mut last_hash: Option = None; - let mut buf = Vec::with_capacity(4096); - - // Helper to hash clipboard contents using FNV-1a (deterministic across - // runs) - let hash_contents = |data: &[u8]| -> u64 { - let mut hasher = Fnv1aHasher::new(); - hasher.write(data); - hasher.finish() - }; - - // Initialize with current clipboard using smart MIME negotiation - if let Ok((mut reader, ..)) = negotiate_mime_type(mime_type_preference) { - buf.clear(); - if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() { - last_hash = Some(hash_contents(&buf)); - } - } - - let poll_interval = Duration::from_millis(500); - - loop { - // Process any pending expirations that are due now - if let Some(next_exp) = exp_queue.peek_next() { - let now = SqliteClipboardDb::now(); - if next_exp <= now { - // Expired entries to process - let expired_ids = exp_queue.pop_expired(now); - for id in expired_ids { - // Verify entry still exists and get its content_hash - let expired_hash: Option = - 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) + // 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)) { - let mut current_buf = Vec::new(); - if reader.read_to_end(&mut current_buf).is_ok() - && !current_buf.is_empty() + // Skip first entry which is already added + if exp_queue + .heap + .iter() + .any(|(_, existing_id)| *existing_id == row_id) { - 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}" + 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); + + // 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)); + } + } + + 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(); + + if let Some(stored_hash) = expired_hash { + // Mark as expired + self + .conn + .execute( + "UPDATE clipboard SET is_expired = 1 WHERE id = ?1", + [id], + ) + .ok(); + log::info!("Entry {id} marked as expired"); + + // Check if this expired entry is currently in the clipboard + if let Ok((mut reader, _)) = + negotiate_mime_type(mime_type_preference) + { + let mut current_buf = Vec::new(); + if reader.read_to_end(&mut current_buf).is_ok() + && !current_buf.is_empty() + { + let current_hash = hash_contents(¤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 + 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 (always run, even when expirations are - // pending) - match negotiate_mime_type(mime_type_preference) { - Ok((mut reader, _mime_type, _all_mimes)) => { - buf.clear(); - if let Err(e) = reader.read_to_end(&mut buf) { - log::error!("failed to read clipboard contents: {e}"); - Timer::after(Duration::from_millis(500)).await; - continue; - } + // 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; + } - // Only store if changed and not empty - if !buf.is_empty() { - let current_hash = hash_contents(&buf); - if last_hash != Some(current_hash) { - // Clone buf for the async operation since it needs 'static - let buf_clone = buf.clone(); - #[allow(clippy::cast_possible_wrap)] - let content_hash = Some(current_hash as i64); - - // Clone data for persistence after successful store - let buf_for_persist = buf.clone(); - let mime_types_for_persist = _all_mimes.clone(); - let selected_mime = _mime_type.clone(); - - match async_db - .store_entry( - buf_clone, + // 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[..], max_dedupe_search, max_items, - Some(excluded_apps.to_vec()), - min_size, - max_size, - content_hash, - Some(mime_types_for_persist.clone()), - ) - .await - { - Ok(id) => { - log::info!("stored new clipboard entry (id: {id})"); - last_hash = Some(current_hash); + Some(excluded_apps), + ) { + Ok(id) => { + log::info!("Stored new clipboard entry (id: {id})"); + last_hash = Some(current_hash); - // Persist clipboard: fork child to serve data - // This keeps the clipboard alive when source app closes - // Check if we're already serving to avoid duplicate processes - if persist && get_serving_pid().is_none() { - let clipboard_data = ClipboardData::new( - buf_for_persist, - mime_types_for_persist, - selected_mime, - ); - - // Validate and persist in blocking task - if clipboard_data.is_valid().is_ok() { - smol::spawn(async move { - // Use blocking task for fork operation - let result = smol::unblock(move || unsafe { - clipboard::persist_clipboard(clipboard_data) - }) - .await; - - if let Err(e) = result { - log::debug!("clipboard persistence failed: {e}"); - } - }) - .detach(); - } - } else if persist { - log::trace!( - "Already serving clipboard, skipping persistence fork" - ); - } - - // Set expiration if configured - if let Some(duration) = expire_after { - let expires_at = - SqliteClipboardDb::now() + duration.as_secs_f64(); - if let Err(e) = - async_db.set_expiration(id, expires_at).await - { - log::warn!( - "Failed to set expiration for entry {id}: {e}" - ); - } else { + // 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"); - 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}"); - } - }, - } + }, + 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; - } + // Normal poll interval (only if no expirations pending) + if exp_queue.peek_next().is_none() { + Timer::after(Duration::from_millis(500)).await; + } + } + }); } } -/// Given ordered offers and a preference, return the +/// 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)] @@ -568,145 +500,4 @@ 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/commands/wipe.rs b/src/commands/wipe.rs new file mode 100644 index 0000000..c0bb9ee --- /dev/null +++ b/src/commands/wipe.rs @@ -0,0 +1,13 @@ +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/db/mod.rs b/src/db/mod.rs index 65eb097..ca8ed37 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,186 +1,27 @@ use std::{ + collections::hash_map::DefaultHasher, env, fmt, fs, + hash::{Hash, Hasher}, io::{BufRead, BufReader, Read, Write}, - path::PathBuf, str, - sync::{Mutex, OnceLock}, - time::{Duration, Instant}, + sync::OnceLock, }; -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 { - 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().checked_sub(Self::TTL).unwrap(), /* 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().checked_sub(Self::TTL).unwrap(); - cache.excluded_app = None; - } - result - } else { - // Lock poisoned - fall back to uncached - get_recently_active_excluded_app_uncached(excluded_apps) - } - } -} - use base64::prelude::*; -use log::{debug, error, info, warn}; -use mime_sniffer::MimeTypeSniffer; +use log::{debug, error, warn}; use regex::Regex; use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; 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.")] 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), @@ -218,29 +59,12 @@ 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) - /// * `mime_types` - Optional list of all MIME types offered (for persistence) - #[allow(clippy::too_many_arguments)] fn store_entry( &self, input: impl Read, max_dedupe_search: u64, max_items: u64, excluded_apps: Option<&[String]>, - min_size: Option, - max_size: usize, - content_hash: Option, - mime_types: Option<&[String]>, ) -> Result; fn deduplicate_by_hash( @@ -256,7 +80,6 @@ pub trait ClipboardDb { out: impl Write, preview_width: u32, include_expired: bool, - reverse: bool, ) -> Result; fn decode_entry( &self, @@ -286,15 +109,11 @@ impl fmt::Display for Entry { } pub struct SqliteClipboardDb { - pub conn: Connection, - pub db_path: PathBuf, + pub conn: Connection, } impl SqliteClipboardDb { - pub fn new( - mut conn: Connection, - db_path: PathBuf, - ) -> Result { + pub fn new(mut conn: Connection) -> Result { conn .pragma_update(None, "synchronous", "OFF") .map_err(|e| { @@ -354,8 +173,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( @@ -519,36 +338,6 @@ 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(), @@ -559,21 +348,22 @@ impl SqliteClipboardDb { // focused window state. #[cfg(feature = "use-toplevel")] crate::wayland::init_wayland_state(); - Ok(Self { conn, db_path }) + Ok(Self { conn }) } } impl SqliteClipboardDb { - pub fn list_json( - &self, - include_expired: bool, - reverse: bool, - ) -> Result { - let builder = ListQueryBuilder::new(include_expired, reverse); - let query = builder.select_star_query(); + 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(&query) + .prepare(query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -620,40 +410,23 @@ impl ClipboardDb for SqliteClipboardDb { max_dedupe_search: u64, max_items: u64, excluded_apps: Option<&[String]>, - 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() { + if input.read_to_end(&mut buf).is_err() + || buf.is_empty() + || buf.len() > 5 * 1_000_000 + { return Err(StashError::EmptyOrTooLarge); } - - let size = buf.len(); - - if let Some(min) = min_size - && size < min - { - return Err(StashError::TooSmall(min)); - } - - if size > max_size { - return Err(StashError::TooLarge(max_size)); - } - if buf.iter().all(u8::is_ascii_whitespace) { return Err(StashError::AllWhitespace); } - // 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 - }); + // 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 = crate::mime::detect_mime(&buf); @@ -679,21 +452,11 @@ 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, \ - mime_types) VALUES (?1, ?2, ?3, ?4, ?5)", + "INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \ + VALUES (?1, ?2, ?3, ?4)", params![ buf, mime, @@ -701,8 +464,7 @@ impl ClipboardDb for SqliteClipboardDb { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("Time went backwards") - .as_secs() as i64, - mime_types_json + .as_secs() as i64 ], ) .map_err(|e| StashError::Store(e.to_string().into()))?; @@ -811,13 +573,17 @@ impl ClipboardDb for SqliteClipboardDb { mut out: impl Write, preview_width: u32, include_expired: bool, - reverse: bool, ) -> Result { - let builder = ListQueryBuilder::new(include_expired, reverse); - let query = builder.select_star_query(); + 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(&query) + .prepare(query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -875,7 +641,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(()) } @@ -975,14 +741,43 @@ impl SqliteClipboardDb { include_expired: bool, search: Option<&str>, ) -> Result { - let builder = - ListQueryBuilder::new(include_expired, false).with_search(search); - let query = builder.count_query(); + let search_pattern = search.map(|s| { + // Avoid backslash escaping issues + let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_"); + format!("%{escaped}%") + }); - 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)) + 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) @@ -1002,25 +797,47 @@ impl SqliteClipboardDb { limit: usize, preview_width: u32, search: Option<&str>, - reverse: bool, ) -> Result, StashError> { - let builder = ListQueryBuilder::new(include_expired, reverse) - .with_search(search) - .with_pagination(offset, limit); - let query = builder.select_star_query(); + 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) + .prepare(query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut rows = if let Some(pattern) = builder.search_param() { + let mut rows = if let Some(pattern) = search_pattern.as_deref() { stmt - .query(rusqlite::params![pattern]) + .query(rusqlite::params![limit as i64, offset as i64, pattern]) .map_err(|e| StashError::ListDecode(e.to_string().into()))? } else { stmt - .query([]) + .query(rusqlite::params![limit as i64, offset as i64]) .map_err(|e| StashError::ListDecode(e.to_string().into()))? }; @@ -1066,6 +883,20 @@ 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, @@ -1148,41 +979,31 @@ 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 { - // 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() - }?; + static REGEX_CACHE: OnceLock> = OnceLock::new(); + static CHECKED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); - // 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())); + if !CHECKED.load(std::sync::atomic::Ordering::Relaxed) { + CHECKED.store(true, std::sync::atomic::Ordering::Relaxed); - // Check cache first - if let Ok(cache) = cache.lock() - && let Some(regex) = cache.get(&pattern) - { - return Some(regex.clone()); + 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); } - // Compile and cache - Regex::new(&pattern).ok().inspect(|regex| { - if let Ok(mut cache) = cache.lock() { - cache.insert(pattern.clone(), regex.clone()); - } - }) + REGEX_CACHE.get().and_then(std::clone::Clone::clone) } pub fn extract_id(input: &str) -> Result { @@ -1224,14 +1045,26 @@ pub fn preview_entry(data: &[u8], mime: Option<&str>, width: u32) -> 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); - } + // For non-text data, use lossy conversion + let s = String::from_utf8_lossy(data); + truncate(s.trim(), width as usize, "…") +} - // 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 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() + } } pub fn size_str(size: usize) -> String { @@ -1283,8 +1116,7 @@ fn detect_excluded_app_activity(excluded_apps: &[String]) -> bool { } // Strategy 2: Check recently active processes (timing correlation) - // Use cached results to avoid expensive /proc scanning - if let Some(active_app) = ProcessCache::get(excluded_apps) { + if let Some(active_app) = get_recently_active_excluded_app(excluded_apps) { debug!("Clipboard excluded: recent activity from {active_app}"); return true; } @@ -1315,8 +1147,7 @@ fn get_focused_window_app() -> Option { } /// Check for recently active excluded apps using CPU and I/O activity. -/// This is the uncached version - use `ProcessCache::get()` for cached access. -fn get_recently_active_excluded_app_uncached( +fn get_recently_active_excluded_app( excluded_apps: &[String], ) -> Option { let proc_dir = std::path::Path::new("/proc"); @@ -1462,8 +1293,7 @@ mod tests { fn test_db() -> SqliteClipboardDb { let conn = Connection::open_in_memory().expect("Failed to open in-memory db"); - SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) - .expect("Failed to create test database") + SqliteClipboardDb::new(conn).expect("Failed to create test database") } fn get_schema_version(conn: &Connection) -> rusqlite::Result { @@ -1494,17 +1324,15 @@ 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, PathBuf::from(":memory:")) - .expect("Failed to create database"); + let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); assert_eq!( get_schema_version(&db.conn).expect("Failed to get schema version"), - 6 + 5 ); 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")); @@ -1546,18 +1374,16 @@ mod tests { assert_eq!(get_schema_version(&conn).expect("Failed to get version"), 0); - let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) - .expect("Failed to create database"); + let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); assert_eq!( get_schema_version(&db.conn) .expect("Failed to get version after migration"), - 6 + 5 ); 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 @@ -1590,18 +1416,16 @@ mod tests { ) .expect("Failed to insert data"); - let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) - .expect("Failed to create database"); + let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); assert_eq!( get_schema_version(&db.conn) .expect("Failed to get version after migration"), - 6 + 5 ); 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 @@ -1635,18 +1459,16 @@ mod tests { ) .expect("Failed to insert data"); - let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) - .expect("Failed to create database"); + let db = SqliteClipboardDb::new(conn).expect("Failed to create database"); assert_eq!( get_schema_version(&db.conn) .expect("Failed to get version after migration"), - 6 + 5 ); 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 @@ -1668,18 +1490,17 @@ mod tests { ) .expect("Failed to create table"); - let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) - .expect("Failed to create database"); + 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, db.db_path) - .expect("Failed to create database again"); + 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, 6); + assert_eq!(version_after_first, 5); } #[test] @@ -1687,25 +1508,130 @@ 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, PathBuf::from(":memory:")) - .expect("Failed to create 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, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, - ) + 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"), + 5 + ); + let count: i64 = db .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) @@ -1718,16 +1644,7 @@ 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, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, - ) + .store_entry(std::io::Cursor::new(data.to_vec()), 100, 1000, None) .expect("Failed to store URI list"); let mime: Option = db @@ -1753,16 +1670,7 @@ mod tests { 0x90, 0x77, 0x53, 0xDE, // CRC ]; let id = db - .store_entry( - std::io::Cursor::new(data.clone()), - 100, - 1000, - None, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, - ) + .store_entry(std::io::Cursor::new(data.clone()), 100, 1000, None) .expect("Failed to store image"); let (contents, mime): (Vec, Option) = db @@ -1783,28 +1691,10 @@ mod tests { let data = b"duplicate content"; let id1 = db - .store_entry( - std::io::Cursor::new(data.to_vec()), - 100, - 1000, - None, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, - ) + .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, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, - ) + .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 @@ -1837,10 +1727,6 @@ mod tests { 100, 3, // max 3 items None, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, ) .expect("Failed to store"); } @@ -1855,16 +1741,8 @@ 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, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, - ); + let result = + db.store_entry(std::io::Cursor::new(Vec::new()), 100, 1000, None); assert!(matches!(result, Err(StashError::EmptyOrTooLarge))); } @@ -1876,10 +1754,6 @@ mod tests { 100, 1000, None, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, ); assert!(matches!(result, Err(StashError::AllWhitespace))); } @@ -1889,33 +1763,15 @@ 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, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, - ); - assert!(matches!(result, Err(StashError::TooLarge(5000000)))); + 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, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, - ) + .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"); @@ -1939,10 +1795,6 @@ mod tests { 100, 1000, None, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, ) .expect("Failed to store"); db.store_entry( @@ -1950,10 +1802,6 @@ mod tests { 100, 1000, None, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, ) .expect("Failed to store"); @@ -1974,17 +1822,8 @@ 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, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, - ) - .expect("Failed to store"); + db.store_entry(std::io::Cursor::new(data.into_bytes()), 100, 1000, None) + .expect("Failed to store"); } db.wipe_db().expect("Failed to wipe"); @@ -2041,30 +1880,12 @@ 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(); let data = b"copy me"; let id = db - .store_entry( - std::io::Cursor::new(data.to_vec()), - 100, - 1000, - None, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, - ) + .store_entry(std::io::Cursor::new(data.to_vec()), 100, 1000, None) .expect("Failed to store"); let (returned_id, contents, mime) = @@ -2073,169 +1894,4 @@ 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, - None, - None, - ) - .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" - ); - } - - /// 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/db/nonblocking.rs b/src/db/nonblocking.rs deleted file mode 100644 index d62e0dd..0000000 --- a/src/db/nonblocking.rs +++ /dev/null @@ -1,375 +0,0 @@ -use std::path::PathBuf; - -use rusqlite::OptionalExtension; - -use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; - -/// Async wrapper for database operations that runs blocking operations -/// on a thread pool to avoid blocking the async runtime. Since -/// [`rusqlite::Connection`] is not Send, we store the database path and open a -/// new connection for each operation. -pub struct AsyncClipboardDb { - db_path: PathBuf, -} - -impl AsyncClipboardDb { - pub fn new(db_path: PathBuf) -> Self { - Self { db_path } - } - - #[expect(clippy::too_many_arguments)] - pub async fn store_entry( - &self, - data: Vec, - max_dedupe_search: u64, - max_items: u64, - excluded_apps: Option>, - min_size: Option, - max_size: usize, - content_hash: Option, - mime_types: Option>, - ) -> 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, - content_hash, - mime_types.as_deref(), - ) - }) - .await - } - - pub async fn set_expiration( - &self, - id: i64, - expires_at: f64, - ) -> Result<(), StashError> { - let path = self.db_path.clone(); - blocking::unblock(move || { - let db = Self::open_db_internal(&path)?; - db.set_expiration(id, expires_at) - }) - .await - } - - pub async fn load_all_expirations( - &self, - ) -> Result, 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(), - } - } -} - -#[cfg(test)] -mod tests { - use std::{collections::HashSet, hash::Hasher}; - - use tempfile::tempdir; - - use super::*; - use crate::hash::Fnv1aHasher; - - fn setup_test_db() -> (AsyncClipboardDb, tempfile::TempDir) { - let temp_dir = tempdir().expect("Failed to create temp dir"); - let db_path = temp_dir.path().join("test.db"); - - // Create initial database - { - let conn = - rusqlite::Connection::open(&db_path).expect("Failed to open database"); - crate::db::SqliteClipboardDb::new(conn, db_path.clone()) - .expect("Failed to create database"); - } - - let async_db = AsyncClipboardDb::new(db_path); - (async_db, temp_dir) - } - - #[test] - fn test_async_store_entry() { - smol::block_on(async { - let (async_db, _temp_dir) = setup_test_db(); - let data = b"async test data"; - - let id = async_db - .store_entry( - data.to_vec(), - 100, - 1000, - None, - None, - 5_000_000, - None, - None, - ) - .await - .expect("Failed to store entry"); - - assert!(id > 0, "Should return positive id"); - - // Verify it was stored by checking content hash - let hash = async_db - .get_content_hash(id) - .await - .expect("Failed to get hash") - .expect("Hash should exist"); - - // Calculate expected hash - let mut hasher = Fnv1aHasher::new(); - hasher.write(data); - let expected_hash = hasher.finish() as i64; - - assert_eq!(hash, expected_hash, "Stored hash should match"); - }); - } - - #[test] - fn test_async_set_expiration_and_load() { - smol::block_on(async { - let (async_db, _temp_dir) = setup_test_db(); - let data = b"expiring entry"; - - let id = async_db - .store_entry( - data.to_vec(), - 100, - 1000, - None, - None, - 5_000_000, - None, - None, - ) - .await - .expect("Failed to store entry"); - - let expires_at = 1234567890.5; - async_db - .set_expiration(id, expires_at) - .await - .expect("Failed to set expiration"); - - // Load all expirations - let expirations = async_db - .load_all_expirations() - .await - .expect("Failed to load expirations"); - - assert_eq!(expirations.len(), 1, "Should have one expiration"); - assert!( - (expirations[0].0 - expires_at).abs() < 0.001, - "Expiration time should match" - ); - assert_eq!(expirations[0].1, id, "Expiration id should match"); - }); - } - - #[test] - fn test_async_mark_expired() { - smol::block_on(async { - let (async_db, _temp_dir) = setup_test_db(); - let data = b"entry to expire"; - - let id = async_db - .store_entry( - data.to_vec(), - 100, - 1000, - None, - None, - 5_000_000, - None, - None, - ) - .await - .expect("Failed to store entry"); - - async_db - .mark_expired(id) - .await - .expect("Failed to mark as expired"); - - // Load expirations, this should be empty since entry is now marked - // expired - let expirations = async_db - .load_all_expirations() - .await - .expect("Failed to load expirations"); - - assert!( - expirations.is_empty(), - "Expired entries should not be loaded" - ); - }); - } - - #[test] - fn test_async_get_content_hash_not_found() { - smol::block_on(async { - let (async_db, _temp_dir) = setup_test_db(); - - let hash = async_db - .get_content_hash(999999) - .await - .expect("Should not fail on non-existent entry"); - - assert!(hash.is_none(), "Hash should be None for non-existent entry"); - }); - } - - #[test] - fn test_async_clone() { - let (async_db, _temp_dir) = setup_test_db(); - let cloned = async_db.clone(); - - smol::block_on(async { - // Both should work independently - let data = b"clone test"; - - let id1 = async_db - .store_entry( - data.to_vec(), - 100, - 1000, - None, - None, - 5_000_000, - None, - None, - ) - .await - .expect("Failed with original"); - - let id2 = cloned - .store_entry( - data.to_vec(), - 100, - 1000, - None, - None, - 5_000_000, - None, - None, - ) - .await - .expect("Failed with clone"); - - assert_ne!(id1, id2, "Should store as separate entries"); - }); - } - - #[test] - fn test_async_concurrent_operations() { - smol::block_on(async { - let (async_db, _temp_dir) = setup_test_db(); - - // Spawn multiple concurrent store operations - let futures: Vec<_> = (0..5) - .map(|i| { - let db = async_db.clone(); - let data = format!("concurrent test {}", i).into_bytes(); - smol::spawn(async move { - db.store_entry(data, 100, 1000, None, None, 5_000_000, None, None) - .await - }) - }) - .collect(); - - let results: Result, _> = 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"); - }); - } -} diff --git a/src/hash.rs b/src/hash.rs deleted file mode 100644 index f017a51..0000000 --- a/src/hash.rs +++ /dev/null @@ -1,101 +0,0 @@ -/// 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 f006d36..56c2170 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,3 @@ -mod clipboard; -mod commands; -mod db; -mod hash; -mod mime; -mod multicall; - use std::{ env, io::{self, IsTerminal}, @@ -13,27 +6,24 @@ use std::{ }; use clap::{CommandFactory, Parser, Subcommand}; -use color_eyre::eyre; use humantime::parse_duration; use inquire::Confirm; -// 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. +mod commands; +pub(crate) mod db; +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, - }, - db::{ClipboardDb, DEFAULT_MAX_ENTRY_SIZE}, +use crate::commands::{ + decode::DecodeCommand, + delete::DeleteCommand, + import::ImportCommand, + list::ListCommand, + query::QueryCommand, + store::StoreCommand, + watch::WatchCommand, + wipe::WipeCommand, }; #[derive(Parser)] @@ -52,16 +42,6 @@ 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)] @@ -98,10 +78,6 @@ 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 @@ -123,6 +99,16 @@ 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)] @@ -149,10 +135,6 @@ 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, }, } @@ -189,27 +171,9 @@ 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() -> eyre::Result<()> { - color_eyre::install()?; - +fn main() -> color_eyre::eyre::Result<()> { // 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() @@ -235,25 +199,19 @@ fn main() -> eyre::Result<()> { .filter_level(cli.verbosity.into()) .init(); - 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") - }, - }; + let db_path = cli.db_path.unwrap_or_else(|| { + dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("stash") + .join("db") + }); if let Some(parent) = db_path.parent() { std::fs::create_dir_all(parent)?; } let conn = rusqlite::Connection::open(&db_path)?; - let db = db::SqliteClipboardDb::new(conn, db_path)?; + let db = db::SqliteClipboardDb::new(conn)?; match cli.command { Some(Command::Store) => { @@ -268,26 +226,20 @@ fn main() -> eyre::Result<()> { &cli.excluded_apps, #[cfg(not(feature = "use-toplevel"))] &[], - cli.min_size, - cli.max_size, ), "failed to store entry", ); }, - Some(Command::List { - format, - expired, - reverse, - }) => { + Some(Command::List { format, expired }) => { match format.as_deref() { Some("tsv") => { report_error( - db.list(io::stdout(), cli.preview_width, expired, reverse), + db.list(io::stdout(), cli.preview_width, expired), "failed to list entries", ); }, Some("json") => { - match db.list_json(expired, reverse) { + match db.list_json(expired) { Ok(json) => { println!("{json}"); }, @@ -302,12 +254,12 @@ fn main() -> eyre::Result<()> { None => { if std::io::stdout().is_terminal() { report_error( - db.list_tui(cli.preview_width, expired, reverse), + db.list_tui(cli.preview_width, expired), "failed to list entries in TUI", ); } else { report_error( - db.list(io::stdout(), cli.preview_width, expired, reverse), + db.list(io::stdout(), cli.preview_width, expired), "failed to list entries", ); } @@ -324,7 +276,10 @@ fn main() -> eyre::Result<()> { let mut should_proceed = true; if ask { should_proceed = - confirm("Are you sure you want to delete clipboard entries?"); + Confirm::new("Are you sure you want to delete clipboard entries?") + .with_default(false) + .prompt() + .unwrap_or(false); if !should_proceed { log::info!("aborted by user."); @@ -375,6 +330,27 @@ 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::new( + "Are you sure you want to wipe all clipboard history?", + ) + .with_default(false) + .prompt() + .unwrap_or(false); + 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 { @@ -386,7 +362,10 @@ fn main() -> eyre::Result<()> { } else { "Are you sure you want to wipe ALL clipboard history?" }; - should_proceed = confirm(message); + should_proceed = Confirm::new(message) + .with_default(false) + .prompt() + .unwrap_or(false); if !should_proceed { log::info!("db wipe command aborted by user."); } @@ -395,21 +374,21 @@ fn main() -> eyre::Result<()> { if expired { match db.cleanup_expired() { Ok(count) => { - log::info!("wiped {count} expired entries"); + log::info!("Wiped {} expired entries", count); }, Err(e) => { log::error!("failed to wipe expired entries: {e}"); }, } } else { - report_error(db.wipe_db(), "failed to wipe database"); + report_error(db.wipe(), "failed to wipe database"); } } }, 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}"); @@ -419,7 +398,7 @@ fn main() -> eyre::Result<()> { DbAction::Stats => { match db.stats() { Ok(stats) => { - println!("{stats}"); + println!("{}", stats); }, Err(e) => { log::error!("failed to get database stats: {e}"); @@ -432,10 +411,13 @@ fn main() -> eyre::Result<()> { Some(Command::Import { r#type, ask }) => { let mut should_proceed = true; if ask { - should_proceed = confirm( + should_proceed = Confirm::new( "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."); } @@ -459,7 +441,6 @@ fn main() -> eyre::Result<()> { Some(Command::Watch { expire_after, mime_type, - persist, }) => { db.watch( cli.max_dedupe_search, @@ -470,11 +451,7 @@ fn main() -> eyre::Result<()> { &[], expire_after, &mime_type, - cli.min_size, - cli.max_size, - persist, - ) - .await; + ); }, None => { diff --git a/src/multicall/wl_paste.rs b/src/multicall/wl_paste.rs index 5daa1fd..af686c4 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() { - // If no MIME type, check if content is valid UTF-8 - std::str::from_utf8(&buf).is_ok() - } else { + 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 diff --git a/src/wayland/mod.rs b/src/wayland/mod.rs index 38f6ff5..9cfa765 100644 --- a/src/wayland/mod.rs +++ b/src/wayland/mod.rs @@ -1,9 +1,8 @@ use std::{ collections::HashMap, - sync::{Arc, LazyLock, Mutex}, + sync::{LazyLock, Mutex}, }; -use arc_swap::ArcSwapOption; use log::debug; use wayland_client::{ Connection as WaylandConnection, @@ -18,7 +17,7 @@ use wayland_protocols_wlr::foreign_toplevel::v1::client::{ zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1}, }; -static FOCUSED_APP: ArcSwapOption = ArcSwapOption::const_empty(); +static FOCUSED_APP: Mutex> = Mutex::new(None); static TOPLEVEL_APPS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); @@ -33,11 +32,12 @@ pub fn init_wayland_state() { /// Get the currently focused window application name using Wayland protocols pub fn get_focused_window_app() -> Option { - // Load the focused app using lock-free arc-swap - let focused = FOCUSED_APP.load(); - if let Some(app) = focused.as_ref() { + // 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.to_string()); + return Some(app.clone()); } debug!("No focused window detection method worked"); @@ -152,11 +152,12 @@ impl Dispatch for AppState { }) { debug!("Toplevel activated"); // Update focused app to the `app_id` of this handle - if let Ok(apps) = TOPLEVEL_APPS.lock() + 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_APP.store(Some(Arc::new(app_id.clone()))); + *focused = Some(app_id.clone()); } } },