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/.rustfmt.toml b/.rustfmt.toml index 9d5c77e..324bf8b 100644 --- a/.rustfmt.toml +++ b/.rustfmt.toml @@ -1,26 +1,26 @@ -condense_wildcard_suffixes = true +condense_wildcard_suffixes = true doc_comment_code_block_width = 80 -edition = "2024" # Keep in sync with Cargo.toml. +edition = "2024" # Keep in sync with Cargo.toml. enum_discrim_align_threshold = 60 -force_explicit_abi = false -force_multiline_blocks = true -format_code_in_doc_comments = true -format_macro_matchers = true -format_strings = true -group_imports = "StdExternalCrate" -hex_literal_case = "Upper" -imports_granularity = "Crate" -imports_layout = "HorizontalVertical" -inline_attribute_width = 60 -match_block_trailing_comma = true -max_width = 80 -newline_style = "Unix" -normalize_comments = true -normalize_doc_attributes = true -overflow_delimited_expr = true +force_explicit_abi = false +force_multiline_blocks = true +format_code_in_doc_comments = true +format_macro_matchers = true +format_strings = true +group_imports = "StdExternalCrate" +hex_literal_case = "Upper" +imports_granularity = "Crate" +imports_layout = "HorizontalVertical" +inline_attribute_width = 60 +match_block_trailing_comma = true +max_width = 80 +newline_style = "Unix" +normalize_comments = true +normalize_doc_attributes = true +overflow_delimited_expr = true struct_field_align_threshold = 60 -tab_spaces = 2 -unstable_features = true -use_field_init_shorthand = true -use_try_shorthand = true -wrap_comments = true +tab_spaces = 2 +unstable_features = true +use_field_init_shorthand = true +use_try_shorthand = true +wrap_comments = true diff --git a/.taplo.toml b/.taplo.toml deleted file mode 100644 index fae0c57..0000000 --- a/.taplo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[formatting] -align_entries = true -column_width = 110 -compact_arrays = false -reorder_inline_tables = false -reorder_keys = true - -[[rule]] -include = [ "**/Cargo.toml" ] -keys = [ "package" ] - -[rule.formatting] -reorder_keys = false diff --git a/Cargo.lock b/Cargo.lock index 113cb91..d3b5980 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", @@ -212,14 +203,14 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.114", ] [[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", @@ -247,7 +238,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.114", ] [[package]] @@ -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.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" dependencies = [ "clap_builder", "clap_derive", @@ -415,9 +406,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" dependencies = [ "anstream", "anstyle", @@ -427,21 +418,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.114", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[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", ] @@ -601,7 +592,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.114", ] [[package]] @@ -612,7 +603,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn 2.0.117", + "syn 2.0.114", ] [[package]] @@ -623,9 +614,9 @@ checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", ] @@ -649,7 +640,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.114", ] [[package]] @@ -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" @@ -757,14 +737,14 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.114", ] [[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" @@ -1135,127 +1011,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "humantime" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" - -[[package]] -name = "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 +1031,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]] @@ -1291,11 +1050,11 @@ dependencies = [ [[package]] name = "inquire" -version = "0.9.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" +checksum = "ae51d5da01ce7039024fbdec477767c102c454dbdb09d4e2a432ece705b1b25d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.10.0", "crossterm", "dyn-clone", "unicode-segmentation", @@ -1304,15 +1063,15 @@ 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", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.114", ] [[package]] @@ -1332,15 +1091,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,20 +1110,20 @@ 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", - "syn 2.0.117", + "syn 2.0.114", ] [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -1372,9 +1131,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 +1152,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.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[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,24 +1181,18 @@ 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]] name = "linux-raw-sys" -version = "0.12.1" +version = "0.11.0" 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" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litrs" @@ -1478,9 +1226,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 +1248,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 +1267,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 +1284,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 +1300,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 +1309,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 +1340,9 @@ dependencies = [ [[package]] name = "notify-rust" -version = "4.14.0" +version = "4.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b2c9bc1689653cfbc04400b8719f2562638ff9c545bbd48cc58c657a14526df" +checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400" dependencies = [ "futures-lite", "log", @@ -1622,9 +1354,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-derive" @@ -1634,7 +1366,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.114", ] [[package]] @@ -1657,9 +1389,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 +1402,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 +1419,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 +1437,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 +1484,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 +1517,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 +1529,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,22 +1539,22 @@ 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", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.114", ] [[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", @@ -1885,7 +1611,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.114", ] [[package]] @@ -1899,15 +1625,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 +1642,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 +1662,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 +1710,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.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ "proc-macro2", ] @@ -2025,12 +1732,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 +1767,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 +1819,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 +1838,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]] @@ -2153,9 +1854,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", @@ -2165,9 +1866,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 +1877,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 +1893,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", @@ -2222,11 +1923,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", @@ -2241,9 +1942,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 +1954,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" @@ -2284,7 +1985,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.114", ] [[package]] @@ -2308,7 +2009,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.114", ] [[package]] @@ -2370,15 +2071,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 +2116,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" +version = "0.3.4" dependencies = [ - "arc-swap", "base64", - "blocking", "clap", "clap-verbosity-flag", "color-eyre", @@ -2435,13 +2128,10 @@ dependencies = [ "ctrlc", "dirs", "env_logger", - "futures", - "humantime", "imagesize", "inquire", "libc", "log", - "mime-sniffer", "notify-rust", "ratatui", "regex", @@ -2488,7 +2178,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.114", ] [[package]] @@ -2504,26 +2194,15 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", "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 +2217,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.27.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys", @@ -2578,7 +2257,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.11.0", + "bitflags 2.10.0", "fancy-regex", "filedescriptor", "finl_unicode", @@ -2638,7 +2317,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.114", ] [[package]] @@ -2649,7 +2328,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.114", ] [[package]] @@ -2663,9 +2342,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", "libc", @@ -2678,48 +2357,38 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.7" 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", -] +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" [[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]] @@ -2741,7 +2410,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.114", ] [[package]] @@ -2766,9 +2435,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,26 +2469,26 @@ 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]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[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 +2507,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 +2515,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", @@ -2923,20 +2568,11 @@ dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -2947,9 +2583,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2957,65 +2593,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.114", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" 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 +2628,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 +2641,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 +2653,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 +2666,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", ] @@ -3230,7 +2832,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.114", ] [[package]] @@ -3241,7 +2843,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.114", ] [[package]] @@ -3313,18 +2915,9 @@ 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", ] @@ -3334,88 +2927,6 @@ name = "wit-bindgen" version = "0.51.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", -] [[package]] name = "wl-clipboard-rs" @@ -3435,40 +2946,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 +2975,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys", - "winnow 0.7.15", + "winnow", "zbus_macros", "zbus_names", "zvariant", @@ -3501,14 +2983,14 @@ 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", "quote", - "syn 2.0.117", + "syn 2.0.114", "zbus_names", "zvariant", "zvariant_utils", @@ -3521,94 +3003,40 @@ 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", "quote", - "syn 2.0.117", + "syn 2.0.114", "zvariant_utils", ] @@ -3621,6 +3049,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", - "winnow 0.7.15", + "syn 2.0.114", + "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 2aae609..94ec687 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,59 +1,56 @@ [package] -name = "stash-clipboard" -description = "Wayland clipboard manager with fast persistent history and multi-media support" -version = "0.3.6" -edition = "2024" -authors = [ "NotAShelf " ] -license = "MPL-2.0" -readme = true -repository = "https://github.com/notashelf/stash" -rust-version = "1.91.0" +name = "stash-clipboard" +description = "Wayland clipboard manager with fast persistent history and multi-media support" +version = "0.3.4" +edition = "2024" +authors = ["NotAShelf "] +license = "MPL-2.0" +readme = true +repository = "https://github.com/notashelf/stash" +rust-version = "1.90" [[bin]] -name = "stash" # actual binary name for Nix, Cargo, etc. +name = "stash" # actual binary name for Nix, Cargo, etc. path = "src/main.rs" [dependencies] -arc-swap = { version = "1.9.1", optional = true } -base64 = "0.22.1" -blocking = "1.6.2" -clap = { version = "4.6.0", features = [ "derive", "env" ] } -clap-verbosity-flag = "3.0.4" -color-eyre = "0.6.5" -crossterm = "0.29.0" -ctrlc = "3.5.2" -dirs = "6.0.0" -env_logger = "0.11.10" -humantime = "2.3.0" -imagesize = "0.14.0" -inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] } -libc = "0.2.185" -log = "0.4.29" -mime-sniffer = "0.1.3" -notify-rust = { version = "4.14.0", optional = true } -ratatui = "0.30.0" -regex = "1.12.3" -rusqlite = { version = "0.39.0", features = [ "bundled" ] } -serde = { version = "1.0.228", features = [ "derive" ] } -serde_json = "1.0.149" -smol = "2.0.2" -thiserror = "2.0.18" -unicode-segmentation = "1.13.2" -unicode-width = "0.2.2" -wayland-client = { version = "0.31.14", features = [ "log" ], optional = true } -wayland-protocols-wlr = { version = "0.3.12", default-features = false, optional = true } -wl-clipboard-rs = "0.9.3" +base64 = "0.22.1" +clap = { version = "4.5.54", features = ["derive", "env"] } +clap-verbosity-flag = "3.0.4" +color-eyre = "0.6.5" +crossterm = "0.29.0" +ctrlc = "3.5.1" +dirs = "6.0.0" +env_logger = "0.11.8" +imagesize = "0.14.0" +inquire = { version = "0.9.2", default-features = false, features = [ + "crossterm", +] } +libc = "0.2.180" +log = "0.4.29" +notify-rust = { version = "4.11.7", optional = true } +ratatui = "0.30.0" +regex = "1.12.2" +rusqlite = { version = "0.38.0", features = ["bundled"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +smol = "2.0.2" +thiserror = "2.0.18" +unicode-segmentation = "1.12.0" +unicode-width = "0.2.2" +wayland-client = { version = "0.31.12", features = ["log"], optional = true } +wayland-protocols-wlr = { version = "0.3.10", default-features = false, optional = true } +wl-clipboard-rs = "0.9.3" [dev-dependencies] -futures = "0.3.32" -tempfile = "3.27.0" +tempfile = "3.18.0" [features] -default = [ "notifications", "use-toplevel" ] -notifications = [ "dep:notify-rust" ] -use-toplevel = [ "dep:arc-swap", "dep:wayland-client", "dep:wayland-protocols-wlr" ] +default = ["notifications", "use-toplevel"] +notifications = ["dep:notify-rust"] +use-toplevel = ["dep:wayland-client", "dep:wayland-protocols-wlr"] [profile.release] -lto = true opt-level = "z" -strip = true +strip = true +lto = true diff --git a/README.md b/README.md index d29b4f4..c3fd56c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@
- Lightweight & feature-rich Wayland clipboard "manager" with fast persistent history and + Lightweight Wayland clipboard "manager" with fast persistent history and robust multi-media support. Stores and previews clipboard entries (text, images) on the clipboard with a neat TUI and advanced scripting capabilities.
@@ -28,7 +28,7 @@

Features
- Installation | Usage | Motivation
+ Installation | Usage
Tips and Tricks
@@ -45,35 +45,21 @@ with many features such as but not necessarily limited to: - Import clipboard history from TSV (e.g., from `cliphist list`) - Image preview (shows dimensions and format) - Text previews with customizable width -- De-duplication, whitespace prevention and entry limit control -- Automatic clipboard monitoring with - [`stash watch`](#watch-clipboard-for-changes-and-store-automatically) - - Configurable auto-expiry of old entries in watch mode as a safety buffer +- Deduplication and entry limit control +- Automatic clipboard monitoring with `stash watch` - 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 +90,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 +109,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, @@ -163,7 +141,7 @@ Commands: list List clipboard history decode Decode and output clipboard entry by id delete Delete clipboard entry by id (if numeric), or entries matching a query (if not). Numeric arguments are treated as ids. Use --type to specify explicitly - db Database management operations + wipe Wipe all clipboard history import Import clipboard data from stdin (default: TSV format) watch Start a process to watch clipboard for changes and store automatically help Print this message or the help of the given subcommand(s) @@ -176,7 +154,7 @@ Options: --preview-width Maximum width (in characters) for clipboard entry previews in list output [default: 100] --db-path - Path to the `SQLite` clipboard database file [env: STASH_DB_PATH=] + Path to the `SQLite` clipboard database file --excluded-apps Application names to exclude from clipboard history [env: STASH_EXCLUDED_APPS=] --ask @@ -210,11 +188,6 @@ and copying/deleting entries. This behaviour is EXCLUSIVE TO TTYs and Stash will display entries in Cliphist-compatible TSV format in Bash scripts. You may also enforce the output format with `stash list --format `. -You may also view your clipboard _with the addition of expired entries_, i.e., -entries that have reached their TTL and are marked as expired, using the -`--expired` flag as `stash list --expired`. Expired entries are not cleaned up -when using this flag, allowing you to inspect them before running cleanup. - ### Decode an entry by ID ```bash @@ -246,33 +219,10 @@ stash delete --type id < ids.txt ### Wipe all entries -> [!WARNING] -> This command is deprecated, and will be removed in v0.4.0. Use `stash db wipe` -> instead. - ```bash stash wipe ``` -### Database management - -Stash provides a `db` subcommand for database maintenance operations: - -```bash -stash db wipe [--expired] [--ask] -stash db vacuum -stash db stats -``` - -- `stash db wipe`: Remove all entries from the database. Use `--expired` to only - wipe expired entries instead of all entries. Requires `--ask` confirmation by - default. -- `stash db vacuum`: Optimize the database using SQLite's VACUUM command, - reclaiming space and improving performance. -- `stash db stats`: Display database statistics including total/active/expired - entry counts, storage size, and page information. This is provided purely for - convenience and the rule of the cool. - ### Watch clipboard for changes and store automatically ```bash @@ -285,59 +235,13 @@ automatically. This is designed as an alternative to shelling out to premade Systemd service in `contrib/`. Packagers are encouraged to vendor the service unless adding their own. -#### Automatic Clipboard Clearing on Expiration - -When `stash watch` is running and a clipboard entry expires, Stash will detect -if the current clipboard still contains that expired content and automatically -clear it. This prevents stale data from remaining in your clipboard after an -entry has expired from history. - -> [!NOTE] -> This behavior only applies when the watch daemon is actively running. Manual -> expiration or deletion of entries will not clear the clipboard. - -#### MIME Type Preference for Watch - -`stash watch` supports a `--mime-type` (short `-t`) option that lets you -prioritise which MIME type the daemon should request from the clipboard when -multiple representations are available. - -- `any` (default): Request any available representation (current behaviour). -- `text`: Prefer text representations (e.g. `text/plain`, `text/html`). -- `image`: Prefer image representations (e.g. `image/png`, `image/jpeg`) so that - image copies from browsers or file managers are stored as images rather than - HTML fragments. - -Example: prefer images when running the watch daemon - -```bash -stash watch --mime-type image -``` - -This is useful when copying images from browsers or file managers where the -clipboard may offer both HTML and image representations; selecting `image` will -ask the compositor for image data first. Most users will be fine using the -default value (`any`) but in the case your browser (or other applications!) -regularly misrepresent data, you might wish to prioritize a different type. - -#### Clipboard Persistence - -By default, when you copy something and close the source application, Wayland -clears the clipboard. Stash can optionally keep the clipboard contents available -after the source closes using the `--persist` flag. - -```bash -stash watch --persist -``` - -When enabled, Stash will fork a background process to serve the clipboard -contents, keeping them available even after the original application exits. - -> [!NOTE] -> This feature is **opt-in** and disabled by default, as it may not be desirable -> for all users and can leave clipboard data in memory longer than expected. You -> must start the `stash watch` daemon with `--persist` for clipboard -> persistence. +> [!TIP] +> Stash provides `wl-copy` and `wl-paste` binaries for backwards compatibility +> with the `wl-clipboard` tools. If _must_ depend on those binaries by name, you +> may simply use the `wl-copy` and `wl-paste` provided as `wl-clipboard-rs` +> wrappers on your system. In other words, you can use +> `wl-paste --watch stash store` as an alternative to `stash watch` if +> preferred. ### Options @@ -415,20 +319,6 @@ be only copied to the clipboard. > > `stash --excluded-apps Bitwarden watch` -## Motivation - -I've been a long-time user of Cliphist. You can probably tell by the number of -times it has been mentioned in the README, if not for the attributions section, -that Stash is _clearly_ inspired and adapted from it. It's actually a great -clipboard manager if your needs are simple, but mine aren't. I need an -**all-in-one** solution, that I can freely hack on, with simple solutions to -complex problems that I've had with managing my clipboard. I wanted it to be -scriptable _and_ interactive, I wanted it to be performant, I wanted it to be... - -You get the point. Perhaps you also share similar needs, or just like Rust -software in general on your desktop. In either case, Stash hopes to serve as an -excellent clipboard manager for your needs, with _excellent_ performance. - ## Tips & Tricks ### Migrating from Cliphist @@ -516,87 +406,6 @@ figured out something new, e.g. a neat shell trick, feel free to add it here! the packagers. While building from source, you may link `target/release/stash` manually. -### Entry Expiration - -Stash supports time-to-live (TTL) for clipboard entries. When an entry's -expiration time is reached, it is marked as expired rather than immediately -deleted. This allows for inspection of expired entries and automatic clipboard -cleanup. - -#### How Expiration Works - -When `stash watch` is running with `--expire-after`, it monitors the clipboard -and processes expired entries periodically. Upon expiration: - -1. The entry's `is_expired` flag is set to `1` in the database -2. If the current clipboard content matches the expired entry, Stash clears the - clipboard to prevent pasting stale data -3. Expired entries are excluded from normal list operations unless `--expired` - is specified - -> [!NOTE] -> By default, entries do not expire. Use `stash watch --expire-after DURATION` -> to enable expiration (e.g., `--expire-after 24h` for 24-hour TTL). - -#### Viewing Expired Entries - -Use `stash list --expired` to include expired entries in the output. This is -useful for: - -- Inspecting what has expired from your clipboard history -- Verifying that sensitive data has been properly expired -- Debugging expiration behavior - -```bash -# View all entries including expired ones -stash list --expired - -# View expired entries in JSON format -stash list --expired --format json -``` - -#### Cleaning Up Expired Entries - -The watch daemon automatically cleans up expired entries when it processes them. -For manual cleanup, use: - -```bash -# Remove all expired entries from the database -stash db wipe --expired -``` - -> [!NOTE] -> If you have a large number of expired entries, consider running -> `stash db vacuum` afterward to reclaim disk space. - -#### Automatic Clipboard Clearing - -When `stash watch` is running and an entry expires, Stash checks if the current -clipboard still contains that expired content. If it matches, Stash clears the -clipboard automatically. This prevents accidentally pasting outdated content. - -> [!TIP] -> This behavior only applies when the watch daemon is actively running. Manual -> expiration or deletion of entries will not clear the clipboard. - -#### Database Maintenance - -Stash uses SQLite for persistent storage. Over time, deleted entries and -fragmentation can affect performance. Use the `stash db` command to maintain -your database: - -- **Check statistics**: `stash db stats` shows entry counts and storage usage. - Use this to monitor growth and decide when to clean up. -- **Remove expired entries**: `stash db wipe --expired` removes entries that - have reached their TTL. The daemon normally handles this, but this is useful - for manual cleanup. -- **Optimize storage**: `stash db vacuum` runs SQLite's VACUUM command to - reclaim space and defragment the database. This is safe to run periodically. - -It is recommended to run `stash db vacuum` occasionally (e.g., monthly) to keep -the database compact, especially after deleting many entries. You can, of -course, wipe the database entirely if it has grown too large. - ## Attributions My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the @@ -604,14 +413,8 @@ My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the powered by [several crates](./Cargo.toml), but none of them were as detrimental in Stash's design process. -Secondly, but by no means less importantly, I would like to thank -[cliphist](https://github.com/sentriz/cliphist) for the excellent reference it -has provided to me as a "solid clipboard manager." The interface of Stash is -inspired by Cliphist, and it has served me very well for a very long time. - -Additional and definitely heartfelt thanks to my testers, who have tested -earlier versions of Stash, helped with packaging and provided feedback. Thank -you :) +Additional thanks to my testers, who have tested earlier versions of Stash and +provided feedback. Thank you :) ## License diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..f777a7c --- /dev/null +++ b/build.rs @@ -0,0 +1,46 @@ +use std::{env, fs, path::Path}; + +/// List of multicall symlinks to create (name, target) +const MULTICALL_LINKS: &[&str] = + &["stash-copy", "stash-paste", "wl-copy", "wl-paste"]; + +fn main() { + // OUT_DIR is something like .../target/debug/build//out + // We want .../target/debug or .../target/release + let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set"); + let bin_dir = Path::new(&out_dir) + .ancestors() + .nth(3) + .expect("Failed to find binary dir"); + + // Path to the main stash binary + let stash_bin = bin_dir.join("stash"); + + // Create symlinks for each multicall binary + for link in MULTICALL_LINKS { + let link_path = bin_dir.join(link); + // Remove existing symlink or file if present + let _ = fs::remove_file(&link_path); + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + match symlink(&stash_bin, &link_path) { + Ok(()) => { + println!( + "cargo:warning=Created symlink: {} -> {}", + link_path.display(), + stash_bin.display() + ); + }, + Err(e) => { + println!( + "cargo:warning=Failed to create symlink {} -> {}: {}", + link_path.display(), + stash_bin.display(), + e + ); + }, + } + } + } +} diff --git a/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..336926a 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -4,11 +4,9 @@ stdenv, mold, versionCheckHook, - useMold ? stdenv.isLinux, - createSymlinks ? true, }: let pname = "stash"; - version = (lib.importTOML ../Cargo.toml).package.version; + version = (builtins.fromTOML (builtins.readFile ../Cargo.toml)).package.version; src = let fs = lib.fileset; s = ../.; @@ -19,6 +17,7 @@ (fs.fileFilter (file: builtins.any file.hasExt ["rs"]) (s + /src)) (s + /Cargo.lock) (s + /Cargo.toml) + (s + /build.rs) ]; }; @@ -37,7 +36,7 @@ in # generated by the build wrapper are correctly linked, we should link # them *manually*. The postInstallCheck phase that follows will check # to verify if all of those links are in place. - postInstall = lib.optionalString createSymlinks '' + postInstall = '' mkdir -p $out for bin in stash-copy stash-paste wl-copy wl-paste; do ln -sf $out/bin/stash $out/bin/$bin @@ -49,13 +48,13 @@ in # After the version check, let's see if all binaries are linked correctly. # We could probably add a check phase to get the versions of each. - postInstallCheck = lib.optionalString createSymlinks '' + postInstallCheck = '' for bin in stash stash-copy stash-paste wl-copy wl-paste; do [ -x "$out/bin/$bin" ] || { echo "$bin missing"; exit 1; } done ''; - env = lib.optionalAttrs useMold { + env = lib.optionalAttrs (stdenv.isLinux && !stdenv.hostPlatform.isAarch) { CARGO_LINKER = "clang"; CARGO_RUSTFLAGS = "-Clink-arg=-fuse-ld=${mold}/bin/mold"; }; @@ -66,6 +65,5 @@ in license = lib.licenses.mpl20; maintainers = [lib.maintainers.NotAShelf]; mainProgram = "stash"; - platforms = lib.platforms.linux; }; } 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..a5b4e55 100644 --- a/src/commands/import.rs +++ b/src/commands/import.rs @@ -1,6 +1,12 @@ use std::io::{self, BufRead}; -use crate::db::{ClipboardDb, Entry, SqliteClipboardDb, StashError}; +use crate::db::{ + ClipboardDb, + Entry, + SqliteClipboardDb, + StashError, + detect_mime, +}; pub trait ImportCommand { /// Import clipboard entries from TSV format. @@ -38,7 +44,7 @@ impl ImportCommand for SqliteClipboardDb { let entry = Entry { contents: val.as_bytes().to_vec(), - mime: crate::mime::detect_mime(val.as_bytes()), + mime: detect_mime(val.as_bytes()), }; self @@ -55,11 +61,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..1afab2a 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -6,13 +6,8 @@ use unicode_width::UnicodeWidthStr; use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; pub trait ListCommand { - fn list( - &self, - out: impl Write, - preview_width: u32, - include_expired: bool, - reverse: bool, - ) -> Result<(), StashError>; + fn list(&self, out: impl Write, preview_width: u32) + -> Result<(), StashError>; } impl ListCommand for SqliteClipboardDb { @@ -20,267 +15,14 @@ impl ListCommand for SqliteClipboardDb { &self, out: impl Write, preview_width: u32, - include_expired: bool, - reverse: bool, ) -> Result<(), StashError> { - self - .list_entries(out, preview_width, include_expired, reverse) - .map(|_| ()) + self.list_entries(out, preview_width).map(|_| ()) } } -/// All mutable state for the TUI list view. -struct TuiState { - /// Total number of entries matching the current filter in the DB. - total: usize, - - /// Global cursor position: index into the full ordered result set. - cursor: usize, - - /// DB offset of `window[0]`, i.e., the first row currently loaded. - viewport_offset: usize, - - /// The loaded slice of entries: `(id, preview, mime)`. - window: Vec<(i64, String, String)>, - - /// How many rows the window holds (== visible list height). - window_size: usize, - - /// Whether the window needs to be re-fetched from the DB. - dirty: bool, - - /// Current search query. Empty string means no filter. - search_query: String, - - /// Whether we're currently in search input mode. - search_mode: bool, - - /// Whether to show entries in reverse order (oldest first). - reverse: bool, - - /// ID of entry currently being copied. - copying_entry: Option, -} - -impl TuiState { - /// Create initial state: count total rows, load the first window. - fn new( - db: &SqliteClipboardDb, - include_expired: bool, - window_size: usize, - preview_width: u32, - reverse: bool, - ) -> Result { - let total = db.count_entries(include_expired, None)?; - let window = if total > 0 { - db.fetch_entries_window( - include_expired, - 0, - window_size, - preview_width, - None, - reverse, - )? - } else { - Vec::new() - }; - Ok(Self { - total, - cursor: 0, - viewport_offset: 0, - window, - window_size, - dirty: false, - search_query: String::new(), - search_mode: false, - reverse, - copying_entry: None, - }) - } - - /// Return the current search filter (`None` if empty). - fn search_filter(&self) -> Option<&str> { - if self.search_query.is_empty() { - None - } else { - Some(&self.search_query) - } - } - - /// Update search query and reset cursor. Returns true if search changed. - fn set_search(&mut self, query: String) -> bool { - let changed = self.search_query != query; - if changed { - self.search_query = query; - self.cursor = 0; - self.viewport_offset = 0; - self.dirty = true; - } - changed - } - - /// Clear search and reset state. Returns true if was searching. - fn clear_search(&mut self) -> bool { - let had_search = !self.search_query.is_empty(); - self.search_query.clear(); - self.search_mode = false; - if had_search { - self.cursor = 0; - self.viewport_offset = 0; - self.dirty = true; - } - had_search - } - - /// Toggle search mode. - fn toggle_search_mode(&mut self) { - self.search_mode = !self.search_mode; - if self.search_mode { - // When entering search mode, clear query if there was one - // or start fresh - self.search_query.clear(); - self.dirty = true; - } - } - - /// Return the cursor position relative to the current window - /// (`window[local_cursor]` == the selected entry). - #[inline] - fn local_cursor(&self) -> usize { - self.cursor.saturating_sub(self.viewport_offset) - } - - /// Return the selected `(id, preview, mime)` if any entry is selected. - fn selected_entry(&self) -> Option<&(i64, String, String)> { - if self.total == 0 { - return None; - } - self.window.get(self.local_cursor()) - } - - /// Move the cursor down by one, wrapping to 0 at the bottom. - fn move_down(&mut self) { - if self.total == 0 { - return; - } - self.cursor = if self.cursor + 1 >= self.total { - 0 - } else { - self.cursor + 1 - }; - self.dirty = true; - } - - /// Move the cursor up by one, wrapping to `total - 1` at the top. - fn move_up(&mut self) { - if self.total == 0 { - return; - } - self.cursor = if self.cursor == 0 { - self.total - 1 - } else { - self.cursor - 1 - }; - self.dirty = true; - } - - /// Resize the window (e.g. terminal resized). Marks dirty so the - /// viewport is reloaded on the next frame. - fn resize(&mut self, new_size: usize) { - if new_size != self.window_size { - self.window_size = new_size; - self.dirty = true; - } - } - - /// After a delete the total shrinks by one and the cursor may need - /// clamping. The caller is responsible for the DB deletion itself. - fn on_delete(&mut self) { - if self.total == 0 { - return; - } - self.total -= 1; - if self.total == 0 { - self.cursor = 0; - } else if self.cursor >= self.total { - self.cursor = self.total - 1; - } - self.dirty = true; - } - - /// Reload the window from the DB if `dirty` is set or if the cursor - /// has drifted outside the currently loaded range. - fn sync( - &mut self, - db: &SqliteClipboardDb, - include_expired: bool, - preview_width: u32, - ) -> Result<(), StashError> { - let cursor_out_of_window = self.cursor < self.viewport_offset - || self.cursor >= self.viewport_offset + self.window.len().max(1); - - if !self.dirty && !cursor_out_of_window { - return Ok(()); - } - - // Re-anchor the viewport so the cursor sits in the upper half when - // scrolling downward, or at a sensible position when wrapping. - let half = self.window_size / 2; - self.viewport_offset = if self.cursor >= half { - (self.cursor - half).min(self.total.saturating_sub(self.window_size)) - } else { - 0 - }; - - let search = self.search_filter(); - self.window = if self.total > 0 { - db.fetch_entries_window( - include_expired, - self.viewport_offset, - self.window_size, - preview_width, - search, - self.reverse, - )? - } else { - Vec::new() - }; - self.dirty = false; - Ok(()) - } -} - -/// Query the maximum id digit-width and maximum mime byte-length across -/// all entries. This is pretty damn fast as it touches only index/metadata, -/// not blobs. -fn global_column_widths( - db: &SqliteClipboardDb, - include_expired: bool, -) -> Result<(usize, usize), StashError> { - let filter = if include_expired { - "" - } else { - "WHERE (is_expired IS NULL OR is_expired = 0)" - }; - let query = format!( - "SELECT COALESCE(MAX(LENGTH(CAST(id AS TEXT))), 2), \ - COALESCE(MAX(LENGTH(mime)), 8) FROM clipboard {filter}" - ); - let (id_w, mime_w): (i64, i64) = db - .conn - .query_row(&query, [], |r| Ok((r.get(0)?, r.get(1)?))) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - Ok((id_w.max(2) as usize, mime_w.max(8) as usize)) -} - impl SqliteClipboardDb { #[allow(clippy::too_many_lines)] - pub fn list_tui( - &self, - preview_width: u32, - include_expired: bool, - reverse: bool, - ) -> Result<(), StashError> { + pub fn list_tui(&self, preview_width: u32) -> Result<(), StashError> { use std::io::stdout; use crossterm::{ @@ -310,9 +52,42 @@ impl SqliteClipboardDb { }; use wl_clipboard_rs::copy::{MimeType, Options, Source}; - // One-time column-width metadata (no blob reads). - let (max_id_width, max_mime_width) = - global_column_widths(self, include_expired)?; + // Query entries from DB + let mut stmt = self + .conn + .prepare( + "SELECT id, contents, mime FROM clipboard ORDER BY last_accessed \ + DESC, id DESC", + ) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let mut rows = stmt + .query([]) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + + let mut entries: Vec<(i64, String, String)> = Vec::new(); + let mut max_id_width = 2; + let mut max_mime_width = 8; + while let Some(row) = rows + .next() + .map_err(|e| StashError::ListDecode(e.to_string().into()))? + { + let id: i64 = row + .get(0) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let contents: Vec = row + .get(1) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let mime: Option = row + .get(2) + .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let preview = + crate::db::preview_entry(&contents, mime.as_deref(), preview_width); + let mime_str = mime.as_deref().unwrap_or("").to_string(); + let id_str = id.to_string(); + max_id_width = max_id_width.max(id_str.width()); + max_mime_width = max_mime_width.max(mime_str.width()); + entries.push((id, preview, mime_str)); + } enable_raw_mode() .map_err(|e| StashError::ListDecode(e.to_string().into()))?; @@ -323,160 +98,35 @@ impl SqliteClipboardDb { let mut terminal = Terminal::new(backend) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - // Derive initial window size from current terminal height. - let initial_height = terminal - .size() - .map(|r| r.height.saturating_sub(2) as usize) - .unwrap_or(24); - let initial_height = initial_height.max(1); - - let mut tui = TuiState::new( - self, - include_expired, - initial_height, - preview_width, - reverse, - )?; - - // ratatui ListState; only tracks selection within the *window* slice. - let mut list_state = ListState::default(); - if tui.total > 0 { - list_state.select(Some(0)); + let mut state = ListState::default(); + if !entries.is_empty() { + state.select(Some(0)); } - /// Accumulated actions from draining the event queue. - struct EventActions { - quit: bool, - net_down: i64, // positive=down, negative=up, 0=none - copy: bool, - delete: bool, - toggle_search: bool, // enter/exit search mode - search_input: Option, // character typed in search mode - search_backspace: bool, // backspace in search mode - clear_search: bool, // clear search query (ESC in search mode) - } - - /// Drain all pending key events and return what actions to perform. - /// Navigation is capped to +-1 per frame to prevent jumpy scrolling when - /// the key-repeat rate exceeds the render frame rate. - fn drain_events(tui: &TuiState) -> Result { - let mut actions = EventActions { - quit: false, - net_down: 0, - copy: false, - delete: false, - toggle_search: false, - search_input: None, - search_backspace: false, - clear_search: false, - }; - - while event::poll(std::time::Duration::from_millis(0)) - .map_err(|e| StashError::ListDecode(e.to_string().into()))? - { - if let Event::Key(key) = event::read() - .map_err(|e| StashError::ListDecode(e.to_string().into()))? - { - if tui.search_mode { - // In search mode, handle text input - match (key.code, key.modifiers) { - (KeyCode::Esc, _) => { - actions.clear_search = true; - }, - (KeyCode::Enter, _) => { - actions.toggle_search = true; // exit search mode - }, - (KeyCode::Backspace, _) => { - actions.search_backspace = true; - }, - (KeyCode::Char(c), _) => { - actions.search_input = Some(c); - }, - _ => {}, - } - } else { - // Normal mode navigation commands - match (key.code, key.modifiers) { - (KeyCode::Char('q') | KeyCode::Esc, _) => actions.quit = true, - (KeyCode::Down | KeyCode::Char('j'), _) => { - // Cap at +1 per frame for smooth scrolling - if actions.net_down < 1 { - actions.net_down += 1; - } - }, - (KeyCode::Up | KeyCode::Char('k'), _) => { - // Cap at -1 per frame for smooth scrolling - if actions.net_down > -1 { - actions.net_down -= 1; - } - }, - (KeyCode::Enter, _) => actions.copy = true, - (KeyCode::Char('D'), KeyModifiers::SHIFT) => { - actions.delete = true; - }, - (KeyCode::Char('/'), _) => actions.toggle_search = true, - _ => {}, - } - } - } - } - Ok(actions) - } - - let draw_frame = - |terminal: &mut Terminal>, - tui: &mut TuiState, - list_state: &mut ListState, - max_id_width: usize, - max_mime_width: usize| - -> Result<(), StashError> { - // Reserve 2 rows for search bar when in search mode - let search_bar_height = if tui.search_mode { 2 } else { 0 }; - let term_height = terminal - .size() - .map(|r| r.height.saturating_sub(2 + search_bar_height) as usize) - .unwrap_or(24) - .max(1); - tui.resize(term_height); - tui.sync(self, include_expired, preview_width)?; - - if tui.total == 0 { - list_state.select(None); - } else { - list_state.select(Some(tui.local_cursor())); - } - + let res = (|| -> Result<(), StashError> { + loop { terminal .draw(|f| { let area = f.area(); - - // Build title based on search state - let title = if tui.search_mode { - format!("Search: {}", tui.search_query) - } else if tui.search_query.is_empty() { - "Clipboard Entries (j/k/↑/↓ to move, / to search, Enter to copy, \ - Shift+D to delete, q/ESC to quit)" - .to_string() - } else { - format!( - "Clipboard Entries (filtered: '{}' - {} results, / to search, \ - ESC to clear, q to quit)", - tui.search_query, tui.total + let block = Block::default() + .title( + "Clipboard Entries (j/k/↑/↓ to move, Enter to copy, Shift+D \ + to delete, q/ESC to quit)", ) - }; - - let block = Block::default().title(title).borders(Borders::ALL); + .borders(Borders::ALL); let border_width = 2; let highlight_symbol = ">"; let highlight_width = 1; let content_width = area.width as usize - border_width; + // Minimum widths for columns let min_id_width = 2; let min_mime_width = 6; let min_preview_width = 4; - let spaces = 3; + let spaces = 3; // [id][ ][preview][ ][mime] + // Dynamically allocate widths let mut id_col = max_id_width.max(min_id_width); let mut mime_col = max_mime_width.max(min_mime_width); let mut preview_col = content_width @@ -485,6 +135,7 @@ impl SqliteClipboardDb { .saturating_sub(mime_col) .saturating_sub(spaces); + // If not enough space, shrink columns if preview_col < min_preview_width { let needed = min_preview_width - preview_col; if mime_col > min_mime_width { @@ -507,13 +158,13 @@ impl SqliteClipboardDb { preview_col = min_preview_width; } - let selected = list_state.selected(); + let selected = state.selected(); - let list_items: Vec = tui - .window + let list_items: Vec = entries .iter() .enumerate() .map(|(i, entry)| { + // Truncate preview by grapheme clusters and display width let mut preview = String::new(); let mut width = 0; for g in entry.1.graphemes(true) { @@ -525,6 +176,7 @@ impl SqliteClipboardDb { preview.push_str(g); width += g_width; } + // Truncate and pad mimetype let mut mime = String::new(); let mut mwidth = 0; for g in entry.2.graphemes(true) { @@ -537,6 +189,8 @@ impl SqliteClipboardDb { mwidth += g_width; } + // Compose the row as highlight + id + space + preview + space + + // mimetype let mut spans = Vec::new(); let (id, preview, mime) = entry; if Some(i) == selected { @@ -583,121 +237,70 @@ impl SqliteClipboardDb { .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ) - .highlight_symbol(""); + .highlight_symbol(""); // handled manually - f.render_stateful_widget(list, area, list_state); + f.render_stateful_widget(list, area, &mut state); }) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - Ok(()) - }; - // Initial draw. - draw_frame( - &mut terminal, - &mut tui, - &mut list_state, - max_id_width, - max_mime_width, - )?; - - let res = (|| -> Result<(), StashError> { - loop { - // Block waiting for events, then drain and process all queued input. if event::poll(std::time::Duration::from_millis(250)) .map_err(|e| StashError::ListDecode(e.to_string().into()))? + && let Event::Key(key) = event::read() + .map_err(|e| StashError::ListDecode(e.to_string().into()))? { - let actions = drain_events(&tui)?; - - if actions.quit { - break; - } - - // Handle search mode actions - if actions.toggle_search { - tui.toggle_search_mode(); - } - - if actions.clear_search && tui.clear_search() { - // Search was cleared, refresh count - tui.total = - self.count_entries(include_expired, tui.search_filter())?; - } - - if let Some(c) = actions.search_input { - let new_query = format!("{}{}", tui.search_query, c); - if tui.set_search(new_query) { - // Search changed, refresh count and reset - tui.total = - self.count_entries(include_expired, tui.search_filter())?; - } - } - - if actions.search_backspace { - let new_query = tui - .search_query - .chars() - .next_back() - .map(|_| { - tui - .search_query - .chars() - .take(tui.search_query.len() - 1) - .collect::() - }) - .unwrap_or_default(); - if tui.set_search(new_query) { - // Search changed, refresh count and reset - tui.total = - self.count_entries(include_expired, tui.search_filter())?; - } - } - - // Apply navigation (capped at ±1 per frame for smooth scrolling). - if !tui.search_mode { - if actions.net_down > 0 { - tui.move_down(); - } else if actions.net_down < 0 { - tui.move_up(); - } - - if actions.delete - && let Some(&(id, ..)) = tui.selected_entry() - { - self - .conn - .execute( - "DELETE FROM clipboard WHERE id = ?1", - rusqlite::params![id], - ) - .map_err(|e| { - StashError::DeleteEntry(id, e.to_string().into()) - })?; - tui.on_delete(); - let _ = Notification::new() - .summary("Stash") - .body("Deleted entry") - .show(); - } - - if actions.copy - && let Some(&(id, ..)) = tui.selected_entry() - { - if tui.copying_entry == Some(id) { - log::debug!( - "Skipping duplicate copy for entry {id} (already in \ - progress)" - ); + match (key.code, key.modifiers) { + (KeyCode::Char('q') | KeyCode::Esc, _) => break, + (KeyCode::Down | KeyCode::Char('j'), _) => { + if entries.is_empty() { + state.select(None); } else { - tui.copying_entry = Some(id); - match self.copy_entry(id) { + let i = match state.selected() { + Some(i) => { + if i >= entries.len() - 1 { + 0 + } else { + i + 1 + } + }, + None => 0, + }; + state.select(Some(i)); + } + }, + (KeyCode::Up | KeyCode::Char('k'), _) => { + if entries.is_empty() { + state.select(None); + } else { + let i = match state.selected() { + Some(i) => { + if i == 0 { + entries.len() - 1 + } else { + i - 1 + } + }, + None => 0, + }; + state.select(Some(i)); + } + }, + (KeyCode::Enter, _) => { + if let Some(idx) = state.selected() + && let Some((id, ..)) = entries.get(idx) + { + match self.copy_entry(*id) { Ok((new_id, contents, mime)) => { - if new_id != id { - tui.dirty = true; + if new_id != *id { + entries[idx] = ( + new_id, + entries[idx].1.clone(), + entries[idx].2.clone(), + ); } let opts = Options::new(); let mime_type = match mime { Some(ref m) if m == "text/plain" => MimeType::Text, - Some(ref m) => MimeType::Specific(m.clone().clone()), + Some(ref m) => MimeType::Specific(m.clone().to_owned()), None => MimeType::Text, }; let copy_result = opts @@ -710,7 +313,7 @@ impl SqliteClipboardDb { .show(); }, Err(e) => { - log::error!("failed to copy entry to clipboard: {e}"); + log::error!("Failed to copy entry to clipboard: {e}"); let _ = Notification::new() .summary("Stash") .body(&format!("Failed to copy to clipboard: {e}")) @@ -719,26 +322,48 @@ impl SqliteClipboardDb { } }, Err(e) => { - log::error!("failed to fetch entry {id}: {e}"); + log::error!("Failed to fetch entry {id}: {e}"); let _ = Notification::new() .summary("Stash") .body(&format!("Failed to fetch entry: {e}")) .show(); }, } - tui.copying_entry = None; } - } + }, + (KeyCode::Char('D'), KeyModifiers::SHIFT) => { + if let Some(idx) = state.selected() + && let Some((id, ..)) = entries.get(idx) + { + // Delete entry from DB + self + .conn + .execute( + "DELETE FROM clipboard WHERE id = ?1", + rusqlite::params![id], + ) + .map_err(|e| { + StashError::DeleteEntry(*id, e.to_string().into()) + })?; + // Remove from entries and update selection + entries.remove(idx); + let new_len = entries.len(); + if new_len == 0 { + state.select(None); + } else if idx >= new_len { + state.select(Some(new_len - 1)); + } else { + state.select(Some(idx)); + } + // Show notification + let _ = Notification::new() + .summary("Stash") + .body("Deleted entry") + .show(); + } + }, + _ => {}, } - - // Redraw once after processing all accumulated input. - draw_frame( - &mut terminal, - &mut tui, - &mut list_state, - max_id_width, - max_mime_width, - )?; } } Ok(()) diff --git a/src/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..ce2acf7 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -1,712 +1,113 @@ -use std::{collections::BinaryHeap, hash::Hasher, io::Read, time::Duration}; +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + io::Read, + time::Duration, +}; use smol::Timer; -use wl_clipboard_rs::{ - copy::{MimeType as CopyMimeType, Options, Source}, - paste::{ - ClipboardType, - MimeType as PasteMimeType, - Seat, - get_contents, - get_mime_types_ordered, - }, -}; +use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents}; -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. -/// Also see: -/// - -/// - -/// - -#[derive(Debug, Clone, Copy)] -struct Neg(f64); - -impl Neg { - fn inner(&self) -> f64 { - self.0 - } -} - -impl std::cmp::PartialEq for Neg { - fn eq(&self, other: &Self) -> bool { - self.0 == other.0 - } -} - -impl std::cmp::Eq for Neg {} - -impl std::cmp::PartialOrd for Neg { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl std::cmp::Ord for Neg { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - // Reverse ordering for min-heap behavior - other - .0 - .partial_cmp(&self.0) - .unwrap_or(std::cmp::Ordering::Equal) - } -} - -/// Min-heap for tracking entry expirations with sub-second precision. -/// Uses Neg wrapper to turn `BinaryHeap` (max-heap) into min-heap behavior. -#[derive(Debug, Default)] -struct ExpirationQueue { - heap: BinaryHeap<(Neg, i64)>, -} - -impl ExpirationQueue { - /// Create a new empty expiration queue - fn new() -> Self { - Self { - heap: BinaryHeap::new(), - } - } - - /// Push a new expiration into the queue - fn push(&mut self, expires_at: f64, id: i64) { - self.heap.push((Neg(expires_at), id)); - } - - /// Peek at the next expiration timestamp without removing it - fn peek_next(&self) -> Option { - self.heap.peek().map(|(neg, _)| neg.inner()) - } - - /// Remove and return all entries that have expired by `now` - fn pop_expired(&mut self, now: f64) -> Vec { - let mut expired = Vec::new(); - while let Some((neg_exp, id)) = self.heap.peek() { - let expires_at = neg_exp.inner(); - if expires_at <= now { - expired.push(*id); - self.heap.pop(); - } else { - break; - } - } - expired - } - - /// Check if the queue is empty - fn is_empty(&self) -> bool { - self.heap.is_empty() - } - - /// Get the number of entries in the queue - fn len(&self) -> usize { - self.heap.len() - } -} - -/// Get clipboard contents using the source application's preferred MIME type. -/// -/// See, `MimeType::Any` lets wl-clipboard-rs pick a type in arbitrary order, -/// which causes issues when applications offer multiple types (e.g. file -/// managers offering `text/uri-list` + `text/plain`, or Firefox offering -/// `text/html` + `image/png` + `text/plain`). -/// -/// This queries the ordered types via [`get_mime_types_ordered`], which -/// preserves the Wayland protocol's offer order (source application's -/// preference) and then requests the first type with [`MimeType::Specific`]. -/// -/// The two-step approach has a theoretical race (clipboard could change between -/// the calls), but the wl-clipboard-rs API has no single-call variant that -/// respects source ordering. A race simply produces an error that the polling -/// loop handles like any other clipboard-empty/error case. -/// -/// When `preference` is `"text"`, uses `MimeType::Text` directly (single call). -/// When `preference` is `"image"`, picks the first offered `image/*` type. -/// Otherwise picks the source's first offered type. -/// -/// # Returns -/// -/// The content reader, the selected MIME type, and ALL offered MIME -/// types. -#[expect(clippy::type_complexity)] -fn negotiate_mime_type( - preference: &str, -) -> Result<(Box, String, Vec), wl_clipboard_rs::paste::Error> -{ - // Get all offered MIME types first (needed for persistence) - let offered = - get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified)?; - - if preference == "text" { - let (reader, mime_str) = get_contents( - ClipboardType::Regular, - Seat::Unspecified, - PasteMimeType::Text, - )?; - return Ok((Box::new(reader) as Box, mime_str, offered)); - } - - let chosen = if preference == "image" { - // Pick the first offered image type, fall back to first overall - offered - .iter() - .find(|m| m.starts_with("image/")) - .or_else(|| offered.first()) - } else { - // XXX: When preference is "any", deprioritize text/html if a more - // concrete type is available. Browsers and Electron apps put - // text/html first even for "Copy Image", but the HTML is just - // a wrapper (), i.e., never what the user wants in a - // clipboard manager. Prefer image/* first, then any non-html - // type, and fall back to text/html only as a last resort. - let has_image = offered.iter().any(|m| m.starts_with("image/")); - if has_image { - offered - .iter() - .find(|m| m.starts_with("image/")) - .or_else(|| offered.first()) - } else if offered.first().is_some_and(|m| m == "text/html") { - offered - .iter() - .find(|m| *m != "text/html") - .or_else(|| offered.first()) - } else { - offered.first() - } - }; - - match chosen { - Some(mime_str) => { - let (reader, actual_mime) = get_contents( - ClipboardType::Regular, - Seat::Unspecified, - PasteMimeType::Specific(mime_str), - )?; - - Ok((Box::new(reader) as Box, actual_mime, offered)) - }, - 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"); - if persist { - log::info!("clipboard persistence enabled"); - } + // We use hashes for comparison instead of storing full contents + let mut last_hash: Option = None; + let mut buf = Vec::with_capacity(4096); - // Build expiration queue from existing entries - let mut exp_queue = ExpirationQueue::new(); + // Helper to hash clipboard contents + let hash_contents = |data: &[u8]| -> u64 { + let mut hasher = DefaultHasher::new(); + data.hash(&mut hasher); + hasher.finish() + }; - // 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); + // Initialize with current clipboard + if let Ok((mut reader, _)) = get_contents( + ClipboardType::Regular, + Seat::Unspecified, + wl_clipboard_rs::paste::MimeType::Any, + ) { + buf.clear(); + if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() { + last_hash = Some(hash_contents(&buf)); } - if !exp_queue.is_empty() { - log::info!("loaded {} expirations from database", exp_queue.len()); - } - }, - Err(e) => { - log::warn!("failed to load expirations: {e}"); - }, - } - - // We use hashes for comparison instead of storing full contents - let mut last_hash: Option = None; - let mut buf = Vec::with_capacity(4096); - - // Helper to hash clipboard contents using FNV-1a (deterministic across - // runs) - let hash_contents = |data: &[u8]| -> u64 { - let mut hasher = Fnv1aHasher::new(); - hasher.write(data); - hasher.finish() - }; - - // Initialize with current clipboard using smart MIME negotiation - if let Ok((mut reader, ..)) = negotiate_mime_type(mime_type_preference) { - buf.clear(); - if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() { - last_hash = Some(hash_contents(&buf)); } - } - let poll_interval = Duration::from_millis(500); + loop { + match get_contents( + ClipboardType::Regular, + Seat::Unspecified, + wl_clipboard_rs::paste::MimeType::Any, + ) { + Ok((mut reader, _mime_type)) => { + buf.clear(); + if let Err(e) = reader.read_to_end(&mut buf) { + log::error!("Failed to read clipboard contents: {e}"); + Timer::after(Duration::from_millis(500)).await; + continue; + } - 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) - { - 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); - // 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}" - ); - } - } + // Only store if changed and not empty + if !buf.is_empty() { + let current_hash = hash_contents(&buf); + if last_hash != Some(current_hash) { + let id = self.next_sequence(); + match self.store_entry( + &buf[..], + max_dedupe_search, + max_items, + Some(excluded_apps), + ) { + Ok(_) => { + log::info!("Stored new clipboard entry (id: {id})"); + last_hash = Some(current_hash); + }, + Err(crate::db::StashError::ExcludedByApp(_)) => { + log::info!("Clipboard entry excluded by app filter"); + last_hash = Some(current_hash); + }, + Err(crate::db::StashError::Store(ref msg)) + if msg.contains("Excluded by app filter") => + { + log::info!("Clipboard entry excluded by app filter"); + last_hash = Some(current_hash); + }, + Err(e) => { + log::error!("Failed to store clipboard entry: {e}"); + last_hash = Some(current_hash); + }, } } } - } - } - } - - // Normal clipboard polling (always run, even when expirations are - // pending) - match negotiate_mime_type(mime_type_preference) { - Ok((mut reader, _mime_type, _all_mimes)) => { - buf.clear(); - if let Err(e) = reader.read_to_end(&mut buf) { - log::error!("failed to read clipboard contents: {e}"); - Timer::after(Duration::from_millis(500)).await; - continue; - } - - // Only store if changed and not empty - if !buf.is_empty() { - let current_hash = hash_contents(&buf); - if last_hash != Some(current_hash) { - // Clone buf for the async operation since it needs 'static - let buf_clone = buf.clone(); - #[allow(clippy::cast_possible_wrap)] - let content_hash = Some(current_hash as i64); - - // Clone data for persistence after successful store - let buf_for_persist = buf.clone(); - let mime_types_for_persist = _all_mimes.clone(); - let selected_mime = _mime_type.clone(); - - match async_db - .store_entry( - buf_clone, - max_dedupe_search, - max_items, - Some(excluded_apps.to_vec()), - min_size, - max_size, - content_hash, - Some(mime_types_for_persist.clone()), - ) - .await - { - Ok(id) => { - log::info!("stored new clipboard entry (id: {id})"); - last_hash = Some(current_hash); - - // Persist clipboard: fork child to serve data - // This keeps the clipboard alive when source app closes - // Check if we're already serving to avoid duplicate processes - if persist && get_serving_pid().is_none() { - let clipboard_data = ClipboardData::new( - buf_for_persist, - mime_types_for_persist, - selected_mime, - ); - - // Validate and persist in blocking task - if clipboard_data.is_valid().is_ok() { - smol::spawn(async move { - // Use blocking task for fork operation - let result = smol::unblock(move || unsafe { - clipboard::persist_clipboard(clipboard_data) - }) - .await; - - if let Err(e) = result { - log::debug!("clipboard persistence failed: {e}"); - } - }) - .detach(); - } - } else if persist { - log::trace!( - "Already serving clipboard, skipping persistence fork" - ); - } - - // Set expiration if configured - if let Some(duration) = expire_after { - let expires_at = - SqliteClipboardDb::now() + duration.as_secs_f64(); - if let Err(e) = - async_db.set_expiration(id, expires_at).await - { - log::warn!( - "Failed to set expiration for entry {id}: {e}" - ); - } else { - exp_queue.push(expires_at, id); - } - } - }, - Err(crate::db::StashError::ExcludedByApp(_)) => { - log::info!("clipboard entry excluded by app filter"); - last_hash = Some(current_hash); - }, - Err(crate::db::StashError::Store(ref msg)) - if msg.contains("Excluded by app filter") => - { - log::info!("clipboard entry excluded by app filter"); - last_hash = Some(current_hash); - }, - Err(e) => { - log::error!("failed to store clipboard entry: {e}"); - last_hash = Some(current_hash); - }, - } + }, + Err(e) => { + let error_msg = e.to_string(); + if !error_msg.contains("empty") { + log::error!("Failed to get clipboard contents: {e}"); } - } - }, - Err(e) => { - let error_msg = e.to_string(); - if !error_msg.contains("empty") { - log::error!("failed to get clipboard contents: {e}"); - } - }, + }, + } + Timer::after(Duration::from_millis(500)).await; } - - // Calculate sleep time: min of poll interval and time until next - // expiration - let sleep_duration = if let Some(next_exp) = exp_queue.peek_next() { - let now = SqliteClipboardDb::now(); - let time_to_exp = (next_exp - now).max(0.0); - poll_interval.min(Duration::from_secs_f64(time_to_exp)) - } else { - poll_interval - }; - Timer::after(sleep_duration).await; - } - } -} - -/// Given ordered offers and a preference, return the -/// chosen MIME type. This mirrors the selection logic in -/// [`negotiate_mime_type`] without requiring a Wayland connection. -#[cfg(test)] -fn pick_mime<'a>( - offered: &'a [String], - preference: &str, -) -> Option<&'a String> { - if preference == "image" { - offered - .iter() - .find(|m| m.starts_with("image/")) - .or_else(|| offered.first()) - } else { - let has_image = offered.iter().any(|m| m.starts_with("image/")); - if has_image { - offered - .iter() - .find(|m| m.starts_with("image/")) - .or_else(|| offered.first()) - } else if offered.first().is_some_and(|m| m == "text/html") { - offered - .iter() - .find(|m| *m != "text/html") - .or_else(|| offered.first()) - } else { - offered.first() - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_pick_first_offered() { - let offered = vec!["text/uri-list".to_string(), "text/plain".to_string()]; - assert_eq!(pick_mime(&offered, "any").unwrap(), "text/uri-list"); - } - - #[test] - fn test_pick_image_preference_finds_image() { - let offered = vec![ - "text/html".to_string(), - "image/png".to_string(), - "text/plain".to_string(), - ]; - assert_eq!(pick_mime(&offered, "image").unwrap(), "image/png"); - } - - #[test] - fn test_pick_image_preference_falls_back() { - let offered = vec!["text/html".to_string(), "text/plain".to_string()]; - // No image types offered — falls back to first - assert_eq!(pick_mime(&offered, "image").unwrap(), "text/html"); - } - - #[test] - fn test_pick_empty_offered() { - let offered: Vec = vec![]; - assert!(pick_mime(&offered, "any").is_none()); - } - - #[test] - fn test_pick_image_over_html_firefox_copy_image() { - // Firefox "Copy Image" offers html first, then image, then text. - // We should pick the image, not the html wrapper. - let offered = vec![ - "text/html".to_string(), - "image/png".to_string(), - "text/plain".to_string(), - ]; - assert_eq!(pick_mime(&offered, "any").unwrap(), "image/png"); - } - - #[test] - fn test_pick_image_over_html_electron() { - // Electron apps also put text/html before image types - let offered = vec!["text/html".to_string(), "image/jpeg".to_string()]; - assert_eq!(pick_mime(&offered, "any").unwrap(), "image/jpeg"); - } - - #[test] - fn test_pick_html_fallback_when_only_html() { - // When text/html is the only type, pick it - let offered = vec!["text/html".to_string()]; - assert_eq!(pick_mime(&offered, "any").unwrap(), "text/html"); - } - - #[test] - fn test_pick_text_over_html_when_no_image() { - // Rich text copy: html + plain, no image — prefer plain text - let offered = vec!["text/html".to_string(), "text/plain".to_string()]; - assert_eq!(pick_mime(&offered, "any").unwrap(), "text/plain"); - } - - #[test] - fn test_pick_file_manager_uri_list_first() { - // File managers typically offer uri-list first - let offered = vec!["text/uri-list".to_string(), "text/plain".to_string()]; - assert_eq!(pick_mime(&offered, "any").unwrap(), "text/uri-list"); - } - - /// Test that "text" preference is handled separately from pick_mime logic. - /// Documents that "text" preference uses PasteMimeType::Text directly - /// without querying MIME type ordering. This is functionally a regression - /// test for `negotiate_mime_type()`, which is load bearing, to ensure that - /// we don't mess it up. - #[test] - fn test_text_preference_behavior() { - // When preference is "text", negotiate_mime_type() should: - // 1. Use PasteMimeType::Text directly (no ordering query via - // get_mime_types_ordered) - // 2. Return content with text/plain MIME type - // - // Note: "text" is NOT passed to pick_mime() - it's handled separately - // in negotiate_mime_type() before the pick_mime logic. - // This test documents the separation of concerns. - let offered = vec![ - "text/html".to_string(), - "image/png".to_string(), - "text/plain".to_string(), - ]; - // pick_mime is only called for "image" and "any" preferences - // "text" goes through a different code path - assert_eq!(pick_mime(&offered, "any").unwrap(), "image/png"); - } - - /// Test MIME type selection priority for "any" preference with multiple - /// types. Documents that: - /// 1. Image types are preferred over text/html - /// 2. Non-html text types are preferred over text/html - /// 3. First offered type is used when no special cases match - #[test] - fn test_any_preference_selection_priority() { - // Priority 1: Image over HTML - let offered = vec!["text/html".to_string(), "image/png".to_string()]; - assert_eq!(pick_mime(&offered, "any").unwrap(), "image/png"); - - // Priority 2: Plain text over HTML - let offered = vec!["text/html".to_string(), "text/plain".to_string()]; - assert_eq!(pick_mime(&offered, "any").unwrap(), "text/plain"); - - // Priority 3: First type when no special handling - let offered = - vec!["application/json".to_string(), "text/plain".to_string()]; - assert_eq!(pick_mime(&offered, "any").unwrap(), "application/json"); - } - - /// Test "image" preference behavior. - /// Documents that: - /// 1. First image/* type is selected - /// 2. Falls back to first type if no images - #[test] - fn test_image_preference_selection_behavior() { - // Multiple images - pick first one - let offered = vec![ - "image/jpeg".to_string(), - "image/png".to_string(), - "text/plain".to_string(), - ]; - assert_eq!(pick_mime(&offered, "image").unwrap(), "image/jpeg"); - - // No images - fall back to first - let offered = vec!["text/html".to_string(), "text/plain".to_string()]; - assert_eq!(pick_mime(&offered, "image").unwrap(), "text/html"); - } - - /// Test edge case: text/html as only option. - /// Documents that text/html is used when it's the only type available. - #[test] - fn test_html_fallback_as_only_option() { - let offered = vec!["text/html".to_string()]; - assert_eq!(pick_mime(&offered, "any").unwrap(), "text/html"); - assert_eq!(pick_mime(&offered, "image").unwrap(), "text/html"); - } - - /// Test complex Firefox scenario with all MIME types. - /// Documents expected behavior when source offers many types. - #[test] - fn test_firefox_copy_image_all_types() { - // Firefox "Copy Image" offers: - // text/html, text/_moz_htmlcontext, text/_moz_htmlinfo, - // image/png, image/bmp, image/x-bmp, image/x-ico, - // text/ico, application/ico, image/ico, image/icon, - // text/icon, image/x-win-bitmap, image/x-win-bmp, - // image/x-icon, text/plain - let offered = vec![ - "text/html".to_string(), - "text/_moz_htmlcontext".to_string(), - "image/png".to_string(), - "image/bmp".to_string(), - "text/plain".to_string(), - ]; - - // "any" should pick image/png (first image, skipping HTML) - assert_eq!(pick_mime(&offered, "any").unwrap(), "image/png"); - - // "image" should pick image/png - assert_eq!(pick_mime(&offered, "image").unwrap(), "image/png"); - } - - /// Test complex Electron app scenario. - #[test] - fn test_electron_app_mime_types() { - // Electron apps often offer: text/html, image/png, text/plain - let offered = vec![ - "text/html".to_string(), - "image/png".to_string(), - "text/plain".to_string(), - ]; - - assert_eq!(pick_mime(&offered, "any").unwrap(), "image/png"); - assert_eq!(pick_mime(&offered, "image").unwrap(), "image/png"); - } - - /// Test that the function handles empty offers correctly. - /// Documents that empty offers result in an error (NoSeats equivalent). - #[test] - fn test_empty_offers_behavior() { - let offered: Vec = 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..97e2bb3 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,186 +1,28 @@ 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 imagesize::ImageType; +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 +60,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( @@ -255,8 +80,6 @@ pub trait ClipboardDb { &self, out: impl Write, preview_width: u32, - include_expired: bool, - reverse: bool, ) -> Result; fn decode_entry( &self, @@ -270,6 +93,7 @@ pub trait ClipboardDb { &self, id: i64, ) -> Result<(i64, Vec, Option), StashError>; + fn next_sequence(&self) -> i64; } #[derive(Serialize, Deserialize)] @@ -286,15 +110,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 +174,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( @@ -436,119 +256,6 @@ impl SqliteClipboardDb { })?; } - // Add expires_at column if it doesn't exist (v4) - if schema_version < 4 { - let has_expires_at: bool = tx - .query_row( - "SELECT sql FROM sqlite_master WHERE type='table' AND \ - name='clipboard'", - [], - |row| { - let sql: String = row.get(0)?; - Ok(sql.to_lowercase().contains("expires_at")) - }, - ) - .unwrap_or(false); - - if !has_expires_at { - tx.execute("ALTER TABLE clipboard ADD COLUMN expires_at REAL", []) - .map_err(|e| { - StashError::Store( - format!("Failed to add expires_at column: {e}").into(), - ) - })?; - } - - // Create partial index for expires_at (only index non-NULL values) - tx.execute( - "CREATE INDEX IF NOT EXISTS idx_expires_at ON clipboard(expires_at) \ - WHERE expires_at IS NOT NULL", - [], - ) - .map_err(|e| { - StashError::Store( - format!("Failed to create expires_at index: {e}").into(), - ) - })?; - - tx.execute("PRAGMA user_version = 4", []).map_err(|e| { - StashError::Store(format!("Failed to set schema version: {e}").into()) - })?; - } - - // Add is_expired column if it doesn't exist (v5) - if schema_version < 5 { - let has_is_expired: bool = tx - .query_row( - "SELECT sql FROM sqlite_master WHERE type='table' AND \ - name='clipboard'", - [], - |row| { - let sql: String = row.get(0)?; - Ok(sql.to_lowercase().contains("is_expired")) - }, - ) - .unwrap_or(false); - - if !has_is_expired { - tx.execute( - "ALTER TABLE clipboard ADD COLUMN is_expired INTEGER DEFAULT 0", - [], - ) - .map_err(|e| { - StashError::Store( - format!("Failed to add is_expired column: {e}").into(), - ) - })?; - } - - // Create index for is_expired (for filtering) - tx.execute( - "CREATE INDEX IF NOT EXISTS idx_is_expired ON clipboard(is_expired) \ - WHERE is_expired = 1", - [], - ) - .map_err(|e| { - StashError::Store( - format!("Failed to create is_expired index: {e}").into(), - ) - })?; - - tx.execute("PRAGMA user_version = 5", []).map_err(|e| { - StashError::Store(format!("Failed to set schema version: {e}").into()) - })?; - } - - // 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 +266,18 @@ 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) -> Result { let mut stmt = self .conn - .prepare(&query) + .prepare( + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) DESC, id DESC", + ) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -620,42 +324,25 @@ 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); + let mime = detect_mime_optimized(&buf); // Try to load regex from systemd credential file, then env var let regex = load_sensitive_regex(); @@ -679,21 +366,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 +378,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()))?; @@ -810,14 +486,13 @@ impl ClipboardDb for SqliteClipboardDb { &self, 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 mut stmt = self .conn - .prepare(&query) + .prepare( + "SELECT id, contents, mime FROM clipboard ORDER BY \ + COALESCE(last_accessed, 0) DESC, id DESC", + ) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -875,7 +550,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(()) } @@ -965,181 +640,16 @@ impl ClipboardDb for SqliteClipboardDb { Ok((id, contents, mime)) } -} -impl SqliteClipboardDb { - /// Count visible clipboard entries, with respect to `include_expired` and - /// optional search filter. - pub fn count_entries( - &self, - include_expired: bool, - search: Option<&str>, - ) -> Result { - let builder = - ListQueryBuilder::new(include_expired, false).with_search(search); - let query = builder.count_query(); - - 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)) + fn next_sequence(&self) -> i64 { + match self + .conn + .query_row("SELECT MAX(id) FROM clipboard", [], |row| { + row.get::<_, Option>(0) + }) { + Ok(Some(max_id)) => max_id + 1, + Ok(None) | Err(_) => 1, } - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - Ok(count.max(0) as usize) - } - - /// Fetch a window of entries for TUI virtual scrolling. - /// - /// Returns `(id, preview_string, mime_string)` tuples for at most - /// `limit` rows starting at `offset` (0-indexed) in the canonical - /// display order (most-recently-accessed first, then id DESC). - /// Optionally filters by search query in a case-insensitive nabber on text - /// content. - pub fn fetch_entries_window( - &self, - include_expired: bool, - offset: usize, - limit: usize, - preview_width: u32, - search: Option<&str>, - 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 mut stmt = self - .conn - .prepare(&query) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - - let mut rows = if let Some(pattern) = builder.search_param() { - stmt - .query(rusqlite::params![pattern]) - .map_err(|e| StashError::ListDecode(e.to_string().into()))? - } else { - stmt - .query([]) - .map_err(|e| StashError::ListDecode(e.to_string().into()))? - }; - - let mut window = Vec::with_capacity(limit); - while let Some(row) = rows - .next() - .map_err(|e| StashError::ListDecode(e.to_string().into()))? - { - let id: i64 = row - .get(0) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mime: Option = row - .get(2) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let preview = preview_entry(&contents, mime.as_deref(), preview_width); - let mime_str = mime.unwrap_or_default(); - window.push((id, preview, mime_str)); - } - Ok(window) - } - - /// Get current Unix timestamp with sub-second precision - pub fn now() -> f64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs_f64() - } - - /// Clean up all expired entries. Returns count deleted. - pub fn cleanup_expired(&self) -> Result { - let now = Self::now(); - self - .conn - .execute( - "DELETE FROM clipboard WHERE expires_at IS NOT NULL AND expires_at <= \ - ?1", - [now], - ) - .map_err(|e| StashError::Trim(e.to_string().into())) - } - - /// Set expiration timestamp for an entry - pub fn set_expiration( - &self, - id: i64, - expires_at: f64, - ) -> Result<(), StashError> { - self - .conn - .execute( - "UPDATE clipboard SET expires_at = ?2 WHERE id = ?1", - params![id, expires_at], - ) - .map_err(|e| StashError::Store(e.to_string().into()))?; - Ok(()) - } - - /// Optimize database using VACUUM - pub fn vacuum(&self) -> Result<(), StashError> { - self - .conn - .execute("VACUUM", []) - .map_err(|e| StashError::Store(e.to_string().into()))?; - Ok(()) - } - - /// Get database statistics - pub fn stats(&self) -> Result { - let total: i64 = self - .conn - .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - - let expired: i64 = self - .conn - .query_row( - "SELECT COUNT(*) FROM clipboard WHERE is_expired = 1", - [], - |row| row.get(0), - ) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - - let active = total - expired; - - let with_expiration: i64 = self - .conn - .query_row( - "SELECT COUNT(*) FROM clipboard WHERE expires_at IS NOT NULL AND \ - (is_expired IS NULL OR is_expired = 0)", - [], - |row| row.get(0), - ) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - - // Get database file size - let page_count: i64 = self - .conn - .query_row("PRAGMA page_count", [], |row| row.get(0)) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - - let page_size: i64 = self - .conn - .query_row("PRAGMA page_size", [], |row| row.get(0)) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - - let size_bytes = page_count * page_size; - let size_mb = size_bytes as f64 / 1024.0 / 1024.0; - - Ok(format!( - "Database Statistics:\n\nEntries:\nTotal: {total}\nActive: \ - {active}\nExpired: {expired}\nWith TTL: \ - {with_expiration}\n\nStorage:\nSize: {size_mb:.2} MB \ - ({size_bytes} bytes)\nPages: {page_count}\nPage size: \ - {page_size} bytes" - )) } } @@ -1148,41 +658,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 { @@ -1190,6 +690,51 @@ pub fn extract_id(input: &str) -> Result { id_str.parse().map_err(|_| "invalid id") } +pub fn detect_mime_optimized(data: &[u8]) -> Option { + // Check if it's valid UTF-8 first, which most clipboard content are. + // This will be used to return early without unnecessary mimetype detection + // overhead. + if std::str::from_utf8(data).is_ok() { + return Some("text/plain".to_string()); + } + + // Only run image detection on binary data + detect_mime(data) +} + +pub fn detect_mime(data: &[u8]) -> Option { + if let Ok(img_type) = imagesize::image_type(data) { + let mime_str = match img_type { + ImageType::Png => "image/png", + ImageType::Jpeg => "image/jpeg", + ImageType::Gif => "image/gif", + ImageType::Bmp => "image/bmp", + ImageType::Tiff => "image/tiff", + ImageType::Webp => "image/webp", + ImageType::Aseprite => "image/x-aseprite", + ImageType::Dds => "image/vnd.ms-dds", + ImageType::Exr => "image/aces", + ImageType::Farbfeld => "image/farbfeld", + ImageType::Hdr => "image/vnd.radiance", + ImageType::Ico => "image/x-icon", + ImageType::Ilbm => "image/ilbm", + ImageType::Jxl => "image/jxl", + ImageType::Ktx2 => "image/ktx2", + ImageType::Pnm => "image/x-portable-anymap", + ImageType::Psd => "image/vnd.adobe.photoshop", + ImageType::Qoi => "image/qoi", + ImageType::Tga => "image/x-tga", + ImageType::Vtf => "image/x-vtf", + ImageType::Heif(imagesize::Compression::Hevc) => "image/heic", + ImageType::Heif(_) => "image/heif", + _ => "application/octet-stream", + }; + Some(mime_str.to_string()) + } else { + None + } +} + pub fn preview_entry(data: &[u8], mime: Option<&str>, width: u32) -> String { if let Some(mime) = mime { if mime.starts_with("image/") { @@ -1224,14 +769,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 +840,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 +871,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"); @@ -1458,14 +1013,6 @@ mod tests { use super::*; - /// Create an in-memory test database with full schema. - fn test_db() -> SqliteClipboardDb { - let conn = - Connection::open_in_memory().expect("Failed to open in-memory db"); - SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) - .expect("Failed to create test database") - } - fn get_schema_version(conn: &Connection) -> rusqlite::Result { conn.pragma_query_value(None, "user_version", |row| row.get(0)) } @@ -1494,17 +1041,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 + 3 ); 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 +1091,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 + 3 ); 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 +1133,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 + 3 ); 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 +1176,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 + 3 ); 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 +1207,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, 3); } #[test] @@ -1687,555 +1225,134 @@ 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"), + 3 + ); + let count: i64 = db .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) .expect("Failed to count"); assert_eq!(count, 1, "Existing data should be preserved"); } - - #[test] - fn test_store_uri_list_content() { - let db = test_db(); - let data = b"file:///home/user/document.pdf\nfile:///home/user/image.png"; - let id = db - .store_entry( - std::io::Cursor::new(data.to_vec()), - 100, - 1000, - None, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, - ) - .expect("Failed to store URI list"); - - let mime: Option = db - .conn - .query_row("SELECT mime FROM clipboard WHERE id = ?1", [id], |row| { - row.get(0) - }) - .expect("Failed to get mime"); - assert_eq!(mime, Some("text/uri-list".to_string())); - } - - #[test] - fn test_store_binary_image() { - let db = test_db(); - // Minimal PNG header - let data: Vec = vec![ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature - 0x00, 0x00, 0x00, 0x0D, // IHDR chunk length - 0x49, 0x48, 0x44, 0x52, // "IHDR" - 0x00, 0x00, 0x00, 0x01, // width: 1 - 0x00, 0x00, 0x00, 0x01, // height: 1 - 0x08, 0x02, 0x00, 0x00, 0x00, // bit depth, color, etc. - 0x90, 0x77, 0x53, 0xDE, // CRC - ]; - let id = db - .store_entry( - std::io::Cursor::new(data.clone()), - 100, - 1000, - None, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, - ) - .expect("Failed to store image"); - - let (contents, mime): (Vec, Option) = db - .conn - .query_row( - "SELECT contents, mime FROM clipboard WHERE id = ?1", - [id], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .expect("Failed to get stored entry"); - assert_eq!(contents, data); - assert_eq!(mime, Some("image/png".to_string())); - } - - #[test] - fn test_deduplication() { - let db = test_db(); - let data = b"duplicate content"; - - let id1 = db - .store_entry( - std::io::Cursor::new(data.to_vec()), - 100, - 1000, - None, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - 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, - ) - .expect("Failed to store second"); - - // First entry should have been removed by deduplication - let count: i64 = db - .conn - .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) - .expect("Failed to count"); - assert_eq!(count, 1, "Deduplication should keep only one copy"); - - // The original id should be gone - let exists: bool = db - .conn - .query_row( - "SELECT COUNT(*) FROM clipboard WHERE id = ?1", - [id1], - |row| row.get::<_, i64>(0), - ) - .map(|c| c > 0) - .unwrap_or(false); - assert!(!exists, "Old entry should be removed"); - } - - #[test] - fn test_trim_excess_entries() { - let db = test_db(); - for i in 0..5 { - let data = format!("entry {i}"); - db.store_entry( - std::io::Cursor::new(data.into_bytes()), - 100, - 3, // max 3 items - None, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, - ) - .expect("Failed to store"); - } - - let count: i64 = db - .conn - .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) - .expect("Failed to count"); - assert!(count <= 3, "Trim should keep at most max_items entries"); - } - - #[test] - fn test_reject_empty_input() { - let db = test_db(); - let result = db.store_entry( - std::io::Cursor::new(Vec::new()), - 100, - 1000, - None, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, - ); - assert!(matches!(result, Err(StashError::EmptyOrTooLarge))); - } - - #[test] - fn test_reject_whitespace_input() { - let db = test_db(); - let result = db.store_entry( - std::io::Cursor::new(b" \n\t ".to_vec()), - 100, - 1000, - None, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, - ); - assert!(matches!(result, Err(StashError::AllWhitespace))); - } - - #[test] - fn test_reject_oversized_input() { - let db = test_db(); - // 5MB + 1 byte - let data = vec![b'a'; 5 * 1_000_000 + 1]; - let result = db.store_entry( - std::io::Cursor::new(data), - 100, - 1000, - None, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, - ); - assert!(matches!(result, Err(StashError::TooLarge(5000000)))); - } - - #[test] - fn test_delete_entries_by_id() { - let db = test_db(); - let id = db - .store_entry( - std::io::Cursor::new(b"to delete".to_vec()), - 100, - 1000, - None, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, - ) - .expect("Failed to store"); - - let input = format!("{id}\tpreview text\n"); - let deleted = db - .delete_entries(std::io::Cursor::new(input.into_bytes())) - .expect("Failed to delete"); - assert_eq!(deleted, 1); - - let count: i64 = db - .conn - .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) - .expect("Failed to count"); - assert_eq!(count, 0); - } - - #[test] - fn test_delete_query_matching() { - let db = test_db(); - db.store_entry( - std::io::Cursor::new(b"secret password 123".to_vec()), - 100, - 1000, - None, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, - ) - .expect("Failed to store"); - db.store_entry( - std::io::Cursor::new(b"normal text".to_vec()), - 100, - 1000, - None, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, - ) - .expect("Failed to store"); - - let deleted = db - .delete_query("secret password") - .expect("Failed to delete query"); - assert_eq!(deleted, 1); - - let count: i64 = db - .conn - .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) - .expect("Failed to count"); - assert_eq!(count, 1); - } - - #[test] - fn test_wipe_db() { - let db = test_db(); - for i in 0..3 { - let data = format!("entry {i}"); - db.store_entry( - std::io::Cursor::new(data.into_bytes()), - 100, - 1000, - None, - None, - DEFAULT_MAX_ENTRY_SIZE, - None, - None, - ) - .expect("Failed to store"); - } - - db.wipe_db().expect("Failed to wipe"); - - let count: i64 = db - .conn - .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) - .expect("Failed to count"); - assert_eq!(count, 0); - } - - #[test] - fn test_extract_id_valid() { - assert_eq!(extract_id("42\tsome preview"), Ok(42)); - assert_eq!(extract_id("1"), Ok(1)); - assert_eq!(extract_id("999\t"), Ok(999)); - } - - #[test] - fn test_extract_id_invalid() { - assert!(extract_id("abc\tpreview").is_err()); - assert!(extract_id("").is_err()); - assert!(extract_id("\tpreview").is_err()); - } - - #[test] - fn test_preview_entry_text() { - let data = b"Hello, world!"; - let preview = preview_entry(data, Some("text/plain"), 100); - assert_eq!(preview, "Hello, world!"); - } - - #[test] - fn test_preview_entry_image() { - let data = vec![0x89, 0x50, 0x4E, 0x47]; // PNG-ish bytes - let preview = preview_entry(&data, Some("image/png"), 100); - assert!(preview.contains("binary data")); - assert!(preview.contains("image/png")); - } - - #[test] - fn test_preview_entry_truncation() { - let data = b"This is a rather long piece of text that should be truncated"; - let preview = preview_entry(data, Some("text/plain"), 10); - assert!(preview.len() <= 15); // 10 chars + ellipsis (multi-byte) - assert!(preview.ends_with('…')); - } - - #[test] - fn test_size_str_formatting() { - assert_eq!(size_str(0), "0 B"); - assert_eq!(size_str(512), "512 B"); - assert_eq!(size_str(1024), "1 KiB"); - assert_eq!(size_str(1024 * 1024), "1 MiB"); - } - - #[test] - fn test_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, - ) - .expect("Failed to store"); - - let (returned_id, contents, mime) = - db.copy_entry(id).expect("Failed to copy"); - assert_eq!(returned_id, id); - assert_eq!(contents, data.to_vec()); - assert_eq!(mime, Some("text/plain".to_string())); - } - - #[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..28f9fb0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,39 +1,26 @@ -mod clipboard; -mod commands; -mod db; -mod hash; -mod mime; -mod multicall; - use std::{ env, io::{self, IsTerminal}, path::PathBuf, - time::Duration, }; 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; +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 +39,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)] @@ -94,14 +71,6 @@ enum Command { /// Output format: "tsv" (default) or "json" #[arg(long, value_parser = ["tsv", "json"])] format: Option, - - /// Show only expired entries (diagnostic, does not remove them) - #[arg(long)] - expired: bool, - - /// Reverse the order of entries (oldest first instead of newest first) - #[arg(long)] - reverse: bool, }, /// Decode and output clipboard entry by id @@ -123,10 +92,11 @@ enum Command { ask: bool, }, - /// Database management operations - Db { - #[command(subcommand)] - action: DbAction, + /// Wipe all clipboard history + Wipe { + /// Ask for confirmation before wiping + #[arg(long)] + ask: bool, }, /// Import clipboard data from stdin (default: TSV format) @@ -141,39 +111,7 @@ enum Command { }, /// Start a process to watch clipboard for changes and store automatically. - Watch { - /// Expire new entries after duration (e.g., "3s", "500ms", "1h30m"). - #[arg(long, value_parser = parse_duration)] - expire_after: Option, - - /// 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, - }, -} - -#[derive(Subcommand)] -enum DbAction { - /// Wipe database entries - Wipe { - /// Only wipe expired entries instead of all entries - #[arg(long)] - expired: bool, - - /// Ask for confirmation before wiping - #[arg(long)] - ask: bool, - }, - - /// Optimize database using VACUUM - Vacuum, - - /// Show database statistics - Stats, + Watch, } fn report_error( @@ -189,27 +127,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 +155,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 +182,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 }) => { match format.as_deref() { Some("tsv") => { report_error( - db.list(io::stdout(), cli.preview_width, expired, reverse), + db.list(io::stdout(), cli.preview_width), "failed to list entries", ); }, Some("json") => { - match db.list_json(expired, reverse) { + match db.list_json() { Ok(json) => { println!("{json}"); }, @@ -302,12 +210,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), "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), "failed to list entries", ); } @@ -324,7 +232,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,67 +286,34 @@ fn main() -> eyre::Result<()> { } } }, - - Some(Command::Db { action }) => { - match action { - DbAction::Wipe { expired, ask } => { - let mut should_proceed = true; - if ask { - let message = if expired { - "Are you sure you want to wipe all expired clipboard entries?" - } else { - "Are you sure you want to wipe ALL clipboard history?" - }; - should_proceed = confirm(message); - if !should_proceed { - log::info!("db wipe command aborted by user."); - } - } - if should_proceed { - if expired { - match db.cleanup_expired() { - Ok(count) => { - log::info!("wiped {count} expired entries"); - }, - Err(e) => { - log::error!("failed to wipe expired entries: {e}"); - }, - } - } else { - report_error(db.wipe_db(), "failed to wipe database"); - } - } - }, - DbAction::Vacuum => { - match db.vacuum() { - Ok(()) => { - log::info!("database optimized successfully"); - }, - Err(e) => { - log::error!("failed to vacuum database: {e}"); - }, - } - }, - DbAction::Stats => { - match db.stats() { - Ok(stats) => { - println!("{stats}"); - }, - Err(e) => { - log::error!("failed to get database stats: {e}"); - }, - } - }, + Some(Command::Wipe { ask }) => { + 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::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."); } @@ -456,11 +334,7 @@ fn main() -> eyre::Result<()> { } } }, - Some(Command::Watch { - expire_after, - mime_type, - persist, - }) => { + Some(Command::Watch) => { db.watch( cli.max_dedupe_search, cli.max_items, @@ -468,13 +342,7 @@ fn main() -> eyre::Result<()> { &cli.excluded_apps, #[cfg(not(feature = "use-toplevel"))] &[], - expire_after, - &mime_type, - cli.min_size, - cli.max_size, - persist, - ) - .await; + ); }, None => { diff --git a/src/mime.rs b/src/mime.rs deleted file mode 100644 index 3761ab3..0000000 --- a/src/mime.rs +++ /dev/null @@ -1,273 +0,0 @@ -use imagesize::ImageType; - -/// Detect MIME type of clipboard data. We try binary detection first using -/// [`imagesize`] followed by a check for text/uri-list for file manager copies -/// and finally fall back to text/plain for UTF-8 or [`None`] for binary. -pub fn detect_mime(data: &[u8]) -> Option { - if data.is_empty() { - return None; - } - - // Try image detection first - if let Ok(img_type) = imagesize::image_type(data) { - return Some(image_type_to_mime(img_type)); - } - - // Check if it's UTF-8 text - if let Ok(text) = std::str::from_utf8(data) { - let trimmed = text.trim(); - - // Check for text/uri-list format (file paths from file managers) - if is_uri_list(trimmed) { - return Some("text/uri-list".to_string()); - } - - // Default to plain text - return Some("text/plain".to_string()); - } - - // Unknown binary data - None -} - -/// Convert [`imagesize`] [`ImageType`] to MIME type string -fn image_type_to_mime(img_type: ImageType) -> String { - let mime = match img_type { - ImageType::Png => "image/png", - ImageType::Jpeg => "image/jpeg", - ImageType::Gif => "image/gif", - ImageType::Bmp => "image/bmp", - ImageType::Tiff => "image/tiff", - ImageType::Webp => "image/webp", - ImageType::Aseprite => "image/x-aseprite", - ImageType::Dds => "image/vnd.ms-dds", - ImageType::Exr => "image/aces", - ImageType::Farbfeld => "image/farbfeld", - ImageType::Hdr => "image/vnd.radiance", - ImageType::Ico => "image/x-icon", - ImageType::Ilbm => "image/ilbm", - ImageType::Jxl => "image/jxl", - ImageType::Ktx2 => "image/ktx2", - ImageType::Pnm => "image/x-portable-anymap", - ImageType::Psd => "image/vnd.adobe.photoshop", - ImageType::Qoi => "image/qoi", - ImageType::Tga => "image/x-tga", - ImageType::Vtf => "image/x-vtf", - ImageType::Heif(imagesize::Compression::Hevc) => "image/heic", - ImageType::Heif(_) => "image/heif", - _ => "application/octet-stream", - }; - mime.to_string() -} - -/// Check if text is a URI list per RFC 2483. -/// -/// Used when copying files from file managers - they provide file paths -/// as text/uri-list format (`file://` URIs, one per line, `#` for comments). -fn is_uri_list(text: &str) -> bool { - if text.is_empty() { - return false; - } - - // Must start with a URI scheme to even consider it - if !text.starts_with("file://") - && !text.starts_with("http://") - && !text.starts_with("https://") - && !text.starts_with("ftp://") - && !text.starts_with('#') - { - return false; - } - - let lines: Vec<&str> = text.lines().map(str::trim).collect(); - - // Check first non-comment line is a URI - let first_content = - lines.iter().find(|l| !l.is_empty() && !l.starts_with('#')); - - if let Some(line) = first_content { - line.starts_with("file://") - || line.starts_with("http://") - || line.starts_with("https://") - || line.starts_with("ftp://") - } else { - false - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_empty_data() { - assert_eq!(detect_mime(b""), None); - } - - #[test] - fn test_plain_text() { - let data = b"Hello, world!"; - assert_eq!(detect_mime(data), Some("text/plain".to_string())); - } - - #[test] - fn test_uri_list_single_file() { - let data = b"file:///home/user/document.pdf"; - assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); - } - - #[test] - fn test_uri_list_multiple_files() { - let data = b"file:///home/user/file1.txt\nfile:///home/user/file2.txt"; - assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); - } - - #[test] - fn test_uri_list_with_comments() { - let data = b"# Comment\nfile:///home/user/file.txt"; - assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); - } - - #[test] - fn test_uri_list_http() { - let data = b"https://example.com/image.png"; - assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); - } - - #[test] - fn test_not_uri_list() { - let data = b"This is just text with file:// in the middle"; - assert_eq!(detect_mime(data), Some("text/plain".to_string())); - } - - #[test] - fn test_unknown_binary() { - // Binary data that's not UTF-8 and not a known format - let data = b"\x80\x81\x82\x83\x84\x85\x86\x87"; - assert_eq!(detect_mime(data), None); - } - - #[test] - fn test_uri_list_trailing_newline() { - let data = b"file:///foo\n"; - assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); - } - - #[test] - fn test_uri_list_ftp() { - let data = b"ftp://host/path"; - assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); - } - - #[test] - fn test_uri_list_mixed_schemes() { - let data = b"file:///home/user/doc.pdf\nhttps://example.com/file.zip"; - assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); - } - - #[test] - fn test_plain_url_in_text() { - let data = b"visit http://example.com for info"; - assert_eq!(detect_mime(data), Some("text/plain".to_string())); - } - - #[test] - fn test_png_magic_bytes() { - // Real PNG header: 8-byte signature + minimal IHDR chunk - let data: &[u8] = &[ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature - 0x00, 0x00, 0x00, 0x0D, // IHDR chunk length - 0x49, 0x48, 0x44, 0x52, // "IHDR" - 0x00, 0x00, 0x00, 0x01, // width: 1 - 0x00, 0x00, 0x00, 0x01, // height: 1 - 0x08, 0x02, // bit depth: 8, color type: 2 (RGB) - 0x00, 0x00, 0x00, // compression, filter, interlace - 0x90, 0x77, 0x53, 0xDE, // CRC - ]; - assert_eq!(detect_mime(data), Some("image/png".to_string())); - } - - #[test] - fn test_jpeg_magic_bytes() { - // JPEG SOI marker + APP0 (JFIF) marker - let data: &[u8] = &[ - 0xFF, 0xD8, 0xFF, 0xE0, // SOI + APP0 - 0x00, 0x10, // Length - 0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0" - 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, - ]; - assert_eq!(detect_mime(data), Some("image/jpeg".to_string())); - } - - #[test] - fn test_gif_magic_bytes() { - // GIF89a header - let data: &[u8] = &[ - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // "GIF89a" - 0x01, 0x00, 0x01, 0x00, // 1x1 - 0x80, 0x00, 0x00, // GCT flag, bg, aspect - ]; - assert_eq!(detect_mime(data), Some("image/gif".to_string())); - } - - #[test] - fn test_webp_magic_bytes() { - // RIFF....WEBP header - let data: &[u8] = &[ - 0x52, 0x49, 0x46, 0x46, // "RIFF" - 0x24, 0x00, 0x00, 0x00, // file size - 0x57, 0x45, 0x42, 0x50, // "WEBP" - 0x56, 0x50, 0x38, 0x20, // "VP8 " - 0x18, 0x00, 0x00, 0x00, // chunk size - 0x30, 0x01, 0x00, 0x9D, 0x01, 0x2A, // VP8 bitstream - 0x01, 0x00, 0x01, 0x00, // width/height - ]; - assert_eq!(detect_mime(data), Some("image/webp".to_string())); - } - - #[test] - fn test_whitespace_only() { - let data = b" \n\t "; - // Valid UTF-8 text, even if only whitespace. [`detect_mime`] doesn't reject - // it (store_entry rejects it separately). As text it's text/plain. - assert_eq!(detect_mime(data), Some("text/plain".to_string())); - } - - #[test] - fn test_image_type_to_mime_coverage() { - assert_eq!(image_type_to_mime(ImageType::Png), "image/png"); - assert_eq!(image_type_to_mime(ImageType::Jpeg), "image/jpeg"); - assert_eq!(image_type_to_mime(ImageType::Gif), "image/gif"); - assert_eq!(image_type_to_mime(ImageType::Bmp), "image/bmp"); - assert_eq!(image_type_to_mime(ImageType::Tiff), "image/tiff"); - assert_eq!(image_type_to_mime(ImageType::Webp), "image/webp"); - assert_eq!(image_type_to_mime(ImageType::Aseprite), "image/x-aseprite"); - assert_eq!(image_type_to_mime(ImageType::Dds), "image/vnd.ms-dds"); - assert_eq!(image_type_to_mime(ImageType::Exr), "image/aces"); - assert_eq!(image_type_to_mime(ImageType::Farbfeld), "image/farbfeld"); - assert_eq!(image_type_to_mime(ImageType::Hdr), "image/vnd.radiance"); - assert_eq!(image_type_to_mime(ImageType::Ico), "image/x-icon"); - assert_eq!(image_type_to_mime(ImageType::Ilbm), "image/ilbm"); - assert_eq!(image_type_to_mime(ImageType::Jxl), "image/jxl"); - assert_eq!(image_type_to_mime(ImageType::Ktx2), "image/ktx2"); - assert_eq!( - image_type_to_mime(ImageType::Pnm), - "image/x-portable-anymap" - ); - assert_eq!( - image_type_to_mime(ImageType::Psd), - "image/vnd.adobe.photoshop" - ); - assert_eq!(image_type_to_mime(ImageType::Qoi), "image/qoi"); - assert_eq!(image_type_to_mime(ImageType::Tga), "image/x-tga"); - assert_eq!(image_type_to_mime(ImageType::Vtf), "image/x-vtf"); - assert_eq!( - image_type_to_mime(ImageType::Heif(imagesize::Compression::Hevc)), - "image/heic" - ); - assert_eq!( - image_type_to_mime(ImageType::Heif(imagesize::Compression::Av1)), - "image/heif" - ); - } -} 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()); } } },