diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index aa30540..4bbfe7c 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -1,13 +1,23 @@ version: 2 updates: - # Update Cargo deps - - package-ecosystem: cargo - directory: "/" - schedule: - interval: "weekly" - # Update used workflows - package-ecosystem: github-actions directory: "/" schedule: interval: daily + + # Update Cargo deps + - package-ecosystem: cargo + directory: "/" + cooldown: + default-days: 7 + schedule: + interval: "weekly" + + # Update Nixpkgs & Crane + - package-ecosystem: nix + directory: "/" + cooldown: + default-days: 7 + schedule: + interval: daily diff --git a/.github/workflows/nix-cache.yaml b/.github/workflows/nix-cache.yaml index 2ffb411..8936c67 100644 --- a/.github/workflows/nix-cache.yaml +++ b/.github/workflows/nix-cache.yaml @@ -14,13 +14,13 @@ jobs: runs-on: ubuntu-latest steps: - name: "Checkout" - uses: actions/checkout@v5 + uses: actions/checkout@v6 - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v16 + - uses: cachix/cachix-action@v17 with: name: nyx authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ab9d8a6..62cfe82 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - uses: cachix/install-nix-action@v31 with: @@ -40,7 +40,7 @@ jobs: steps: - name: Create Release id: create_release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: draft: false prerelease: false @@ -62,7 +62,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -98,7 +98,7 @@ jobs: cp target/${{ matrix.target }}/release/stash ${{ matrix.name }} - name: Upload Release Asset - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: files: ${{ matrix.name }} @@ -106,7 +106,7 @@ jobs: needs: [create-release, build-release] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Download Assets uses: robinraju/release-downloader@v1 @@ -120,7 +120,7 @@ jobs: sha256sum stash-* > SHA256SUMS - name: Upload Checksums - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: token: ${{ secrets.GITHUB_TOKEN }} files: SHA256SUMS diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 360ef75..0ae86c8 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Build run: cargo build --verbose diff --git a/.gitignore b/.gitignore index ad467a2..99b71d1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ !/nix !/src -!/vendor +!/contrib # Rust/Cargo !/Cargo.lock diff --git a/.rustfmt.toml b/.rustfmt.toml index cb120a3..9d5c77e 100644 --- a/.rustfmt.toml +++ b/.rustfmt.toml @@ -1,6 +1,6 @@ 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 @@ -24,4 +24,3 @@ unstable_features = true use_field_init_shorthand = true use_try_shorthand = true wrap_comments = true - diff --git a/.taplo.toml b/.taplo.toml new file mode 100644 index 0000000..fae0c57 --- /dev/null +++ b/.taplo.toml @@ -0,0 +1,13 @@ +[formatting] +align_entries = true +column_width = 110 +compact_arrays = false +reorder_inline_tables = false +reorder_keys = true + +[[rule]] +include = [ "**/Cargo.toml" ] +keys = [ "package" ] + +[rule.formatting] +reorder_keys = false diff --git a/Cargo.lock b/Cargo.lock index 0cad45c..3c753bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,10 +3,25 @@ version = 4 [[package]] -name = "aho-corasick" -version = "1.1.3" +name = "addr2line" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -19,9 +34,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -34,37 +49,52 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys", ] [[package]] name = "anstyle-wincon" -version = "3.0.10" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +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", ] [[package]] @@ -93,9 +123,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", @@ -129,16 +159,16 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.2", + "rustix", "slab", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "async-lock" -version = "3.4.1" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ "event-listener", "event-listener-strategy", @@ -171,7 +201,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.2", + "rustix", ] [[package]] @@ -182,14 +212,14 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "async-signal" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ "async-io", "async-lock", @@ -197,10 +227,10 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.2", + "rustix", "signal-hook-registry", "slab", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -217,7 +247,16 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", ] [[package]] @@ -232,6 +271,21 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + [[package]] name = "base64" version = "0.22.1" @@ -239,10 +293,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "bitflags" -version = "2.9.4" +name = "bit-set" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] [[package]] name = "block2" @@ -267,10 +351,16 @@ dependencies = [ ] [[package]] -name = "cassowary" -version = "0.3.0" +name = "bumpalo" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "castaway" @@ -283,9 +373,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.41" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9fe6cdbb24b6ade63616c0a0688e45bb56732262c158df3c0c4bea4ca47cb7" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "shlex", @@ -293,9 +383,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -305,9 +395,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "clap" -version = "4.5.49" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4512b90fa68d3a9932cea5184017c5d200f5921df706d45e853537dea51508f" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -325,9 +415,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.49" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0025e98baa12e766c67ba13ff4695a887a1eba19569aad00a472546795bd6730" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -337,33 +427,60 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compact_str" -version = "0.8.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" dependencies = [ "castaway", "cfg-if", @@ -384,48 +501,41 @@ dependencies = [ [[package]] name = "convert_case" -version = "0.7.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crossterm" -version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" -dependencies = [ - "bitflags", - "crossterm_winapi", - "mio", - "parking_lot", - "rustix 0.38.44", - "signal-hook", - "signal-hook-mio", - "winapi", -] - [[package]] name = "crossterm" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags", + "bitflags 2.11.0", "crossterm_winapi", "derive_more", "document-features", "mio", "parking_lot", - "rustix 1.1.2", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -441,10 +551,41 @@ dependencies = [ ] [[package]] -name = "darling" -version = "0.20.11" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix 0.31.2", + "windows-sys", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ "darling_core", "darling_macro", @@ -452,57 +593,73 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.11" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] name = "darling_macro" -version = "0.20.11" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.117", ] [[package]] -name = "deranged" -version = "0.5.4" +name = "deltae" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", ] [[package]] name = "derive_more" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ "convert_case", "proc-macro2", "quote", - "syn", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", ] [[package]] @@ -523,24 +680,37 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.11.0", + "block2", + "libc", "objc2", ] [[package]] -name = "document-features" -version = "0.2.11" +name = "displaydoc" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ "litrs", ] @@ -565,9 +735,9 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "endi" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enumflags2" @@ -587,14 +757,14 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "env_filter" -version = "0.1.4" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", "regex", @@ -602,9 +772,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ "anstream", "anstyle", @@ -626,7 +796,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", ] [[package]] @@ -650,6 +829,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -663,16 +852,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] -name = "fastrand" -version = "2.3.0" +name = "fancy-regex" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" [[package]] name = "fixedbitset" @@ -680,6 +896,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "fnv" version = "1.0.7" @@ -693,16 +915,67 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "futures-core" -version = "0.3.31" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -718,52 +991,130 @@ dependencies = [ ] [[package]] -name = "getrandom" -version = "0.2.16" +name = "futures-macro" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ - "cfg-if", - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", ] [[package]] name = "getrandom" -version = "0.3.3" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "r-efi", - "wasi 0.14.7+wasi-0.2.4", + "wasi", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "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.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -784,12 +1135,127 @@ 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" @@ -797,113 +1263,162 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] -name = "indexmap" -version = "2.11.4" +name = "indenter" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.17.0", + "serde", + "serde_core", ] [[package]] name = "indoc" -version = "2.0.6" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] [[package]] name = "inquire" -version = "0.9.1" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2628910d0114e9139056161d8644a2026be7b117f8498943f9437748b04c9e0a" +checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" dependencies = [ - "bitflags", - "crossterm 0.29.0", + "bitflags 2.11.0", + "crossterm", "dyn-clone", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width", ] [[package]] name = "instability" -version = "0.3.9" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ "darling", "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.15" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" dependencies = [ "jiff-static", "log", "portable-atomic", "portable-atomic-util", - "serde", + "serde_core", ] [[package]] name = "jiff-static" -version = "0.2.15" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] -name = "libc" -version = "0.2.177" +name = "js-sys" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.18", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +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.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags", "libc", ] [[package]] name = "libsqlite3-sys" -version = "0.35.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", "pkg-config", @@ -911,22 +1426,31 @@ dependencies = [ ] [[package]] -name = "linux-raw-sys" -version = "0.4.15" +name = "line-clipping" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.11.0", +] [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "litrs" -version = "0.4.2" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" @@ -939,24 +1463,24 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" -version = "0.12.5" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] name = "mac-notification-sys" -version = "0.6.6" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "119c8490084af61b44c9eda9d626475847a186737c0378c85e32d77c33a01cd4" +checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" dependencies = [ "cc", "objc2", @@ -965,10 +1489,26 @@ dependencies = [ ] [[package]] -name = "memchr" -version = "2.7.6" +name = "mac_address" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix 0.29.0", + "winapi", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" [[package]] name = "memoffset" @@ -979,6 +1519,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime-sniffer" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b8b2a64cd735f1d5f17ff6701ced3cc3c54851f9448caf454cd9c923d812408" +dependencies = [ + "mime", + "url", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -986,30 +1542,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] -name = "mio" -version = "1.0.4" +name = "miniz_oxide" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", + "wasi", + "windows-sys", ] [[package]] name = "nix" -version = "0.30.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", "memoffset", ] +[[package]] +name = "nix" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -1021,10 +1598,19 @@ dependencies = [ ] [[package]] -name = "notify-rust" -version = "4.11.7" +name = "nom" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "notify-rust" +version = "4.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b2c9bc1689653cfbc04400b8719f2562638ff9c545bbd48cc58c657a14526df" dependencies = [ "futures-lite", "log", @@ -1036,15 +1622,44 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] @@ -1055,7 +1670,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.11.0", "dispatch2", "objc2", ] @@ -1072,7 +1687,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.11.0", "block2", "libc", "objc2", @@ -1080,16 +1695,25 @@ dependencies = [ ] [[package]] -name = "once_cell" -version = "1.21.3" +name = "object" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "option-ext" @@ -1097,6 +1721,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -1114,9 +1747,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + [[package]] name = "parking" version = "2.2.1" @@ -1147,32 +1786,128 @@ dependencies = [ ] [[package]] -name = "paste" -version = "1.0.15" +name = "percent-encoding" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] [[package]] name = "petgraph" -version = "0.6.5" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "fixedbitset", + "fixedbitset 0.5.7", + "hashbrown 0.15.5", "indexmap", ] [[package]] -name = "pin-project-lite" -version = "0.2.16" +name = "phf" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -1181,9 +1916,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "polling" @@ -1195,25 +1930,34 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.2", - "windows-sys 0.61.2", + "rustix", + "windows-sys", ] [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" 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" @@ -1221,19 +1965,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] -name = "proc-macro-crate" -version = "3.4.0" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ "toml_edit", ] [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -1248,10 +2002,19 @@ dependencies = [ ] [[package]] -name = "quote" -version = "1.0.41" +name = "quick-xml" +version = "0.39.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -1263,24 +2026,109 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] -name = "ratatui" -version = "0.29.0" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ - "bitflags", - "cassowary", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.11.0", "compact_str", - "crossterm 0.28.1", + "hashbrown 0.16.1", + "indoc", + "itertools", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.16.1", "indoc", "instability", "itertools", - "lru", - "paste", + "line-clipping", + "ratatui-core", "strum", + "time", "unicode-segmentation", - "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width", ] [[package]] @@ -1289,7 +2137,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -1298,16 +2146,16 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.18", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -1317,9 +2165,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -1328,48 +2176,61 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] [[package]] name = "rusqlite" -version = "0.37.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ - "bitflags", + "bitflags 2.11.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", ] [[package]] name = "rustix" -version = "0.38.44" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustix" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "linux-raw-sys", + "windows-sys", ] [[package]] @@ -1380,9 +2241,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "scopeguard" @@ -1390,6 +2251,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1417,20 +2284,20 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -1441,7 +2308,27 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", ] [[package]] @@ -1462,9 +2349,9 @@ dependencies = [ [[package]] name = "signal-hook-mio" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", "mio", @@ -1473,18 +2360,25 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] [[package]] -name = "slab" -version = "0.4.11" +name = "siphasher" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -1510,18 +2404,44 @@ dependencies = [ ] [[package]] -name = "stash-clipboard" -version = "0.3.2" +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stash-clipboard" +version = "0.3.6" +dependencies = [ + "arc-swap", "base64", + "blocking", "clap", "clap-verbosity-flag", - "crossterm 0.29.0", + "color-eyre", + "crossterm", + "ctrlc", "dirs", "env_logger", + "futures", + "humantime", "imagesize", "inquire", + "libc", "log", + "mime-sniffer", "notify-rust", "ratatui", "regex", @@ -1529,11 +2449,11 @@ dependencies = [ "serde", "serde_json", "smol", - "thiserror", + "tempfile", + "thiserror 2.0.18", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width", "wayland-client", - "wayland-protocols", "wayland-protocols-wlr", "wl-clipboard-rs", ] @@ -1552,136 +2472,261 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.26.3" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.26.4" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck", "proc-macro2", "quote", - "rustversion", - "syn", + "syn 2.0.117", ] [[package]] name = "syn" -version = "2.0.106" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" dependencies = [ - "quick-xml", - "thiserror", + "quick-xml 0.37.5", + "thiserror 2.0.18", "windows", "windows-version", ] [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom 0.4.2", "once_cell", - "rustix 1.1.2", - "windows-sys 0.61.2", + "rustix", + "windows-sys", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom 7.1.3", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.11.0", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset 0.4.2", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", ] [[package]] name = "thiserror" -version = "2.0.17" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", ] [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "libc", "num-conv", + "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] [[package]] name = "toml_datetime" -version = "0.7.3" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", "toml_datetime", "toml_parser", - "winnow", + "winnow 1.0.1", ] [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.1", ] [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -1690,81 +2735,132 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", ] [[package]] name = "tree_magic_mini" -version = "3.2.0" +version = "3.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f943391d896cdfe8eec03a04d7110332d445be7df856db382dd96a730667562c" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" dependencies = [ "memchr", - "nom", - "once_cell", + "nom 8.0.0", "petgraph", ] [[package]] -name = "uds_windows" -version = "1.1.0" +name = "typenum" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "winapi", + "windows-sys", ] [[package]] name = "unicode-ident" -version = "1.0.19" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-truncate" -version = "1.1.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width 0.1.14", + "unicode-width", ] [[package]] name = "unicode-width" -version = "0.1.14" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] -name = "unicode-width" -version = "0.2.0" +name = "unicode-xid" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +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" @@ -1772,68 +2868,182 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "atomic", + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] [[package]] -name = "wayland-backend" -version = "0.3.11" +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 = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.2", + "rustix", "smallvec", "wayland-sys", ] [[package]] name = "wayland-client" -version = "0.31.11" +version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ - "bitflags", - "rustix 1.1.2", + "bitflags 2.11.0", + "log", + "rustix", "wayland-backend", "wayland-scanner", ] [[package]] name = "wayland-protocols" -version = "0.32.9" +version = "0.32.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" dependencies = [ - "bitflags", + "bitflags 2.11.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -1841,11 +3051,11 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.9" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags", + "bitflags 2.11.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -1854,24 +3064,96 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.7" +version = "0.31.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.39.2", "quote", ] [[package]] name = "wayland-sys" -version = "0.31.7" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "pkg-config", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1948,7 +3230,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1959,7 +3241,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2002,24 +3284,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -2029,39 +3293,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.1.0" @@ -2081,128 +3312,122 @@ dependencies = [ ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" +name = "winnow" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] [[package]] name = "winnow" -version = "0.7.13" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" dependencies = [ "memchr", ] [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +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" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5ff8d0e60065f549fafd9d6cb626203ea64a798186c80d8e7df4f8af56baeb" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" dependencies = [ "libc", "log", "os_pipe", - "rustix 0.38.44", - "tempfile", - "thiserror", + "rustix", + "thiserror 2.0.18", "tree_magic_mini", "wayland-backend", "wayland-client", @@ -2211,10 +3436,39 @@ dependencies = [ ] [[package]] -name = "zbus" -version = "5.11.0" +name = "writeable" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" dependencies = [ "async-broadcast", "async-executor", @@ -2230,14 +3484,16 @@ dependencies = [ "futures-core", "futures-lite", "hex", - "nix", + "libc", "ordered-stream", + "rustix", "serde", "serde_repr", "tracing", "uds_windows", - "windows-sys 0.60.2", - "winnow", + "uuid", + "windows-sys", + "winnow 0.7.15", "zbus_macros", "zbus_names", "zvariant", @@ -2245,14 +3501,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.11.0" +version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zbus_names", "zvariant", "zvariant_utils", @@ -2260,52 +3516,111 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.2.0" +version = "4.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "static_assertions", - "winnow", + "winnow 0.7.15", "zvariant", ] [[package]] -name = "zvariant" -version = "5.7.0" +name = "zerofrom" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "999dd3be73c52b1fccd109a4a81e4fcd20fab1d3599c8121b38d04e1419498db" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 0.7.15", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.7.0" +version = "5.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6643fd0b26a46d226bd90d3f07c1b5321fe9bb7f04673cb37ac6d6883885b68e" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" dependencies = [ "proc-macro2", "quote", "serde", - "syn", - "winnow", + "syn 2.0.117", + "winnow 0.7.15", ] diff --git a/Cargo.toml b/Cargo.toml index ab80a8d..e3467ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,51 +1,59 @@ [package] -name = "stash-clipboard" -description = "Wayland clipboard manager with fast persistent history and multi-media support" -version = "0.3.2" -edition = "2024" -authors = ["NotAShelf "] -license = "MPL-2.0" -readme = true -repository = "https://github.com/notashelf/stash" -rust-version = "1.85" +name = "stash-clipboard" +description = "Wayland clipboard manager with fast persistent history and multi-media support" +version = "0.3.6" +edition = "2024" +authors = [ "NotAShelf " ] +license = "MPL-2.0" +readme = true +repository = "https://github.com/notashelf/stash" +rust-version = "1.91.0" [[bin]] -name = "stash" # actual binary name for Nix, Cargo, etc. +name = "stash" # actual binary name for Nix, Cargo, etc. path = "src/main.rs" -[features] -default = ["use-toplevel", "notifications"] -use-toplevel = ["dep:wayland-client", "dep:wayland-protocols", "dep:wayland-protocols-wlr"] -notifications = ["dep:notify-rust"] - [dependencies] -clap = { version = "4.5.48", features = ["derive", "env"] } -clap-verbosity-flag = "3.0.4" -dirs = "6.0.0" -imagesize = "0.14.0" -inquire = { default-features = false, version = "0.9.1", features = [ - "crossterm", -] } -log = "0.4.28" -env_logger = "0.11.8" -thiserror = "2.0.17" -wl-clipboard-rs = "0.9.2" -rusqlite = { version = "0.37.0", features = ["bundled"] } -smol = "2.0.2" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.145" -base64 = "0.22.1" -regex = "1.11.3" -ratatui = "0.29.0" -crossterm = "0.29.0" -unicode-segmentation = "1.12.0" -unicode-width = "0.2.0" # FIXME: held back by ratatui -wayland-client = { version = "0.31.11", optional = true } -wayland-protocols = { version = "0.32.0", optional = true } -wayland-protocols-wlr = { version = "0.3.9", optional = true } -notify-rust = { version = "4.11.7", optional = true } +arc-swap = { version = "1.9.1", optional = true } +base64 = "0.22.1" +blocking = "1.6.2" +clap = { version = "4.6.0", features = [ "derive", "env" ] } +clap-verbosity-flag = "3.0.4" +color-eyre = "0.6.5" +crossterm = "0.29.0" +ctrlc = "3.5.2" +dirs = "6.0.0" +env_logger = "0.11.10" +humantime = "2.3.0" +imagesize = "0.14.0" +inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] } +libc = "0.2.184" +log = "0.4.29" +mime-sniffer = "0.1.3" +notify-rust = { version = "4.14.0", optional = true } +ratatui = "0.30.0" +regex = "1.12.3" +rusqlite = { version = "0.39.0", features = [ "bundled" ] } +serde = { version = "1.0.228", features = [ "derive" ] } +serde_json = "1.0.149" +smol = "2.0.2" +thiserror = "2.0.18" +unicode-segmentation = "1.13.2" +unicode-width = "0.2.2" +wayland-client = { version = "0.31.14", features = [ "log" ], optional = true } +wayland-protocols-wlr = { version = "0.3.12", default-features = false, optional = true } +wl-clipboard-rs = "0.9.3" + +[dev-dependencies] +futures = "0.3.32" +tempfile = "3.27.0" + +[features] +default = [ "notifications", "use-toplevel" ] +notifications = [ "dep:notify-rust" ] +use-toplevel = [ "dep:arc-swap", "dep:wayland-client", "dep:wayland-protocols-wlr" ] [profile.release] -lto = true +lto = true opt-level = "z" -strip = true +strip = true diff --git a/README.md b/README.md index a974185..d29b4f4 100644 --- a/README.md +++ b/README.md @@ -20,23 +20,23 @@
- Wayland clipboard "manager" with fast persistent history and multi-media - support. Stores and previews clipboard entries (text, images) on the command - line. + Lightweight & feature-rich Wayland clipboard "manager" with fast persistent history and + robust multi-media support. Stores and previews clipboard entries (text, images) + on the clipboard with a neat TUI and advanced scripting capabilities.

Features
- Installation | Usage
+ Installation | Usage | Motivation
Tips and Tricks
## Features -Stash is a feature-rich, yet simple clipboard management utility with many -features such as but not limited to: +Stash is a feature-rich, yet simple and lightweight clipboard management utility +with many features such as but not necessarily limited to: - Automatic MIME detection for stored entries - Fast persistent storage using SQLite @@ -45,26 +45,40 @@ features such as but not limited to: - Import clipboard history from TSV (e.g., from `cliphist list`) - Image preview (shows dimensions and format) - Text previews with customizable width -- Deduplication and entry limit control -- Automatic clipboard monitoring with `stash watch` +- De-duplication, whitespace prevention and entry limit control +- Automatic clipboard monitoring with + [`stash watch`](#watch-clipboard-for-changes-and-store-automatically) + - Configurable auto-expiry of old entries in watch mode as a safety buffer - Drop-in replacement for `wl-clipboard` tools (`wl-copy` and `wl-paste`) - Sensitive clipboard filtering via regex (see below) - Sensitive clipboard filtering by application (see below) -See [usage section](#usage) for more details. +on top of the existing features of Cliphist, which are as follows: + +- Write clipboard changes to a history file. +- Recall history with dmenu, rofi, wofi (or whatever other picker you like). +- Both text and images are supported. +- Clipboard is preserved byte-for-byte. + - Leading/trailing whitespace, no whitespace, or newlines are preserved. + - Won’t break fancy editor selections like Vim wordwise, linewise, or block + mode. + +Most of Stash's usage is documented in the [usage section](#usage) for more +details. Refer to the [Tips & Tricks section](#tips--tricks) for more "advanced" +features, or conveniences provided by Stash. ## Installation ### With Nix -Nix is the recommended way of downloading Stash. You can install it using Nix -flakes using `nix profile add` if on non-nixos or add Stash as a flake input if -you are on NixOS. +Nix is the recommended way of downloading (and developing!) Stash. You can +install it using Nix flakes using `nix profile add` if on non-nixos or add Stash +as a flake input if you are on NixOS. ```nix { # Add Stash to your inputs like so - inputs.stash.url = "github:notashelf/stash"; + inputs.stash.url = "github:NotAShelf/stash"; outputs = { /* ... */ }; } @@ -86,10 +100,12 @@ in { } ``` -You can also run it one time with `nix run` +If you want to give Stash a try before you switch to it, you may also run it one +time with `nix run`. ```sh -nix run github:notashelf/stash -- watch # start the watch daemon +# Run directly from the git repository; will be garbage collected +$ nix run github:NotAShelf/stash -- watch # start the watch daemon ``` ### Without Nix @@ -98,29 +114,84 @@ nix run github:notashelf/stash -- watch # start the watch daemon You can also install Stash on any of your systems _without_ using Nix. New releases are made when a version gets tagged, and are available under -[GitHub Releases]. To install Stash on your system without Nix, eiter: +[GitHub Releases]. To install Stash on your system without Nix, either: - Download a tagged release from [GitHub Releases] for your platform and place the binary in your `$PATH`. Instructions may differ based on your distribution, but generally you want to download the built binary from - releases and put it somewhere like `/usr/bin`. + releases and put it somewhere like `/usr/bin` or `~/.local/bin` depending on + your distribution. - Build and install from source with Cargo: ```bash - cargo install --git https://github.com/notashelf/stash + cargo install stash --locked ``` +Additionally, you may get Stash from source via `cargo install` using +`cargo install --git https://github.com/notashelf/stash --locked` or you may +check out to the repository, and use Cargo to build it. You'll need Rust 1.91.0 +or above. Most distributions should package this version already. You may, of +course, prefer to package the built releases if you'd like. + ## Usage -Command interface is only slightly different from Cliphist. In most cases, it -will be as simple as replacing `cliphist` with `stash` in your commands, aliases -or scripts. - -> [!NOTE] +> [!IMPORTANT] > It is not a priority to provide 1:1 backwards compatibility with Cliphist. -> While the interface is _almost_ identical, Stash chooses to build upon +> While the interface is generally similar, Stash chooses to build upon > Cliphist's design and extend existing design choices. See -> [Migrating from Cliphist](#migrating-from-cliphist) for more details. +> [Migrating from Cliphist](#migrating-from-cliphist) for more details. Refer to +> help text if confused. + +The command interface of Stash is _only slightly_ different from Cliphist. In +most cases, you may simply replace `cliphist` with `stash` and your commands, +aliases or scripts will continue to work as intended. + +Some of the commands allow further fine-graining with flags such as `--type` or +`--format` to allow specific input and output specifiers. See `--help` for +individual subcommands if in doubt. + + + +```console +$ stash help +Wayland clipboard manager + +Usage: stash [OPTIONS] [COMMAND] + +Commands: + store Store clipboard contents + list List clipboard history + decode Decode and output clipboard entry by id + delete Delete clipboard entry by id (if numeric), or entries matching a query (if not). Numeric arguments are treated as ids. Use --type to specify explicitly + db Database management operations + import Import clipboard data from stdin (default: TSV format) + watch Start a process to watch clipboard for changes and store automatically + help Print this message or the help of the given subcommand(s) + +Options: + --max-items + Maximum number of clipboard entries to keep [default: 18446744073709551615] + --max-dedupe-search + Number of recent entries to check for duplicates when storing new clipboard data [default: 20] + --preview-width + Maximum width (in characters) for clipboard entry previews in list output [default: 100] + --db-path + Path to the `SQLite` clipboard database file [env: STASH_DB_PATH=] + --excluded-apps + Application names to exclude from clipboard history [env: STASH_EXCLUDED_APPS=] + --ask + Ask for confirmation before destructive operations + -v, --verbose... + Increase logging verbosity + -q, --quiet... + Decrease logging verbosity + -h, --help + Print help + -V, --version + Print version +``` + + ### Store an entry @@ -134,18 +205,39 @@ echo "some clipboard text" | stash store stash list ``` +Stash list will list all entries in an interactive TUI that allows navigation +and copying/deleting entries. This behaviour is EXCLUSIVE TO TTYs and Stash will +display entries in Cliphist-compatible TSV format in Bash scripts. You may also +enforce the output format with `stash list --format `. + +You may also view your clipboard _with the addition of expired entries_, i.e., +entries that have reached their TTL and are marked as expired, using the +`--expired` flag as `stash list --expired`. Expired entries are not cleaned up +when using this flag, allowing you to inspect them before running cleanup. + ### Decode an entry by ID ```bash -stash decode --input "1234" +stash decode ``` +> [!TIP] +> Decoding from dmenu-compatible tools: +> +> ```bash +> stash list | tofi | stash decode +> ``` + ### Delete entries matching a query ```bash -stash delete --type query --arg "some text" +stash delete --type [id | query] ``` +By default stash will try to guess the type of an entry, but this may not be +desirable for all users. If you wish to be explicit, pass `--type` to +`stash delete`. + ### Delete multiple entries by ID (from a file or stdin) ```bash @@ -154,10 +246,33 @@ stash delete --type id < ids.txt ### Wipe all entries +> [!WARNING] +> This command is deprecated, and will be removed in v0.4.0. Use `stash db wipe` +> instead. + ```bash stash wipe ``` +### Database management + +Stash provides a `db` subcommand for database maintenance operations: + +```bash +stash db wipe [--expired] [--ask] +stash db vacuum +stash db stats +``` + +- `stash db wipe`: Remove all entries from the database. Use `--expired` to only + wipe expired entries instead of all entries. Requires `--ask` confirmation by + default. +- `stash db vacuum`: Optimize the database using SQLite's VACUUM command, + reclaiming space and improving performance. +- `stash db stats`: Display database statistics including total/active/expired + entry counts, storage size, and page information. This is provided purely for + convenience and the rule of the cool. + ### Watch clipboard for changes and store automatically ```bash @@ -167,16 +282,62 @@ stash watch This runs a daemon that monitors the clipboard and stores new entries automatically. This is designed as an alternative to shelling out to `wl-paste --watch` inside a Systemd service or XDG autostart. You may find a -premade Systemd service in `vendor/`. Packagers are encouraged to vendor the +premade Systemd service in `contrib/`. Packagers are encouraged to vendor the service unless adding their own. -> [!TIP] -> Stash provides `wl-copy` and `wl-paste` binaries for backwards compatibility -> with the `wl-clipboard` tools. If _must_ depend on those binaries by name, you -> may simply use the `wl-copy` and `wl-paste` provided as `wl-clipboard-rs` -> wrappers on your system. In other words, you can use -> `wl-paste --watch stash store` as an alternative to `stash watch` if -> preferred. +#### Automatic Clipboard Clearing on Expiration + +When `stash watch` is running and a clipboard entry expires, Stash will detect +if the current clipboard still contains that expired content and automatically +clear it. This prevents stale data from remaining in your clipboard after an +entry has expired from history. + +> [!NOTE] +> This behavior only applies when the watch daemon is actively running. Manual +> expiration or deletion of entries will not clear the clipboard. + +#### MIME Type Preference for Watch + +`stash watch` supports a `--mime-type` (short `-t`) option that lets you +prioritise which MIME type the daemon should request from the clipboard when +multiple representations are available. + +- `any` (default): Request any available representation (current behaviour). +- `text`: Prefer text representations (e.g. `text/plain`, `text/html`). +- `image`: Prefer image representations (e.g. `image/png`, `image/jpeg`) so that + image copies from browsers or file managers are stored as images rather than + HTML fragments. + +Example: prefer images when running the watch daemon + +```bash +stash watch --mime-type image +``` + +This is useful when copying images from browsers or file managers where the +clipboard may offer both HTML and image representations; selecting `image` will +ask the compositor for image data first. Most users will be fine using the +default value (`any`) but in the case your browser (or other applications!) +regularly misrepresent data, you might wish to prioritize a different type. + +#### Clipboard Persistence + +By default, when you copy something and close the source application, Wayland +clears the clipboard. Stash can optionally keep the clipboard contents available +after the source closes using the `--persist` flag. + +```bash +stash watch --persist +``` + +When enabled, Stash will fork a background process to serve the clipboard +contents, keeping them available even after the original application exits. + +> [!NOTE] +> This feature is **opt-in** and disabled by default, as it may not be desirable +> for all users and can leave clipboard data in memory longer than expected. You +> must start the `stash watch` daemon with `--persist` for clipboard +> persistence. ### Options @@ -205,7 +366,8 @@ This can be configured in one of two ways. You can use the **environment variable** `STASTH_SENSITIVE_REGEX` to a valid regex pattern, and if the clipboard text matches the regex it will not be stored. This can be used for trivial secrets such as but not limited to GitHub tokens or secrets that follow -a rule, e.g. a prefix. +a rule, e.g. a prefix. You would typically set this in your `~/.bashrc` or +similar but in some cases this might be a security flaw. The safer alternative to this is using **Systemd LoadCrediental**. If Stash is running as a Systemd service, you can provide a regex pattern using a crediental @@ -216,8 +378,9 @@ LoadCredential=clipboard_filter:/etc/stash/clipboard_filter ``` The file `/etc/stash/clipboard_filter` should contain your regex pattern (no -quotes). This is done automatically in the vendored Systemd service. Remember to -set the appropriate file permissions if using this option. +quotes). This is done automatically in the +[vendored Systemd service](./contrib/stash.service). Remember to set the +appropriate file permissions if using this option. The service will check the credential file first, then the environment variable. If a clipboard entry matches the regex, it will be skipped and a warning will be @@ -227,6 +390,9 @@ logged. > **Example regex to block common password patterns**: > > `(password|secret|api[_-]?key|token)[=: ]+[^\s]+` +> +> For security reasons, you are recommended to use the regex only for generic +> tokens that follow a specific rule, for example a generic prefix or suffix. #### Clipboard Filtering by Application Class @@ -249,6 +415,20 @@ be only copied to the clipboard. > > `stash --excluded-apps Bitwarden watch` +## Motivation + +I've been a long-time user of Cliphist. You can probably tell by the number of +times it has been mentioned in the README, if not for the attributions section, +that Stash is _clearly_ inspired and adapted from it. It's actually a great +clipboard manager if your needs are simple, but mine aren't. I need an +**all-in-one** solution, that I can freely hack on, with simple solutions to +complex problems that I've had with managing my clipboard. I wanted it to be +scriptable _and_ interactive, I wanted it to be performant, I wanted it to be... + +You get the point. Perhaps you also share similar needs, or just like Rust +software in general on your desktop. In either case, Stash hopes to serve as an +excellent clipboard manager for your needs, with _excellent_ performance. + ## Tips & Tricks ### Migrating from Cliphist @@ -326,6 +506,113 @@ figured out something new, e.g. a neat shell trick, feel free to add it here! cliphist list --db ~/.cache/cliphist/db | stash import ``` +3. Stash provides its own implementation of `wl-copy` and `wl-paste` commands + backed by `wl-clipboard-rs`. Those implementations are backwards compatible + with `wl-clipboard`, and may be used as **drop-in** replacements. The default + build wrapper in `build.rs` links `stash` to `stash-copy` and `stash-paste`, + which are also available as `wl-copy` and `wl-paste` respectively. The Nix + package automatically links those to `$out/bin` for you, which means they are + installed by default but other package managers may need additional steps by + the packagers. While building from source, you may link + `target/release/stash` manually. + +### Entry Expiration + +Stash supports time-to-live (TTL) for clipboard entries. When an entry's +expiration time is reached, it is marked as expired rather than immediately +deleted. This allows for inspection of expired entries and automatic clipboard +cleanup. + +#### How Expiration Works + +When `stash watch` is running with `--expire-after`, it monitors the clipboard +and processes expired entries periodically. Upon expiration: + +1. The entry's `is_expired` flag is set to `1` in the database +2. If the current clipboard content matches the expired entry, Stash clears the + clipboard to prevent pasting stale data +3. Expired entries are excluded from normal list operations unless `--expired` + is specified + +> [!NOTE] +> By default, entries do not expire. Use `stash watch --expire-after DURATION` +> to enable expiration (e.g., `--expire-after 24h` for 24-hour TTL). + +#### Viewing Expired Entries + +Use `stash list --expired` to include expired entries in the output. This is +useful for: + +- Inspecting what has expired from your clipboard history +- Verifying that sensitive data has been properly expired +- Debugging expiration behavior + +```bash +# View all entries including expired ones +stash list --expired + +# View expired entries in JSON format +stash list --expired --format json +``` + +#### Cleaning Up Expired Entries + +The watch daemon automatically cleans up expired entries when it processes them. +For manual cleanup, use: + +```bash +# Remove all expired entries from the database +stash db wipe --expired +``` + +> [!NOTE] +> If you have a large number of expired entries, consider running +> `stash db vacuum` afterward to reclaim disk space. + +#### Automatic Clipboard Clearing + +When `stash watch` is running and an entry expires, Stash checks if the current +clipboard still contains that expired content. If it matches, Stash clears the +clipboard automatically. This prevents accidentally pasting outdated content. + +> [!TIP] +> This behavior only applies when the watch daemon is actively running. Manual +> expiration or deletion of entries will not clear the clipboard. + +#### Database Maintenance + +Stash uses SQLite for persistent storage. Over time, deleted entries and +fragmentation can affect performance. Use the `stash db` command to maintain +your database: + +- **Check statistics**: `stash db stats` shows entry counts and storage usage. + Use this to monitor growth and decide when to clean up. +- **Remove expired entries**: `stash db wipe --expired` removes entries that + have reached their TTL. The daemon normally handles this, but this is useful + for manual cleanup. +- **Optimize storage**: `stash db vacuum` runs SQLite's VACUUM command to + reclaim space and defragment the database. This is safe to run periodically. + +It is recommended to run `stash db vacuum` occasionally (e.g., monthly) to keep +the database compact, especially after deleting many entries. You can, of +course, wipe the database entirely if it has grown too large. + +## Attributions + +My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the +[wl-clipboard-rs](https://github.com/YaLTeR/wl-clipboard-rs) crate. Stash is +powered by [several crates](./Cargo.toml), but none of them were as detrimental +in Stash's design process. + +Secondly, but by no means less importantly, I would like to thank +[cliphist](https://github.com/sentriz/cliphist) for the excellent reference it +has provided to me as a "solid clipboard manager." The interface of Stash is +inspired by Cliphist, and it has served me very well for a very long time. + +Additional and definitely heartfelt thanks to my testers, who have tested +earlier versions of Stash, helped with packaging and provided feedback. Thank +you :) + ## License This project is made available under Mozilla Public License (MPL) version 2.0. diff --git a/build.rs b/build.rs deleted file mode 100644 index 533368c..0000000 --- a/build.rs +++ /dev/null @@ -1,56 +0,0 @@ -use std::{env, fs, path::Path}; - -/// List of multicall symlinks to create (name, target) -const MULTICALL_LINKS: &[&str] = - &["stash-copy", "stash-paste", "wl-copy", "wl-paste"]; - -fn main() { - // Only run on Unix-like systems - #[cfg(not(unix))] - { - println!( - "cargo:warning=Multicall symlinks are only supported on Unix-like \ - systems." - ); - return; - } - - // OUT_DIR is something like .../target/debug/build//out - // We want .../target/debug or .../target/release - let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set"); - let bin_dir = Path::new(&out_dir) - .ancestors() - .nth(3) - .expect("Failed to find binary dir"); - - // Path to the main stash binary - let stash_bin = bin_dir.join("stash"); - - // Create symlinks for each multicall binary - for link in MULTICALL_LINKS { - let link_path = bin_dir.join(link); - // Remove existing symlink or file if present - let _ = fs::remove_file(&link_path); - #[cfg(unix)] - { - use std::os::unix::fs::symlink; - match symlink(&stash_bin, &link_path) { - Ok(()) => { - println!( - "cargo:warning=Created symlink: {} -> {}", - link_path.display(), - stash_bin.display() - ); - }, - Err(e) => { - println!( - "cargo:warning=Failed to create symlink {} -> {}: {}", - link_path.display(), - stash_bin.display(), - e - ); - }, - } - } - } -} diff --git a/vendor/stash.service b/contrib/stash.service similarity index 100% rename from vendor/stash.service rename to contrib/stash.service diff --git a/flake.lock b/flake.lock index 02d5006..e50ffba 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1754269165, - "narHash": "sha256-0tcS8FHd4QjbCVoxN9jI+PjHgA4vc/IjkUSp+N3zy0U=", + "lastModified": 1775839657, + "narHash": "sha256-SPm9ck7jh3Un9nwPuMGbRU04UroFmOHjLP56T10MOeM=", "owner": "ipetkov", "repo": "crane", - "rev": "444e81206df3f7d92780680e45858e31d2f07a08", + "rev": "7cf72d978629469c4bd4206b95c402514c1f6000", "type": "github" }, "original": { @@ -17,11 +17,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1754725699, - "narHash": "sha256-iAcj9T/Y+3DBy2J0N+yF9XQQQ8IEb5swLFzs23CdP88=", + "lastModified": 1775710090, + "narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "85dbfc7aaf52ecb755f87e577ddbe6dbbdbc1054", + "rev": "4c1018dae018162ec878d42fec712642d214fdfa", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index d7078d3..b41dbf9 100644 --- a/flake.nix +++ b/flake.nix @@ -13,6 +13,11 @@ forEachSystem = nixpkgs.lib.genAttrs systems; pkgsForEach = nixpkgs.legacyPackages; in { + nixosModules = { + stash = import ./nix/modules/nixos.nix self; + default = self.nixosModules.stash; + }; + packages = forEachSystem (system: let craneLib = crane.mkLib pkgsForEach.${system}; in { diff --git a/nix/modules/nixos.nix b/nix/modules/nixos.nix new file mode 100644 index 0000000..23072a0 --- /dev/null +++ b/nix/modules/nixos.nix @@ -0,0 +1,78 @@ +self: { + config, + lib, + pkgs, + ... +}: let + inherit (lib.modules) mkIf; + inherit (lib.options) mkOption mkEnableOption mkPackageOption literalMD; + inherit (lib.types) listOf str; + inherit (lib.strings) concatStringsSep; + inherit (lib.meta) getExe; + + cfg = config.services.stash-clipboard; +in { + options.services.stash-clipboard = { + enable = mkEnableOption "stash, a Wayland clipboard manager"; + + package = mkPackageOption self.packages.${pkgs.system} ["stash"] {}; + + flags = mkOption { + type = listOf str; + default = []; + example = ["--max-items 10"]; + description = "Flags to pass to stash watch."; + }; + + filterFile = mkOption { + type = str; + default = ""; + example = "{file}`/etc/stash/clipboard_filter`"; + description = literalMD '' + File containing a regular expression to catch sensitive patterns. The file + passed to this option must contain your regex pattern with no quotes. + + ::: {.tip} + Example regex to block common password patterns: + + * `(password|secret|api[_-]?key|token)[=: ]+[^\s]+` + ::: + ''; + }; + + excludedApps = mkOption { + type = listOf str; + default = []; + example = ["Bitwarden"]; + description = '' + Stash will avoid storing data if the active window class matches the + entries passed to this option. This is useful for avoiding persistent + passwords in the database, while still allowing one-time copies. + + Entries from these apps are still copied to the clipboard, but it will + never be put inside the database. + ''; + }; + }; + + config = mkIf cfg.enable { + environment.systemPackages = [cfg.package]; + systemd = { + packages = [cfg.package]; + user.services.stash-clipboard = { + description = "Stash clipboard manager daemon"; + wantedBy = ["graphical-session.target"]; + after = ["graphical-session.target"]; + + serviceConfig = { + ExecStart = "${getExe cfg.package} ${concatStringsSep " " cfg.flags} watch"; + LoadCredential = mkIf (cfg.filterFile != "") "clipboard_filter:${cfg.filterFile}"; + }; + + environment = mkIf (cfg.excludedApps != []) { + STASH_EXCLUDED_APPS = concatStringsSep "," cfg.excludedApps; + }; + }; + }; + }; +} diff --git a/nix/package.nix b/nix/package.nix index 3c32991..b27a730 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -1,10 +1,14 @@ { lib, craneLib, + stdenv, + mold, versionCheckHook, + useMold ? stdenv.isLinux, + createSymlinks ? true, }: let pname = "stash"; - version = (builtins.fromTOML (builtins.readFile ../Cargo.toml)).package.version; + version = (lib.importTOML ../Cargo.toml).package.version; src = let fs = lib.fileset; s = ../.; @@ -15,7 +19,6 @@ (fs.fileFilter (file: builtins.any file.hasExt ["rs"]) (s + /src)) (s + /Cargo.lock) (s + /Cargo.toml) - (s + /build.rs) ]; }; @@ -30,18 +33,12 @@ in strictDeps = true; - # Whether cargo's target directory should be copied as an output - doInstallCargoArtifacts = true; - - # Install Systemd service for Stash into $out/share. - # This can be used to use Stash in 'systemd.packages' - postInstall = '' + # Since Crane doesn't have a good way of enforcing that our symlinks + # generated by the build wrapper are correctly linked, we should link + # them *manually*. The postInstallCheck phase that follows will check + # to verify if all of those links are in place. + postInstall = lib.optionalString createSymlinks '' mkdir -p $out - install -Dm755 ${../vendor/stash.service} $out/share/stash.service - - # Since Crane doesn't have a good way of enforcing that our symlinks - # generated by the build wrapper are correctly linked, we should link - # them *manually*. for bin in stash-copy stash-paste wl-copy wl-paste; do ln -sf $out/bin/stash $out/bin/$bin done @@ -51,18 +48,24 @@ in doInstallCheck = true; # After the version check, let's see if all binaries are linked correctly. - # We could probably add a check phase to the versions of each. - postInstallCheck = '' + # We could probably add a check phase to get the versions of each. + postInstallCheck = lib.optionalString createSymlinks '' for bin in stash stash-copy stash-paste wl-copy wl-paste; do [ -x "$out/bin/$bin" ] || { echo "$bin missing"; exit 1; } done ''; + env = lib.optionalAttrs useMold { + CARGO_LINKER = "clang"; + CARGO_RUSTFLAGS = "-Clink-arg=-fuse-ld=${mold}/bin/mold"; + }; + meta = { description = "Wayland clipboard manager with fast persistent history and multi-media support"; homepage = "https://github.com/notashelf/stash"; license = lib.licenses.mpl20; maintainers = [lib.maintainers.NotAShelf]; mainProgram = "stash"; + platforms = lib.platforms.linux; }; } diff --git a/nix/shell.nix b/nix/shell.nix index 9df0432..273d74a 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -6,6 +6,7 @@ clippy, taplo, rust-analyzer-unwrapped, + cargo-nextest, rustPlatform, }: mkShell { @@ -20,6 +21,9 @@ mkShell { cargo taplo rust-analyzer-unwrapped + + # Additional Cargo Tooling + cargo-nextest ]; RUST_SRC_PATH = "${rustPlatform.rustLibSrc}"; diff --git a/src/clipboard/mod.rs b/src/clipboard/mod.rs new file mode 100644 index 0000000..2648ce5 --- /dev/null +++ b/src/clipboard/mod.rs @@ -0,0 +1,3 @@ +pub mod persist; + +pub use persist::{ClipboardData, get_serving_pid, persist_clipboard}; diff --git a/src/clipboard/persist.rs b/src/clipboard/persist.rs new file mode 100644 index 0000000..a677f50 --- /dev/null +++ b/src/clipboard/persist.rs @@ -0,0 +1,262 @@ +use std::{ + process::exit, + sync::atomic::{AtomicI32, Ordering}, +}; + +use wl_clipboard_rs::copy::{ + ClipboardType, + MimeType as CopyMimeType, + Options, + PreparedCopy, + ServeRequests, + Source, +}; + +/// Maximum number of paste requests to serve before exiting. This (hopefully) +/// prevents runaway processes while still providing persistence. +const MAX_SERVE_REQUESTS: usize = 1000; + +/// PID of the current clipboard persistence child process. Used to detect when +/// clipboard content is from our own serve process. +static SERVING_PID: AtomicI32 = AtomicI32::new(0); + +/// Get the current serving PID if any. Used by the watch loop to avoid +/// duplicate persistence processes. +pub fn get_serving_pid() -> Option { + let pid = SERVING_PID.load(Ordering::SeqCst); + if pid != 0 { Some(pid) } else { None } +} + +/// Result type for persistence operations. +pub type PersistenceResult = Result; + +/// Errors that can occur during clipboard persistence. +#[derive(Debug, thiserror::Error)] +pub enum PersistenceError { + #[error("Failed to prepare copy: {0}")] + PrepareFailed(String), + + #[error("Failed to fork: {0}")] + ForkFailed(String), + + #[error("Clipboard data too large: {0} bytes")] + DataTooLarge(usize), + + #[error("Clipboard content is empty")] + EmptyContent, + + #[error("No MIME types to offer")] + NoMimeTypes, +} + +/// Clipboard data with all MIME types for persistence. +#[derive(Debug, Clone)] +pub struct ClipboardData { + /// The actual clipboard content. + pub content: Vec, + + /// All MIME types offered by the source. Preserves order. + pub mime_types: Vec, + + /// The MIME type that was selected for storage. + pub selected_mime: String, +} + +impl ClipboardData { + /// Create new clipboard data. + pub fn new( + content: Vec, + mime_types: Vec, + selected_mime: String, + ) -> Self { + Self { + content, + mime_types, + selected_mime, + } + } + + /// Check if data is valid for persistence. + pub fn is_valid(&self) -> Result<(), PersistenceError> { + const MAX_SIZE: usize = 100 * 1024 * 1024; // 100MB + + if self.content.is_empty() { + return Err(PersistenceError::EmptyContent); + } + + if self.content.len() > MAX_SIZE { + return Err(PersistenceError::DataTooLarge(self.content.len())); + } + + if self.mime_types.is_empty() { + return Err(PersistenceError::NoMimeTypes); + } + + Ok(()) + } +} + +/// Persist clipboard data by forking a background process that serves it. +/// +/// 1. Prepares a clipboard copy operation with all MIME types +/// 2. Forks a child process +/// 3. The child serves clipboard data indefinitely (until MAX_SERVE_REQUESTS) +/// 4. The parent returns immediately +/// +/// # Safety +/// +/// This function uses `libc::fork()` which is unsafe. The child process +/// must not modify any shared state or file descriptors. +pub unsafe fn persist_clipboard(data: ClipboardData) -> PersistenceResult<()> { + // Validate data + data.is_valid()?; + + // Prepare the copy operation + let prepared = prepare_clipboard_copy(&data)?; + + // Fork and serve + unsafe { fork_and_serve(prepared) } +} + +/// Prepare a clipboard copy operation with all MIME types. +fn prepare_clipboard_copy( + data: &ClipboardData, +) -> PersistenceResult { + let mut opts = Options::new(); + opts.clipboard(ClipboardType::Regular); + opts.serve_requests(ServeRequests::Only(MAX_SERVE_REQUESTS)); + opts.foreground(true); // we'll fork manually for better control + + // Determine MIME type for the primary offer + let mime_type = if data.selected_mime.starts_with("text/") { + CopyMimeType::Text + } else { + CopyMimeType::Specific(data.selected_mime.clone()) + }; + + // Prepare the copy + let prepared = opts + .prepare_copy(Source::Bytes(data.content.clone().into()), mime_type) + .map_err(|e| PersistenceError::PrepareFailed(e.to_string()))?; + + Ok(prepared) +} + +/// Fork a child process to serve clipboard data. +/// +/// The child process will: +/// +/// 1. Register its process ID with the self-detection module +/// 2. Serve clipboard requests until MAX_SERVE_REQUESTS +/// 3. Exit cleanly +/// +/// The parent stores the child `PID` in `SERVING_PID` and returns immediately. +unsafe fn fork_and_serve(prepared: PreparedCopy) -> PersistenceResult<()> { + // Enable automatic child reaping to prevent zombie processes + unsafe { + libc::signal(libc::SIGCHLD, libc::SIG_IGN); + } + + match unsafe { libc::fork() } { + 0 => { + // Child process - clear serving PID + // Look at me. I'm the server now. + SERVING_PID.store(0, Ordering::SeqCst); + serve_clipboard_child(prepared); + exit(0); + }, + + -1 => { + // Oops. + Err(PersistenceError::ForkFailed( + "libc::fork() returned -1".to_string(), + )) + }, + + pid => { + // Parent process, store child PID for loop detection + log::debug!("forked clipboard persistence process (pid: {pid})"); + SERVING_PID.store(pid, Ordering::SeqCst); + Ok(()) + }, + } +} + +/// Child process entry point for serving clipboard data. +fn serve_clipboard_child(prepared: PreparedCopy) { + let pid = std::process::id() as i32; + log::debug!("clipboard persistence child process started (pid: {pid})"); + + // Serve clipboard requests. The PreparedCopy::serve() method blocks and + // handles all the Wayland protocol interactions internally via + // wl-clipboard-rs + match prepared.serve() { + Ok(()) => { + log::debug!("clipboard persistence: serve completed normally"); + }, + + Err(e) => { + log::error!("clipboard persistence: serve failed: {e}"); + exit(1); + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clipboard_data_validation() { + // Valid data + let valid = ClipboardData::new( + b"hello".to_vec(), + vec!["text/plain".to_string()], + "text/plain".to_string(), + ); + assert!(valid.is_valid().is_ok()); + + // Empty content + let empty = ClipboardData::new( + vec![], + vec!["text/plain".to_string()], + "text/plain".to_string(), + ); + assert!(matches!( + empty.is_valid(), + Err(PersistenceError::EmptyContent) + )); + + // No MIME types + let no_mimes = + ClipboardData::new(b"hello".to_vec(), vec![], "text/plain".to_string()); + assert!(matches!( + no_mimes.is_valid(), + Err(PersistenceError::NoMimeTypes) + )); + + // Too large + let huge = ClipboardData::new( + vec![0u8; 101 * 1024 * 1024], // 101MB + vec!["text/plain".to_string()], + "text/plain".to_string(), + ); + assert!(matches!( + huge.is_valid(), + Err(PersistenceError::DataTooLarge(_)) + )); + } + + #[test] + fn test_clipboard_data_creation() { + let data = ClipboardData::new( + b"test content".to_vec(), + vec!["text/plain".to_string(), "text/html".to_string()], + "text/plain".to_string(), + ); + + assert_eq!(data.content, b"test content"); + assert_eq!(data.mime_types.len(), 2); + assert_eq!(data.selected_mime, "text/plain"); + } +} diff --git a/src/commands/decode.rs b/src/commands/decode.rs index 8f414a1..f989a18 100644 --- a/src/commands/decode.rs +++ b/src/commands/decode.rs @@ -32,7 +32,7 @@ impl DecodeCommand for SqliteClipboardDb { // If input is empty or whitespace, treat as error and trigger fallback if input_str.trim().is_empty() { - log::debug!("No input provided to decode; relaying clipboard to stdout"); + log::debug!("no input provided to decode; relaying clipboard to stdout"); if let Ok((mut reader, _mime)) = get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Any) { diff --git a/src/commands/delete.rs b/src/commands/delete.rs index dd84989..ba358ad 100644 --- a/src/commands/delete.rs +++ b/src/commands/delete.rs @@ -9,7 +9,7 @@ pub trait DeleteCommand { impl DeleteCommand for SqliteClipboardDb { fn delete(&self, input: impl Read) -> Result { let deleted = self.delete_entries(input)?; - log::info!("Deleted {deleted} entries"); + log::info!("deleted {deleted} entries"); Ok(deleted) } } diff --git a/src/commands/import.rs b/src/commands/import.rs index a5b4e55..4a3a2a7 100644 --- a/src/commands/import.rs +++ b/src/commands/import.rs @@ -1,12 +1,6 @@ use std::io::{self, BufRead}; -use crate::db::{ - ClipboardDb, - Entry, - SqliteClipboardDb, - StashError, - detect_mime, -}; +use crate::db::{ClipboardDb, Entry, SqliteClipboardDb, StashError}; pub trait ImportCommand { /// Import clipboard entries from TSV format. @@ -44,7 +38,7 @@ impl ImportCommand for SqliteClipboardDb { let entry = Entry { contents: val.as_bytes().to_vec(), - mime: detect_mime(val.as_bytes()), + mime: crate::mime::detect_mime(val.as_bytes()), }; self @@ -61,11 +55,11 @@ impl ImportCommand for SqliteClipboardDb { imported += 1; } - log::info!("Imported {imported} records from TSV into SQLite database."); + log::info!("imported {imported} records from TSV into SQLite database."); // Trim database to max_items after import self.trim_db(max_items)?; - log::info!("Trimmed clipboard database to max_items = {max_items}"); + log::info!("trimmed clipboard database to max_items = {max_items}"); Ok(()) } diff --git a/src/commands/list.rs b/src/commands/list.rs index c2be9c1..b3041e5 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -6,8 +6,13 @@ use unicode_width::UnicodeWidthStr; use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; pub trait ListCommand { - fn list(&self, out: impl Write, preview_width: u32) - -> Result<(), StashError>; + fn list( + &self, + out: impl Write, + preview_width: u32, + include_expired: bool, + reverse: bool, + ) -> Result<(), StashError>; } impl ListCommand for SqliteClipboardDb { @@ -15,14 +20,267 @@ impl ListCommand for SqliteClipboardDb { &self, out: impl Write, preview_width: u32, + include_expired: bool, + reverse: bool, ) -> Result<(), StashError> { - self.list_entries(out, preview_width).map(|_| ()) + self + .list_entries(out, preview_width, include_expired, reverse) + .map(|_| ()) } } +/// All mutable state for the TUI list view. +struct TuiState { + /// Total number of entries matching the current filter in the DB. + total: usize, + + /// Global cursor position: index into the full ordered result set. + cursor: usize, + + /// DB offset of `window[0]`, i.e., the first row currently loaded. + viewport_offset: usize, + + /// The loaded slice of entries: `(id, preview, mime)`. + window: Vec<(i64, String, String)>, + + /// How many rows the window holds (== visible list height). + window_size: usize, + + /// Whether the window needs to be re-fetched from the DB. + dirty: bool, + + /// Current search query. Empty string means no filter. + search_query: String, + + /// Whether we're currently in search input mode. + search_mode: bool, + + /// Whether to show entries in reverse order (oldest first). + reverse: bool, + + /// ID of entry currently being copied. + copying_entry: Option, +} + +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) -> Result<(), StashError> { + pub fn list_tui( + &self, + preview_width: u32, + include_expired: bool, + reverse: bool, + ) -> Result<(), StashError> { use std::io::stdout; use crossterm::{ @@ -52,39 +310,9 @@ impl SqliteClipboardDb { }; use wl_clipboard_rs::copy::{MimeType, Options, Source}; - // Query entries from DB - let mut stmt = self - .conn - .prepare("SELECT id, contents, mime FROM clipboard ORDER BY 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<(u64, String, String)> = Vec::new(); - let mut max_id_width = 2; - let mut max_mime_width = 8; - while let Some(row) = rows - .next() - .map_err(|e| StashError::ListDecode(e.to_string().into()))? - { - let id: u64 = row - .get(0) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let contents: Vec = row - .get(1) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mime: Option = row - .get(2) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let preview = - crate::db::preview_entry(&contents, mime.as_deref(), preview_width); - let mime_str = mime.as_deref().unwrap_or("").to_string(); - let id_str = id.to_string(); - max_id_width = max_id_width.max(id_str.width()); - max_mime_width = max_mime_width.max(mime_str.width()); - entries.push((id, preview, mime_str)); - } + // One-time column-width metadata (no blob reads). + let (max_id_width, max_mime_width) = + global_column_widths(self, include_expired)?; enable_raw_mode() .map_err(|e| StashError::ListDecode(e.to_string().into()))?; @@ -95,35 +323,160 @@ impl SqliteClipboardDb { let mut terminal = Terminal::new(backend) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut state = ListState::default(); - if !entries.is_empty() { - state.select(Some(0)); + // Derive initial window size from current terminal height. + let initial_height = terminal + .size() + .map(|r| r.height.saturating_sub(2) as usize) + .unwrap_or(24); + let initial_height = initial_height.max(1); + + let mut tui = TuiState::new( + self, + include_expired, + initial_height, + preview_width, + 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 res = (|| -> Result<(), StashError> { - loop { + /// Accumulated actions from draining the event queue. + struct EventActions { + quit: bool, + net_down: i64, // positive=down, negative=up, 0=none + copy: bool, + delete: bool, + 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())); + } + terminal .draw(|f| { let area = f.area(); - let block = Block::default() - .title( - "Clipboard Entries (j/k/↑/↓ to move, Enter to copy, Shift+D \ - to delete, q/ESC to quit)", + + // Build title based on search state + let title = if tui.search_mode { + format!("Search: {}", tui.search_query) + } else if tui.search_query.is_empty() { + "Clipboard Entries (j/k/↑/↓ to move, / to search, Enter to copy, \ + Shift+D to delete, q/ESC to quit)" + .to_string() + } else { + format!( + "Clipboard Entries (filtered: '{}' - {} results, / to search, \ + ESC to clear, q to quit)", + tui.search_query, tui.total ) - .borders(Borders::ALL); + }; + + let block = Block::default().title(title).borders(Borders::ALL); let border_width = 2; let highlight_symbol = ">"; let highlight_width = 1; let content_width = area.width as usize - border_width; - // Minimum widths for columns let min_id_width = 2; let min_mime_width = 6; let min_preview_width = 4; - let spaces = 3; // [id][ ][preview][ ][mime] + let spaces = 3; - // Dynamically allocate widths let mut id_col = max_id_width.max(min_id_width); let mut mime_col = max_mime_width.max(min_mime_width); let mut preview_col = content_width @@ -132,7 +485,6 @@ impl SqliteClipboardDb { .saturating_sub(mime_col) .saturating_sub(spaces); - // If not enough space, shrink columns if preview_col < min_preview_width { let needed = min_preview_width - preview_col; if mime_col > min_mime_width { @@ -155,13 +507,13 @@ impl SqliteClipboardDb { preview_col = min_preview_width; } - let selected = state.selected(); + let selected = list_state.selected(); - let list_items: Vec = entries + let list_items: Vec = tui + .window .iter() .enumerate() .map(|(i, entry)| { - // Truncate preview by grapheme clusters and display width let mut preview = String::new(); let mut width = 0; for g in entry.1.graphemes(true) { @@ -173,7 +525,6 @@ impl SqliteClipboardDb { preview.push_str(g); width += g_width; } - // Truncate and pad mimetype let mut mime = String::new(); let mut mwidth = 0; for g in entry.2.graphemes(true) { @@ -186,8 +537,6 @@ impl SqliteClipboardDb { mwidth += g_width; } - // Compose the row as highlight + id + space + preview + space + - // mimetype let mut spans = Vec::new(); let (id, preview, mime) = entry; if Some(i) == selected { @@ -234,66 +583,121 @@ impl SqliteClipboardDb { .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ) - .highlight_symbol(""); // handled manually + .highlight_symbol(""); - f.render_stateful_widget(list, area, &mut state); + f.render_stateful_widget(list, area, list_state); }) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + Ok(()) + }; + // Initial draw. + draw_frame( + &mut terminal, + &mut tui, + &mut list_state, + max_id_width, + max_mime_width, + )?; + + let res = (|| -> Result<(), StashError> { + loop { + // Block waiting for events, then drain and process all queued input. if event::poll(std::time::Duration::from_millis(250)) .map_err(|e| StashError::ListDecode(e.to_string().into()))? { - if let Event::Key(key) = event::read() - .map_err(|e| StashError::ListDecode(e.to_string().into()))? - { - match (key.code, key.modifiers) { - (KeyCode::Char('q') | KeyCode::Esc, _) => break, - (KeyCode::Down | KeyCode::Char('j'), _) => { - let i = match state.selected() { - Some(i) => { - if i >= entries.len() - 1 { - 0 - } else { - i + 1 + 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)" + ); + } else { + tui.copying_entry = Some(id); + match self.copy_entry(id) { + Ok((new_id, contents, mime)) => { + if new_id != id { + tui.dirty = true; } - }, - None => 0, - }; - state.select(Some(i)); - }, - (KeyCode::Up | KeyCode::Char('k'), _) => { - 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() { - if let Some((id, ..)) = entries.get(idx) { - // Fetch full contents for the selected entry - let (contents, mime): (Vec, Option) = self - .conn - .query_row( - "SELECT contents, mime FROM clipboard WHERE id = ?1", - rusqlite::params![id], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .map_err(|e| { - StashError::ListDecode(e.to_string().into()) - })?; - // Copy to clipboard let opts = Options::new(); - // Default clipboard is regular, seat is default let mime_type = match mime { Some(ref m) if m == "text/plain" => MimeType::Text, - Some(ref m) => MimeType::Specific(m.clone()), + Some(ref m) => MimeType::Specific(m.clone().clone()), None => MimeType::Text, }; let copy_result = opts @@ -306,50 +710,35 @@ 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}")) .show(); }, } - } - } - }, - (KeyCode::Char('D'), KeyModifiers::SHIFT) => { - if let Some(idx) = state.selected() { - if let Some((id, ..)) = entries.get(idx) { - // Delete entry from DB - self - .conn - .execute( - "DELETE FROM clipboard WHERE id = ?1", - rusqlite::params![id], - ) - .map_err(|e| { - StashError::DeleteEntry(*id, e.to_string().into()) - })?; - // Remove from entries and update selection - entries.remove(idx); - let new_len = entries.len(); - if new_len == 0 { - state.select(None); - } else if idx >= new_len { - state.select(Some(new_len - 1)); - } else { - state.select(Some(idx)); - } - // Show notification + }, + Err(e) => { + log::error!("failed to fetch entry {id}: {e}"); let _ = Notification::new() .summary("Stash") - .body("Deleted entry") + .body(&format!("Failed to fetch entry: {e}")) .show(); - } + }, } - }, - _ => {}, + tui.copying_entry = None; + } } } + + // Redraw once after processing all accumulated input. + draw_frame( + &mut terminal, + &mut tui, + &mut list_state, + max_id_width, + max_mime_width, + )?; } } Ok(()) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 67e9950..86b8c99 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,4 +5,3 @@ pub mod list; pub mod query; pub mod store; pub mod watch; -pub mod wipe; diff --git a/src/commands/query.rs b/src/commands/query.rs index c5b5851..e1bd465 100644 --- a/src/commands/query.rs +++ b/src/commands/query.rs @@ -6,6 +6,6 @@ pub trait QueryCommand { impl QueryCommand for SqliteClipboardDb { fn query_delete(&self, query: &str) -> Result { - ::delete_query(self, query) + ::delete_query(self, query) } } diff --git a/src/commands/store.rs b/src/commands/store.rs index 9e5a6c6..4495754 100644 --- a/src/commands/store.rs +++ b/src/commands/store.rs @@ -2,6 +2,7 @@ use std::io::Read; use crate::db::{ClipboardDb, SqliteClipboardDb}; +#[allow(clippy::too_many_arguments)] pub trait StoreCommand { fn store( &self, @@ -10,6 +11,8 @@ pub trait StoreCommand { max_items: u64, state: Option, excluded_apps: &[String], + min_size: Option, + max_size: usize, ) -> Result<(), crate::db::StashError>; } @@ -21,18 +24,24 @@ 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 ce2acf7..71cdc17 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -1,113 +1,712 @@ -use std::{ - collections::hash_map::DefaultHasher, - hash::{Hash, Hasher}, - io::Read, - time::Duration, -}; +use std::{collections::BinaryHeap, hash::Hasher, io::Read, time::Duration}; use smol::Timer; -use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents}; +use wl_clipboard_rs::{ + copy::{MimeType as CopyMimeType, Options, Source}, + paste::{ + ClipboardType, + MimeType as PasteMimeType, + Seat, + get_contents, + get_mime_types_ordered, + }, +}; -use crate::db::{ClipboardDb, SqliteClipboardDb}; +use crate::{ + clipboard::{self, ClipboardData, get_serving_pid}, + db::{SqliteClipboardDb, nonblocking::AsyncClipboardDb}, + hash::Fnv1aHasher, +}; +/// Wrapper to provide [`Ord`] implementation for `f64` by negating values. +/// This allows [`BinaryHeap`], which is a max-heap, to function as a min-heap. +/// Also see: +/// - +/// - +/// - +#[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 { - fn watch( + async 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 { - fn watch( + async 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, ) { - smol::block_on(async { - log::info!("Starting clipboard watch daemon"); + let async_db = AsyncClipboardDb::new(self.db_path.clone()); + log::info!( + "Starting clipboard watch daemon with MIME type preference: \ + {mime_type_preference}" + ); - // We use hashes for comparison instead of storing full contents - let mut last_hash: Option = None; - let mut buf = Vec::with_capacity(4096); + if persist { + log::info!("clipboard persistence enabled"); + } - // Helper to hash clipboard contents - let hash_contents = |data: &[u8]| -> u64 { - let mut hasher = DefaultHasher::new(); - data.hash(&mut hasher); - hasher.finish() - }; + // Build expiration queue from existing entries + let mut exp_queue = ExpirationQueue::new(); - // 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)); + // Load all expirations from database asynchronously + match async_db.load_all_expirations().await { + Ok(expirations) => { + for (expires_at, id) in expirations { + exp_queue.push(expires_at, id); } + if !exp_queue.is_empty() { + log::info!("loaded {} expirations from database", exp_queue.len()); + } + }, + Err(e) => { + log::warn!("failed to load expirations: {e}"); + }, + } + + // We use hashes for comparison instead of storing full contents + let mut last_hash: Option = None; + let mut buf = Vec::with_capacity(4096); + + // Helper to hash clipboard contents using FNV-1a (deterministic across + // runs) + let hash_contents = |data: &[u8]| -> u64 { + let mut hasher = Fnv1aHasher::new(); + hasher.write(data); + hasher.finish() + }; + + // Initialize with current clipboard using smart MIME negotiation + if let Ok((mut reader, ..)) = negotiate_mime_type(mime_type_preference) { + buf.clear(); + if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() { + last_hash = Some(hash_contents(&buf)); } + } - 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; - } + let poll_interval = Duration::from_millis(500); - // 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); - }, + 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}" + ); + } + } } } } - }, - 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; } - }); + + // Normal clipboard polling (always run, even when expirations are + // pending) + match negotiate_mime_type(mime_type_preference) { + Ok((mut reader, _mime_type, _all_mimes)) => { + buf.clear(); + if let Err(e) = reader.read_to_end(&mut buf) { + log::error!("failed to read clipboard contents: {e}"); + Timer::after(Duration::from_millis(500)).await; + continue; + } + + // Only store if changed and not empty + if !buf.is_empty() { + let current_hash = hash_contents(&buf); + if last_hash != Some(current_hash) { + // Clone buf for the async operation since it needs 'static + let buf_clone = buf.clone(); + #[allow(clippy::cast_possible_wrap)] + let content_hash = Some(current_hash as i64); + + // Clone data for persistence after successful store + let buf_for_persist = buf.clone(); + let mime_types_for_persist = _all_mimes.clone(); + let selected_mime = _mime_type.clone(); + + match async_db + .store_entry( + buf_clone, + max_dedupe_search, + max_items, + Some(excluded_apps.to_vec()), + min_size, + max_size, + content_hash, + Some(mime_types_for_persist.clone()), + ) + .await + { + Ok(id) => { + log::info!("stored new clipboard entry (id: {id})"); + last_hash = Some(current_hash); + + // Persist clipboard: fork child to serve data + // This keeps the clipboard alive when source app closes + // Check if we're already serving to avoid duplicate processes + if persist && get_serving_pid().is_none() { + let clipboard_data = ClipboardData::new( + buf_for_persist, + mime_types_for_persist, + selected_mime, + ); + + // Validate and persist in blocking task + if clipboard_data.is_valid().is_ok() { + smol::spawn(async move { + // Use blocking task for fork operation + let result = smol::unblock(move || unsafe { + clipboard::persist_clipboard(clipboard_data) + }) + .await; + + if let Err(e) = result { + log::debug!("clipboard persistence failed: {e}"); + } + }) + .detach(); + } + } else if persist { + log::trace!( + "Already serving clipboard, skipping persistence fork" + ); + } + + // Set expiration if configured + if let Some(duration) = expire_after { + let expires_at = + SqliteClipboardDb::now() + duration.as_secs_f64(); + if let Err(e) = + async_db.set_expiration(id, expires_at).await + { + log::warn!( + "Failed to set expiration for entry {id}: {e}" + ); + } else { + exp_queue.push(expires_at, id); + } + } + }, + Err(crate::db::StashError::ExcludedByApp(_)) => { + log::info!("clipboard entry excluded by app filter"); + last_hash = Some(current_hash); + }, + Err(crate::db::StashError::Store(ref msg)) + if msg.contains("Excluded by app filter") => + { + log::info!("clipboard entry excluded by app filter"); + last_hash = Some(current_hash); + }, + Err(e) => { + log::error!("failed to store clipboard entry: {e}"); + last_hash = Some(current_hash); + }, + } + } + } + }, + Err(e) => { + let error_msg = e.to_string(); + if !error_msg.contains("empty") { + log::error!("failed to get clipboard contents: {e}"); + } + }, + } + + // Calculate sleep time: min of poll interval and time until next + // expiration + let sleep_duration = if let Some(next_exp) = exp_queue.peek_next() { + let now = SqliteClipboardDb::now(); + let time_to_exp = (next_exp - now).max(0.0); + poll_interval.min(Duration::from_secs_f64(time_to_exp)) + } else { + poll_interval + }; + Timer::after(sleep_duration).await; + } + } +} + +/// Given ordered offers and a preference, return the +/// chosen MIME type. This mirrors the selection logic in +/// [`negotiate_mime_type`] without requiring a Wayland connection. +#[cfg(test)] +fn pick_mime<'a>( + offered: &'a [String], + preference: &str, +) -> Option<&'a String> { + if preference == "image" { + offered + .iter() + .find(|m| m.starts_with("image/")) + .or_else(|| offered.first()) + } else { + let has_image = offered.iter().any(|m| m.starts_with("image/")); + if has_image { + offered + .iter() + .find(|m| m.starts_with("image/")) + .or_else(|| offered.first()) + } else if offered.first().is_some_and(|m| m == "text/html") { + offered + .iter() + .find(|m| *m != "text/html") + .or_else(|| offered.first()) + } else { + offered.first() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pick_first_offered() { + let offered = vec!["text/uri-list".to_string(), "text/plain".to_string()]; + assert_eq!(pick_mime(&offered, "any").unwrap(), "text/uri-list"); + } + + #[test] + fn test_pick_image_preference_finds_image() { + let offered = vec![ + "text/html".to_string(), + "image/png".to_string(), + "text/plain".to_string(), + ]; + assert_eq!(pick_mime(&offered, "image").unwrap(), "image/png"); + } + + #[test] + fn test_pick_image_preference_falls_back() { + let offered = vec!["text/html".to_string(), "text/plain".to_string()]; + // No image types offered — falls back to first + assert_eq!(pick_mime(&offered, "image").unwrap(), "text/html"); + } + + #[test] + fn test_pick_empty_offered() { + let offered: Vec = 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 deleted file mode 100644 index c0bb9ee..0000000 --- a/src/commands/wipe.rs +++ /dev/null @@ -1,13 +0,0 @@ -use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; - -pub trait WipeCommand { - fn wipe(&self) -> Result<(), StashError>; -} - -impl WipeCommand for SqliteClipboardDb { - fn wipe(&self) -> Result<(), StashError> { - self.wipe_db()?; - log::info!("Database wiped"); - Ok(()) - } -} diff --git a/src/db/mod.rs b/src/db/mod.rs index fa27cce..65eb097 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,28 +1,186 @@ use std::{ - collections::hash_map::DefaultHasher, env, fmt, fs, - hash::{Hash, Hasher}, io::{BufRead, BufReader, Read, Write}, + path::PathBuf, str, - sync::OnceLock, + sync::{Mutex, OnceLock}, + time::{Duration, Instant}, }; +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 imagesize::ImageType; -use log::{debug, error, warn}; +use log::{debug, error, info, warn}; +use mime_sniffer::MimeTypeSniffer; use regex::Regex; use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; 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), @@ -56,17 +214,34 @@ pub enum StashError { #[error("Failed to delete entry during query delete: {0}")] QueryDelete(Box), #[error("Failed to delete entry with id {0}: {1}")] - DeleteEntry(u64, Box), + DeleteEntry(i64, Box), } pub trait ClipboardDb { + /// 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]>, - ) -> Result; + min_size: Option, + max_size: usize, + content_hash: Option, + mime_types: Option<&[String]>, + ) -> Result; fn deduplicate_by_hash( &self, @@ -80,6 +255,8 @@ pub trait ClipboardDb { &self, out: impl Write, preview_width: u32, + include_expired: bool, + reverse: bool, ) -> Result; fn decode_entry( &self, @@ -89,7 +266,10 @@ pub trait ClipboardDb { ) -> Result<(), StashError>; fn delete_query(&self, query: &str) -> Result; fn delete_entries(&self, input: impl Read) -> Result; - fn next_sequence(&self) -> u64; + fn copy_entry( + &self, + id: i64, + ) -> Result<(i64, Vec, Option), StashError>; } #[derive(Serialize, Deserialize)] @@ -106,11 +286,15 @@ impl fmt::Display for Entry { } pub struct SqliteClipboardDb { - pub conn: Connection, + pub conn: Connection, + pub db_path: PathBuf, } impl SqliteClipboardDb { - pub fn new(conn: Connection) -> Result { + pub fn new( + mut conn: Connection, + db_path: PathBuf, + ) -> Result { conn .pragma_update(None, "synchronous", "OFF") .map_err(|e| { @@ -139,40 +323,257 @@ impl SqliteClipboardDb { conn.pragma_update(None, "page_size", "512") // small(er) pages .map_err(|e| StashError::Store(format!("Failed to set page_size pragma: {e}").into()))?; - conn - .execute_batch( + let tx = conn.transaction().map_err(|e| { + StashError::Store( + format!("Failed to begin migration transaction: {e}").into(), + ) + })?; + + let schema_version: i64 = tx + .pragma_query_value(None, "user_version", |row| row.get(0)) + .map_err(|e| { + StashError::Store(format!("Failed to read schema version: {e}").into()) + })?; + + if schema_version == 0 { + tx.execute_batch( "CREATE TABLE IF NOT EXISTS clipboard ( id INTEGER PRIMARY KEY AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT );", ) - .map_err(|e| StashError::Store(e.to_string().into()))?; + .map_err(|e| { + StashError::Store( + format!("Failed to create clipboard table: {e}").into(), + ) + })?; - // Add content_hash column if it doesn't exist - // Migration MUST be done to avoid breaking existing installations. - let _ = - conn.execute("ALTER TABLE clipboard ADD COLUMN content_hash INTEGER", []); + tx.execute("PRAGMA user_version = 1", []).map_err(|e| { + StashError::Store(format!("Failed to set schema version: {e}").into()) + })?; + } - // Create index for content_hash if it doesn't exist - let _ = conn.execute( - "CREATE INDEX IF NOT EXISTS idx_content_hash ON clipboard(content_hash)", - [], - ); + // 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( + "SELECT sql FROM sqlite_master WHERE type='table' AND \ + name='clipboard'", + [], + |row| { + let sql: String = row.get(0)?; + Ok(sql.to_lowercase().contains("content_hash")) + }, + ) + .unwrap_or(false); + + if !has_content_hash { + tx.execute("ALTER TABLE clipboard ADD COLUMN content_hash INTEGER", []) + .map_err(|e| { + StashError::Store( + format!("Failed to add content_hash column: {e}").into(), + ) + })?; + } + + // Create index for content_hash if it doesn't exist + tx.execute( + "CREATE INDEX IF NOT EXISTS idx_content_hash ON \ + clipboard(content_hash)", + [], + ) + .map_err(|e| { + StashError::Store( + format!("Failed to create content_hash index: {e}").into(), + ) + })?; + + tx.execute("PRAGMA user_version = 2", []).map_err(|e| { + StashError::Store(format!("Failed to set schema version: {e}").into()) + })?; + } + + // Add last_accessed column if it doesn't exist + if schema_version < 3 { + let has_last_accessed: bool = tx + .query_row( + "SELECT sql FROM sqlite_master WHERE type='table' AND \ + name='clipboard'", + [], + |row| { + let sql: String = row.get(0)?; + Ok(sql.to_lowercase().contains("last_accessed")) + }, + ) + .unwrap_or(false); + + if !has_last_accessed { + tx.execute("ALTER TABLE clipboard ADD COLUMN last_accessed INTEGER", [ + ]) + .map_err(|e| { + StashError::Store( + format!("Failed to add last_accessed column: {e}").into(), + ) + })?; + } + + // Create index for last_accessed if it doesn't exist + tx.execute( + "CREATE INDEX IF NOT EXISTS idx_last_accessed ON \ + clipboard(last_accessed)", + [], + ) + .map_err(|e| { + StashError::Store( + format!("Failed to create last_accessed index: {e}").into(), + ) + })?; + + tx.execute("PRAGMA user_version = 3", []).map_err(|e| { + StashError::Store(format!("Failed to set schema version: {e}").into()) + })?; + } + + // 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(), + ) + })?; // Initialize Wayland state in background thread. This will be used to track // focused window state. #[cfg(feature = "use-toplevel")] crate::wayland::init_wayland_state(); - Ok(Self { conn }) + Ok(Self { conn, db_path }) } } impl SqliteClipboardDb { - pub fn list_json(&self) -> Result { + pub fn list_json( + &self, + 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("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") + .prepare(&query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -184,7 +585,7 @@ impl SqliteClipboardDb { .next() .map_err(|e| StashError::ListDecode(e.to_string().into()))? { - let id: u64 = row + let id: i64 = row .get(0) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let contents: Vec = row @@ -219,35 +620,52 @@ impl ClipboardDb for SqliteClipboardDb { max_dedupe_search: u64, max_items: u64, excluded_apps: Option<&[String]>, - ) -> Result { + 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() - || buf.len() > 5 * 1_000_000 - { + if input.read_to_end(&mut buf).is_err() || buf.is_empty() { return Err(StashError::EmptyOrTooLarge); } + + let size = buf.len(); + + if let Some(min) = min_size + && size < min + { + return Err(StashError::TooSmall(min)); + } + + if size > max_size { + return Err(StashError::TooLarge(max_size)); + } + if buf.iter().all(u8::is_ascii_whitespace) { return Err(StashError::AllWhitespace); } - // 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; + // Use pre-computed hash if provided, otherwise calculate it + let content_hash = content_hash.unwrap_or_else(|| { + let mut hasher = Fnv1aHasher::new(); + hasher.write(&buf); + #[allow(clippy::cast_possible_wrap)] + let hash = hasher.finish() as i64; + hash + }); - let mime = detect_mime_optimized(&buf); + let mime = crate::mime::detect_mime(&buf); // Try to load regex from systemd credential file, then env var let regex = load_sensitive_regex(); if let Some(re) = regex { // Only check text data - if let Ok(s) = std::str::from_utf8(&buf) { - if re.is_match(s) { - warn!("Clipboard entry matches sensitive regex, skipping store."); - return Err(StashError::Store("Filtered by sensitive regex".into())); - } + if let Ok(s) = std::str::from_utf8(&buf) + && re.is_match(s) + { + warn!("Clipboard entry matches sensitive regex, skipping store."); + return Err(StashError::Store("Filtered by sensitive regex".into())); } } @@ -261,17 +679,41 @@ 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) VALUES (?1, ?2, \ - ?3)", - params![buf, mime.map(|s| s.to_string()), content_hash], + "INSERT INTO clipboard (contents, mime, content_hash, last_accessed, \ + mime_types) VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + buf, + mime, + content_hash, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("Time went backwards") + .as_secs() as i64, + mime_types_json + ], ) .map_err(|e| StashError::Store(e.to_string().into()))?; + let id = self + .conn + .query_row("SELECT last_insert_rowid()", [], |row| row.get(0)) + .map_err(|e| StashError::Store(e.to_string().into()))?; + self.trim_db(max_items)?; - Ok(self.next_sequence()) + Ok(id) } fn deduplicate_by_hash( @@ -297,7 +739,7 @@ impl ClipboardDb for SqliteClipboardDb { .next() .map_err(|e| StashError::DeduplicationRead(e.to_string().into()))? { - let id: u64 = row + let id: i64 = row .get(0) .map_err(|e| StashError::DeduplicationDecode(e.to_string().into()))?; self @@ -310,12 +752,15 @@ impl ClipboardDb for SqliteClipboardDb { } fn trim_db(&self, max: u64) -> Result<(), StashError> { - let count: u64 = self + let count: i64 = self .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) .map_err(|e| StashError::Trim(e.to_string().into()))?; - if count > max { - let to_delete = count - max; + let max_i64 = i64::try_from(max).unwrap_or(i64::MAX); + if count > max_i64 { + let to_delete = count - max_i64; + + #[allow(clippy::useless_conversion)] self .conn .execute( @@ -329,7 +774,7 @@ impl ClipboardDb for SqliteClipboardDb { } fn delete_last(&self) -> Result<(), StashError> { - let id: Option = self + let id: Option = self .conn .query_row( "SELECT id FROM clipboard ORDER BY id DESC LIMIT 1", @@ -365,10 +810,14 @@ 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("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") + .prepare(&query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) @@ -379,7 +828,7 @@ impl ClipboardDb for SqliteClipboardDb { .next() .map_err(|e| StashError::ListDecode(e.to_string().into()))? { - let id: u64 = row + let id: i64 = row .get(0) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let contents: Vec = row @@ -413,7 +862,7 @@ impl ClipboardDb for SqliteClipboardDb { .map_err(|e| StashError::DecodeExtractId(e.to_string().into()))?; buf }; - let id = extract_id(&input_str) + let id: i64 = extract_id(&input_str) .map_err(|e| StashError::DecodeExtractId(e.into()))?; let (contents, _mime): (Vec, Option) = self .conn @@ -426,7 +875,7 @@ impl ClipboardDb for SqliteClipboardDb { out .write_all(&contents) .map_err(|e| StashError::DecodeWrite(e.to_string().into()))?; - log::info!("Decoded entry with id {id}"); + log::info!("decoded entry with id {id}"); Ok(()) } @@ -443,7 +892,7 @@ impl ClipboardDb for SqliteClipboardDb { .next() .map_err(|e| StashError::QueryDelete(e.to_string().into()))? { - let id: u64 = row + let id: i64 = row .get(0) .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; let contents: Vec = row @@ -475,15 +924,222 @@ impl ClipboardDb for SqliteClipboardDb { Ok(deleted) } - fn next_sequence(&self) -> u64 { - 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, + fn copy_entry( + &self, + id: i64, + ) -> Result<(i64, Vec, Option), StashError> { + let (contents, mime, content_hash): (Vec, Option, Option) = + self + .conn + .query_row( + "SELECT contents, mime, content_hash FROM clipboard WHERE id = ?1", + params![id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; + + if let Some(hash) = content_hash { + let most_recent_id: Option = self + .conn + .query_row( + "SELECT id FROM clipboard WHERE content_hash = ?1 AND last_accessed \ + = (SELECT MAX(last_accessed) FROM clipboard WHERE content_hash = \ + ?1)", + params![hash], + |row| row.get(0), + ) + .optional() + .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; + + if most_recent_id != Some(id) { + self + .conn + .execute( + "UPDATE clipboard SET last_accessed = CAST(strftime('%s', 'now') \ + AS INTEGER) WHERE id = ?1", + params![id], + ) + .map_err(|e| StashError::Store(e.to_string().into()))?; + } } + + Ok((id, contents, mime)) + } +} + +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)) + } + .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" + )) } } @@ -492,82 +1148,48 @@ impl ClipboardDb for SqliteClipboardDb { /// # Returns /// /// `Some(Regex)` if present and valid, `None` otherwise. +/// +/// # Note +/// +/// This function checks environment variables on every call to pick up +/// changes made after daemon startup. Regex compilation is cached by +/// pattern to avoid recompilation. fn load_sensitive_regex() -> Option { - static REGEX_CACHE: OnceLock> = OnceLock::new(); - static CHECKED: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); + // Get the current pattern from env vars + let pattern = if let Ok(regex_path) = env::var("CREDENTIALS_DIRECTORY") { + let file = format!("{regex_path}/clipboard_filter"); + fs::read_to_string(&file).ok().map(|s| s.trim().to_string()) + } else { + env::var("STASH_SENSITIVE_REGEX").ok() + }?; - if !CHECKED.load(std::sync::atomic::Ordering::Relaxed) { - CHECKED.store(true, std::sync::atomic::Ordering::Relaxed); + // Cache compiled regexes by pattern to avoid recompilation + static REGEX_CACHE: OnceLock< + Mutex>, + > = OnceLock::new(); + let cache = + REGEX_CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new())); - let regex = if let Ok(regex_path) = env::var("CREDENTIALS_DIRECTORY") { - let file = format!("{regex_path}/clipboard_filter"); - if let Ok(contents) = fs::read_to_string(&file) { - Regex::new(contents.trim()).ok() - } else { - None - } - } else if let Ok(pattern) = env::var("STASH_SENSITIVE_REGEX") { - Regex::new(&pattern).ok() - } else { - None - }; - - let _ = REGEX_CACHE.set(regex); + // Check cache first + if let Ok(cache) = cache.lock() + && let Some(regex) = cache.get(&pattern) + { + return Some(regex.clone()); } - REGEX_CACHE.get().and_then(std::clone::Clone::clone) + // Compile and cache + Regex::new(&pattern).ok().inspect(|regex| { + if let Ok(mut cache) = cache.lock() { + cache.insert(pattern.clone(), regex.clone()); + } + }) } -pub fn extract_id(input: &str) -> Result { +pub fn extract_id(input: &str) -> Result { let id_str = input.split('\t').next().unwrap_or(""); 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(_) => "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/") { @@ -602,26 +1224,14 @@ pub fn preview_entry(data: &[u8], mime: Option<&str>, width: u32) -> String { } } - // For non-text data, use lossy conversion - let s = String::from_utf8_lossy(data); - truncate(s.trim(), width as usize, "…") -} - -pub fn truncate(s: &str, max: usize, ellip: &str) -> String { - let char_count = s.chars().count(); - if char_count > max { - let mut result = String::with_capacity(max * 4 + ellip.len()); // UTF-8 worst case - let mut char_iter = s.chars(); - for _ in 0..max { - if let Some(c) = char_iter.next() { - result.push(c); - } - } - result.push_str(ellip); - result - } else { - s.to_string() + // For non-text/non-image data, try to sniff the MIME type + if let Some(sniffed) = data.sniff_mime_type() { + return format!("[[ binary data {} {} ]]", size_str(data.len()), sniffed); } + + // Shouldn't reach here if MIME is properly set, but just in case + info!("Mimetype sniffing failed, omitting"); + format!("[[ binary data {} ]]", size_str(data.len())) } pub fn size_str(size: usize) -> String { @@ -673,7 +1283,8 @@ fn detect_excluded_app_activity(excluded_apps: &[String]) -> bool { } // Strategy 2: Check recently active processes (timing correlation) - if let Some(active_app) = get_recently_active_excluded_app(excluded_apps) { + // Use cached results to avoid expensive /proc scanning + if let Some(active_app) = ProcessCache::get(excluded_apps) { debug!("Clipboard excluded: recent activity from {active_app}"); return true; } @@ -692,11 +1303,11 @@ fn get_focused_window_app() -> Option { } // Fallback: Check WAYLAND_CLIENT_NAME environment variable - if let Ok(client) = env::var("WAYLAND_CLIENT_NAME") { - if !client.is_empty() { - debug!("Found WAYLAND_CLIENT_NAME: {client}"); - return Some(client); - } + if let Ok(client) = env::var("WAYLAND_CLIENT_NAME") + && !client.is_empty() + { + debug!("Found WAYLAND_CLIENT_NAME: {client}"); + return Some(client); } debug!("No focused window detection method worked"); @@ -704,7 +1315,8 @@ fn get_focused_window_app() -> Option { } /// Check for recently active excluded apps using CPU and I/O activity. -fn get_recently_active_excluded_app( +/// This is the uncached version - use `ProcessCache::get()` for cached access. +fn get_recently_active_excluded_app_uncached( excluded_apps: &[String], ) -> Option { let proc_dir = std::path::Path::new("/proc"); @@ -716,19 +1328,17 @@ fn get_recently_active_excluded_app( if let Ok(entries) = std::fs::read_dir(proc_dir) { for entry in entries.flatten() { - if let Ok(pid) = entry.file_name().to_string_lossy().parse::() { - if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) { - let process_name = comm.trim(); + if let Ok(pid) = entry.file_name().to_string_lossy().parse::() + && let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) + { + let process_name = comm.trim(); - // Check process name against exclusion list - if app_matches_exclusion(process_name, excluded_apps) - && has_recent_activity(pid) - { - candidates.push(( - process_name.to_string(), - get_process_activity_score(pid), - )); - } + // Check process name against exclusion list + if app_matches_exclusion(process_name, excluded_apps) + && has_recent_activity(pid) + { + candidates + .push((process_name.to_string(), get_process_activity_score(pid))); } } } @@ -762,15 +1372,13 @@ fn has_recent_activity(pid: u32) -> bool { // Check /proc/PID/io for recent I/O activity if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) { for line in io_stats.lines() { - if line.starts_with("write_bytes:") || line.starts_with("read_bytes:") { - if let Some(value_str) = line.split(':').nth(1) { - if let Ok(value) = value_str.trim().parse::() { - if value > 1024 * 1024 { - // 1MB threshold - return true; - } - } - } + if (line.starts_with("write_bytes:") || line.starts_with("read_bytes:")) + && let Some(value_str) = line.split(':').nth(1) + && let Ok(value) = value_str.trim().parse::() + && value > 1024 * 1024 + { + // 1MB threshold + return true; } } } @@ -785,24 +1393,22 @@ fn get_process_activity_score(pid: u32) -> u64 { // Add CPU time to score if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { let fields: Vec<&str> = stat.split_whitespace().collect(); - if fields.len() > 14 { - if let (Ok(utime), Ok(stime)) = + if fields.len() > 14 + && let (Ok(utime), Ok(stime)) = (fields[13].parse::(), fields[14].parse::()) - { - score += utime + stime; - } + { + score += utime + stime; } } // Add I/O activity to score if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) { for line in io_stats.lines() { - if line.starts_with("write_bytes:") || line.starts_with("read_bytes:") { - if let Some(value_str) = line.split(':').nth(1) { - if let Ok(value) = value_str.trim().parse::() { - score += value / 1024; // convert to KB - } - } + if (line.starts_with("write_bytes:") || line.starts_with("read_bytes:")) + && let Some(value_str) = line.split(':').nth(1) + && let Ok(value) = value_str.trim().parse::() + { + score += value / 1024; // convert to KB } } } @@ -833,11 +1439,11 @@ fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool { } else if excluded.contains('*') { // Simple wildcard matching let pattern = excluded.replace('*', ".*"); - if let Ok(regex) = regex::Regex::new(&pattern) { - if regex.is_match(app_name) { - debug!("Matched wildcard pattern: {app_name} matches {excluded}"); - return true; - } + if let Ok(regex) = regex::Regex::new(&pattern) + && regex.is_match(app_name) + { + debug!("Matched wildcard pattern: {app_name} matches {excluded}"); + return true; } } } @@ -845,3 +1451,791 @@ fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool { debug!("No match found for '{app_name}'"); false } + +#[cfg(test)] +mod tests { + use rusqlite::Connection; + + use super::*; + + /// 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)) + } + + fn table_column_exists(conn: &Connection, table: &str, column: &str) -> bool { + let query = format!( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='{}'", + table + ); + match conn.query_row(&query, [], |row| row.get::<_, String>(0)) { + Ok(sql) => sql.contains(column), + Err(_) => false, + } + } + + fn index_exists(conn: &Connection, index: &str) -> bool { + let query = "SELECT name FROM sqlite_master WHERE type='index' AND name=?1"; + conn + .query_row(query, [index], |row| row.get::<_, String>(0)) + .is_ok() + } + + #[test] + fn test_fresh_database_v3_schema() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_fresh.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + + let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create database"); + + assert_eq!( + get_schema_version(&db.conn).expect("Failed to get schema version"), + 6 + ); + + assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); + assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); + assert!(table_column_exists(&db.conn, "clipboard", "mime_types")); + + assert!(index_exists(&db.conn, "idx_content_hash")); + assert!(index_exists(&db.conn, "idx_last_accessed")); + + db.conn + .execute( + "INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \ + VALUES (x'010203', 'text/plain', 12345, 1704067200)", + [], + ) + .expect("Failed to insert test data"); + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 1); + } + + #[test] + fn test_migration_from_v0() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_v0.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + + conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \ + AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT);", + ) + .expect("Failed to create table"); + + conn + .execute_batch( + "INSERT INTO clipboard (contents, mime) VALUES (x'010203', \ + 'text/plain')", + ) + .expect("Failed to insert data"); + + assert_eq!(get_schema_version(&conn).expect("Failed to get version"), 0); + + let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create database"); + + assert_eq!( + get_schema_version(&db.conn) + .expect("Failed to get version after migration"), + 6 + ); + + assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); + assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); + assert!(table_column_exists(&db.conn, "clipboard", "mime_types")); + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 1, "Existing data should be preserved"); + } + + #[test] + fn test_migration_from_v1() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_v1.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + + conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \ + AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT);", + ) + .expect("Failed to create table"); + + conn + .pragma_update(None, "user_version", 1i64) + .expect("Failed to set version"); + + conn + .execute_batch( + "INSERT INTO clipboard (contents, mime) VALUES (x'010203', \ + 'text/plain')", + ) + .expect("Failed to insert data"); + + let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create database"); + + assert_eq!( + get_schema_version(&db.conn) + .expect("Failed to get version after migration"), + 6 + ); + + assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); + assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); + assert!(table_column_exists(&db.conn, "clipboard", "mime_types")); + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 1, "Existing data should be preserved"); + } + + #[test] + fn test_migration_from_v2() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_v2.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + + conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \ + AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT, content_hash \ + INTEGER);", + ) + .expect("Failed to create table"); + + conn + .pragma_update(None, "user_version", 2i64) + .expect("Failed to set version"); + + conn + .execute_batch( + "INSERT INTO clipboard (contents, mime, content_hash) VALUES \ + (x'010203', 'text/plain', 12345)", + ) + .expect("Failed to insert data"); + + let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create database"); + + assert_eq!( + get_schema_version(&db.conn) + .expect("Failed to get version after migration"), + 6 + ); + + assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); + assert!(index_exists(&db.conn, "idx_last_accessed")); + assert!(table_column_exists(&db.conn, "clipboard", "mime_types")); + + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) + .expect("Failed to count"); + assert_eq!(count, 1, "Existing data should be preserved"); + } + + #[test] + fn test_idempotent_migration() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_idempotent.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + + conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \ + AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT);", + ) + .expect("Failed to create table"); + + let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .expect("Failed to create database"); + let version_after_first = + get_schema_version(&db.conn).expect("Failed to get version"); + + let db2 = SqliteClipboardDb::new(db.conn, db.db_path) + .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); + } + + #[test] + fn test_store_and_retrieve_with_new_columns() { + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let db_path = temp_dir.path().join("test_store.db"); + let conn = Connection::open(&db_path).expect("Failed to open database"); + let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) + .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, + ) + .expect("Failed to store entry"); + + 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 new file mode 100644 index 0000000..d62e0dd --- /dev/null +++ b/src/db/nonblocking.rs @@ -0,0 +1,375 @@ +use std::path::PathBuf; + +use rusqlite::OptionalExtension; + +use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; + +/// Async wrapper for database operations that runs blocking operations +/// on a thread pool to avoid blocking the async runtime. Since +/// [`rusqlite::Connection`] is not Send, we store the database path and open a +/// new connection for each operation. +pub struct AsyncClipboardDb { + db_path: PathBuf, +} + +impl AsyncClipboardDb { + pub fn new(db_path: PathBuf) -> Self { + Self { db_path } + } + + #[expect(clippy::too_many_arguments)] + pub async fn store_entry( + &self, + data: Vec, + 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 new file mode 100644 index 0000000..f017a51 --- /dev/null +++ b/src/hash.rs @@ -0,0 +1,101 @@ +/// FNV-1a hasher for deterministic hashing across process runs. +/// +/// Unlike `std::collections::hash_map::DefaultHasher` (which uses SipHash +/// with a random seed), this produces stable hashes suitable for persistent +/// storage and cross-process comparison. +/// +/// # Example +/// +/// ``` +/// use std::hash::Hasher; +/// +/// use stash::hash::Fnv1aHasher; +/// +/// let mut hasher = Fnv1aHasher::new(); +/// hasher.write(b"hello"); +/// let hash = hasher.finish(); +/// ``` +#[derive(Clone, Copy, Debug)] +pub struct Fnv1aHasher { + state: u64, +} + +impl Fnv1aHasher { + const FNV_OFFSET: u64 = 0xCBF29CE484222325; + const FNV_PRIME: u64 = 0x100000001B3; + + /// Creates a new hasher initialized with the FNV-1a offset basis. + #[must_use] + pub fn new() -> Self { + Self { + state: Self::FNV_OFFSET, + } + } +} + +impl Default for Fnv1aHasher { + fn default() -> Self { + Self::new() + } +} + +impl std::hash::Hasher for Fnv1aHasher { + fn write(&mut self, bytes: &[u8]) { + for byte in bytes { + self.state ^= u64::from(*byte); + self.state = self.state.wrapping_mul(Self::FNV_PRIME); + } + } + + fn finish(&self) -> u64 { + self.state + } +} + +#[cfg(test)] +mod tests { + use std::hash::Hasher; + + use super::*; + + #[test] + fn test_fnv1a_basic() { + let mut hasher = Fnv1aHasher::new(); + hasher.write(b"hello"); + // FNV-1a hash for "hello" (little-endian u64) + assert_eq!(hasher.finish(), 0xA430D84680AABD0B); + } + + #[test] + fn test_fnv1a_empty() { + let hasher = Fnv1aHasher::new(); + // Empty input should return offset basis + assert_eq!(hasher.finish(), Fnv1aHasher::FNV_OFFSET); + } + + #[test] + fn test_fnv1a_deterministic() { + // Same input must produce same hash + let mut h1 = Fnv1aHasher::new(); + let mut h2 = Fnv1aHasher::new(); + h1.write(b"test data"); + h2.write(b"test data"); + assert_eq!(h1.finish(), h2.finish()); + } + + #[test] + fn test_default_trait() { + let h1 = Fnv1aHasher::new(); + let h2 = Fnv1aHasher::default(); + assert_eq!(h1.finish(), h2.finish()); + } + + #[test] + fn test_copy_trait() { + let mut hasher = Fnv1aHasher::new(); + hasher.write(b"data"); + let copied = hasher; + // Both should have same state after copy + assert_eq!(hasher.finish(), copied.finish()); + } +} diff --git a/src/main.rs b/src/main.rs index f5c6b2e..f006d36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,27 +1,39 @@ +mod clipboard; +mod commands; +mod db; +mod hash; +mod mime; +mod multicall; + use std::{ env, io::{self, IsTerminal}, path::PathBuf, - process, + time::Duration, }; use clap::{CommandFactory, Parser, Subcommand}; +use color_eyre::eyre; +use humantime::parse_duration; use inquire::Confirm; -mod commands; -mod db; -mod multicall; +// While the module is named "wayland", the Wayland module is *strictly* for the +// use-toplevel feature as it requires some low-level wayland crates that are +// not required *by default*. The module is named that way because "toplevel" +// sounded too silly. Stash is strictly a Wayland clipboard manager. #[cfg(feature = "use-toplevel")] mod wayland; -use crate::commands::{ - decode::DecodeCommand, - delete::DeleteCommand, - import::ImportCommand, - list::ListCommand, - query::QueryCommand, - store::StoreCommand, - watch::WatchCommand, - wipe::WipeCommand, +use crate::{ + commands::{ + decode::DecodeCommand, + delete::DeleteCommand, + import::ImportCommand, + list::ListCommand, + query::QueryCommand, + store::StoreCommand, + watch::WatchCommand, + }, + db::{ClipboardDb, DEFAULT_MAX_ENTRY_SIZE}, }; #[derive(Parser)] @@ -40,13 +52,23 @@ 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)] preview_width: u32, /// Path to the `SQLite` clipboard database file. - #[arg(long)] + #[arg(long, env = "STASH_DB_PATH")] db_path: Option, /// Application names to exclude from clipboard history @@ -72,6 +94,14 @@ 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 @@ -93,11 +123,10 @@ enum Command { ask: bool, }, - /// Wipe all clipboard history - Wipe { - /// Ask for confirmation before wiping - #[arg(long)] - ask: bool, + /// Database management operations + Db { + #[command(subcommand)] + action: DbAction, }, /// Import clipboard data from stdin (default: TSV format) @@ -112,7 +141,39 @@ enum Command { }, /// Start a process to watch clipboard for changes and store automatically. - Watch, + Watch { + /// Expire new entries after duration (e.g., "3s", "500ms", "1h30m"). + #[arg(long, value_parser = parse_duration)] + expire_after: Option, + + /// 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, } fn report_error( @@ -128,48 +189,72 @@ 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() { - // Multicall dispatch: stash-copy, stash-paste, wl-copy, wl-paste - if crate::multicall::multicall_dispatch() { - // If handled, exit immediately - std::process::exit(0); +fn main() -> eyre::Result<()> { + color_eyre::install()?; + + // Check if we're being called as a multicall binary + // + // NOTE: We cannot use clap's multicall here because it requires the main + // command to have no arguments (only subcommands), but our Cli has global + // arguments like --max-items, --db-path, etc. Instead, we manually detect + // the invocation name and route appropriately. While this is ugly, it's + // seemingly the only option. + let program_name = env::args().next().map(|s| { + PathBuf::from(s) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("stash") + .to_string() + }); + + if let Some(ref name) = program_name { + if name == "wl-copy" || name == "stash-copy" { + crate::multicall::wl_copy::wl_copy_main()?; + return Ok(()); + } else if name == "wl-paste" || name == "stash-paste" { + crate::multicall::wl_paste::wl_paste_main()?; + return Ok(()); + } } - // If not multicall, proceed with normal CLI handling + // Normal CLI handling smol::block_on(async { let cli = Cli::parse(); env_logger::Builder::new() .filter_level(cli.verbosity.into()) .init(); - let db_path = cli.db_path.unwrap_or_else(|| { - dirs::cache_dir() - .unwrap_or_else(|| PathBuf::from("/tmp")) - .join("stash") - .join("db") - }); - - if let Some(parent) = db_path.parent() { - if let Err(e) = std::fs::create_dir_all(parent) { - log::error!("Failed to create database directory: {e}"); - process::exit(1); - } - } - - let conn = rusqlite::Connection::open(&db_path).unwrap_or_else(|e| { - log::error!("Failed to open SQLite database: {e}"); - process::exit(1); - }); - - let db = match db::SqliteClipboardDb::new(conn) { - Ok(db) => db, - Err(e) => { - log::error!("Failed to initialize SQLite database: {e}"); - process::exit(1); + let db_path = match cli.db_path { + Some(path) => path, + None => { + let cache_dir = dirs::cache_dir().ok_or_else(|| { + eyre::eyre!( + "Could not determine cache directory. Set --db-path or \ + $STASH_DB_PATH explicitly." + ) + })?; + cache_dir.join("stash").join("db") }, }; + if let Some(parent) = db_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let conn = rusqlite::Connection::open(&db_path)?; + let db = db::SqliteClipboardDb::new(conn, db_path)?; + match cli.command { Some(Command::Store) => { let state = env::var("STASH_CLIPBOARD_STATE").ok(); @@ -183,41 +268,47 @@ fn main() { &cli.excluded_apps, #[cfg(not(feature = "use-toplevel"))] &[], + cli.min_size, + cli.max_size, ), - "Failed to store entry", + "failed to store entry", ); }, - Some(Command::List { format }) => { + Some(Command::List { + format, + expired, + reverse, + }) => { match format.as_deref() { Some("tsv") => { report_error( - db.list(io::stdout(), cli.preview_width), - "Failed to list entries", + db.list(io::stdout(), cli.preview_width, expired, reverse), + "failed to list entries", ); }, Some("json") => { - match db.list_json() { + match db.list_json(expired, reverse) { Ok(json) => { println!("{json}"); }, Err(e) => { - log::error!("Failed to list entries as JSON: {e}"); + log::error!("failed to list entries as JSON: {e}"); }, } }, Some(other) => { - log::error!("Unsupported format: {other}"); + log::error!("unsupported format: {other}"); }, None => { if std::io::stdout().is_terminal() { report_error( - db.list_tui(cli.preview_width), - "Failed to list entries in TUI", + db.list_tui(cli.preview_width, expired, reverse), + "failed to list entries in TUI", ); } else { report_error( - db.list(io::stdout(), cli.preview_width), - "Failed to list entries", + db.list(io::stdout(), cli.preview_width, expired, reverse), + "failed to list entries", ); } }, @@ -226,20 +317,17 @@ fn main() { Some(Command::Decode { input }) => { report_error( db.decode(io::stdin(), io::stdout(), input), - "Failed to decode entry", + "failed to decode entry", ); }, Some(Command::Delete { arg, r#type, ask }) => { let mut should_proceed = true; if ask { should_proceed = - Confirm::new("Are you sure you want to delete clipboard entries?") - .with_default(false) - .prompt() - .unwrap_or(false); + confirm("Are you sure you want to delete clipboard entries?"); if !should_proceed { - log::info!("Aborted by user."); + log::info!("aborted by user."); } } if should_proceed { @@ -252,13 +340,13 @@ fn main() { "Failed to delete entry by id", ); } else { - log::error!("Argument is not a valid id"); + log::error!("argument is not a valid id"); } }, (Some(s), Some("query")) => { report_error( db.query_delete(&s), - "Failed to delete entry by query", + "failed to delete entry by query", ); }, (Some(s), None) => { @@ -266,57 +354,90 @@ fn main() { use std::io::Cursor; report_error( db.delete(Cursor::new(format!("{id}\n"))), - "Failed to delete entry by id", + "failed to delete entry by id", ); } else { report_error( db.query_delete(&s), - "Failed to delete entry by query", + "failed to delete entry by query", ); } }, (None, _) => { report_error( db.delete(io::stdin()), - "Failed to delete entry from stdin", + "failed to delete entry from stdin", ); }, (_, Some(_)) => { - log::error!("Unknown type for --type. Use \"id\" or \"query\"."); + log::error!("unknown type for --type. Use \"id\" or \"query\"."); }, } } }, - 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!("Aborted by user."); - } - } - if should_proceed { - report_error(db.wipe(), "Failed to wipe database"); + + Some(Command::Db { action }) => { + match action { + DbAction::Wipe { expired, ask } => { + let mut should_proceed = true; + if ask { + let message = if expired { + "Are you sure you want to wipe all expired clipboard entries?" + } else { + "Are you sure you want to wipe ALL clipboard history?" + }; + should_proceed = confirm(message); + if !should_proceed { + log::info!("db wipe command aborted by user."); + } + } + if should_proceed { + if expired { + match db.cleanup_expired() { + Ok(count) => { + log::info!("wiped {count} expired entries"); + }, + Err(e) => { + log::error!("failed to wipe expired entries: {e}"); + }, + } + } else { + report_error(db.wipe_db(), "failed to wipe database"); + } + } + }, + DbAction::Vacuum => { + match db.vacuum() { + Ok(()) => { + log::info!("database optimized successfully"); + }, + Err(e) => { + log::error!("failed to vacuum database: {e}"); + }, + } + }, + DbAction::Stats => { + match db.stats() { + Ok(stats) => { + println!("{stats}"); + }, + Err(e) => { + log::error!("failed to get database stats: {e}"); + }, + } + }, } }, Some(Command::Import { r#type, ask }) => { let mut should_proceed = true; if ask { - should_proceed = Confirm::new( + should_proceed = confirm( "Are you sure you want to import clipboard data? This may \ overwrite existing entries.", - ) - .with_default(false) - .prompt() - .unwrap_or(false); + ); if !should_proceed { - log::info!("Aborted by user."); + log::info!("import command aborted by user."); } } if should_proceed { @@ -326,16 +447,20 @@ fn main() { if let Err(e) = ImportCommand::import_tsv(&db, io::stdin(), cli.max_items) { - log::error!("Failed to import TSV: {e}"); + log::error!("failed to import TSV: {e}"); } }, _ => { - log::error!("Unsupported import format: {format}"); + log::error!("unsupported import format: {format}"); }, } } }, - Some(Command::Watch) => { + Some(Command::Watch { + expire_after, + mime_type, + persist, + }) => { db.watch( cli.max_dedupe_search, cli.max_items, @@ -343,14 +468,20 @@ fn main() { &cli.excluded_apps, #[cfg(not(feature = "use-toplevel"))] &[], - ); + expire_after, + &mime_type, + cli.min_size, + cli.max_size, + persist, + ) + .await; }, + None => { - if let Err(e) = Cli::command().print_help() { - log::error!("Failed to print help: {e}"); - } + Cli::command().print_help()?; println!(); }, } - }); + Ok(()) + }) } diff --git a/src/mime.rs b/src/mime.rs new file mode 100644 index 0000000..3761ab3 --- /dev/null +++ b/src/mime.rs @@ -0,0 +1,273 @@ +use imagesize::ImageType; + +/// Detect MIME type of clipboard data. We try binary detection first using +/// [`imagesize`] followed by a check for text/uri-list for file manager copies +/// and finally fall back to text/plain for UTF-8 or [`None`] for binary. +pub fn detect_mime(data: &[u8]) -> Option { + 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.rs b/src/multicall.rs deleted file mode 100644 index f387df0..0000000 --- a/src/multicall.rs +++ /dev/null @@ -1,282 +0,0 @@ -use std::io::{self, Read, Write}; - -use clap::{ArgAction, Parser}; -use wl_clipboard_rs::paste::{ - ClipboardType, - Error, - MimeType, - Seat, - get_contents, -}; - -/// Dispatch multicall binary logic based on argv[0]. -/// Returns true if a multicall command was handled and the process should exit. -pub fn multicall_dispatch() -> bool { - let argv0 = std::env::args().next().unwrap_or_default(); - let base = std::path::Path::new(&argv0) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or(""); - match base { - "stash-copy" | "wl-copy" => { - multicall_stash_copy(); - true - }, - "stash-paste" | "wl-paste" => { - multicall_stash_paste(); - true - }, - _ => false, - } -} - -#[allow(clippy::too_many_lines)] -fn multicall_stash_copy() { - use clap::{ArgAction, Parser}; - use wl_clipboard_rs::{ - copy::{ClipboardType, MimeType, Options, ServeRequests, Source}, - utils::{PrimarySelectionCheckError, is_primary_selection_supported}, - }; - #[derive(Parser, Debug)] - #[command( - name = "stash-copy", - about = "Copy clipboard contents on Wayland.", - version, - disable_help_subcommand = true - )] - #[allow(clippy::struct_excessive_bools)] - struct Args { - /// Serve only a single paste request and then exit - #[arg(short = 'o', long = "paste-once", action = ArgAction::SetTrue)] - paste_once: bool, - /// Stay in the foreground instead of forking - #[arg(short = 'f', long = "foreground", action = ArgAction::SetTrue)] - foreground: bool, - /// Clear the clipboard instead of copying - #[arg(short = 'c', long = "clear", action = ArgAction::SetTrue)] - clear: bool, - /// Use the \"primary\" clipboard - #[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)] - primary: bool, - /// Use the regular clipboard - #[arg(short = 'r', long = "regular", action = ArgAction::SetTrue)] - regular: bool, - /// Trim the trailing newline character before copying - #[arg(short = 'n', long = "trim-newline", action = ArgAction::SetTrue)] - trim_newline: bool, - /// Pick the seat to work with - #[arg(short = 's', long = "seat")] - seat: Option, - /// Override the inferred MIME type for the content - #[arg(short = 't', long = "type")] - mime_type: Option, - /// Enable verbose logging - #[arg(short = 'v', long = "verbose", action = ArgAction::Count)] - verbose: u8, - /// Check if primary selection is supported and exit - #[arg(long = "check-primary", action = ArgAction::SetTrue)] - check_primary: bool, - /// Do not offer additional text mime types (stash extension) - #[arg(long = "omit-additional-text-mime-types", action = ArgAction::SetTrue, hide = true)] - omit_additional_text_mime_types: bool, - /// Number of paste requests to serve before exiting (stash extension) - #[arg(short = 'x', long = "serve-requests", hide = true)] - serve_requests: Option, - /// Text to copy (if not given, read from stdin) - #[arg(value_name = "TEXT TO COPY", action = ArgAction::Append)] - text: Vec, - } - - let args = Args::parse(); - - if args.check_primary { - match is_primary_selection_supported() { - Ok(true) => { - log::info!("primary selection is supported."); - std::process::exit(0); - }, - Ok(false) => { - log::info!("primary selection is NOT supported."); - std::process::exit(1); - }, - Err(PrimarySelectionCheckError::NoSeats) => { - log::error!("could not determine: no seats available."); - std::process::exit(2); - }, - Err(PrimarySelectionCheckError::MissingProtocol) => { - log::error!("data-control protocol not supported by compositor."); - std::process::exit(3); - }, - Err(e) => { - log::error!("error checking primary selection support: {e}"); - std::process::exit(4); - }, - } - } - - let clipboard = if args.primary { - ClipboardType::Primary - } else { - ClipboardType::Regular - }; - - let mime_type = if let Some(mt) = args.mime_type.as_deref() { - if mt == "text" || mt == "text/plain" { - MimeType::Text - } else if mt == "autodetect" { - MimeType::Autodetect - } else { - MimeType::Specific(mt.to_string()) - } - } else { - MimeType::Autodetect - }; - - let mut input: Vec = Vec::new(); - if args.text.is_empty() { - if let Err(e) = std::io::stdin().read_to_end(&mut input) { - eprintln!("failed to read stdin: {e}"); - std::process::exit(1); - } - } else { - input = args.text.join(" ").into_bytes(); - } - - let mut opts = Options::new(); - opts.clipboard(clipboard); - - if args.trim_newline { - opts.trim_newline(true); - } - if args.foreground { - opts.foreground(true); - } - if let Some(seat) = args.seat.as_deref() { - log::debug!( - "'--seat' is not supported by stash (using default seat: {seat})" - ); - } - if args.omit_additional_text_mime_types { - opts.omit_additional_text_mime_types(true); - } - // --paste-once overrides serve-requests - if args.paste_once { - opts.serve_requests(ServeRequests::Only(1)); - } else if let Some(n) = args.serve_requests { - opts.serve_requests(ServeRequests::Only(n)); - } - // --clear - if args.clear { - // Clear clipboard by setting empty contents - if let Err(e) = opts.copy(Source::Bytes(Vec::new().into()), mime_type) { - log::error!("failed to clear clipboard: {e}"); - std::process::exit(1); - } - return; - } - if let Err(e) = opts.copy(Source::Bytes(input.into()), mime_type) { - log::error!("failed to copy to clipboard: {e}"); - std::process::exit(1); - } -} - -fn multicall_stash_paste() { - #[derive(Parser, Debug)] - #[command( - name = "stash-paste", - about = "Paste clipboard contents on Wayland.", - version, - disable_help_subcommand = true - )] - struct Args { - /// List the offered MIME types instead of pasting - #[arg(short = 'l', long = "list-types", action = ArgAction::SetTrue)] - list_types: bool, - /// Use the "primary" clipboard - #[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)] - primary: bool, - /// Do not append a newline character - #[arg(short = 'n', long = "no-newline", action = ArgAction::SetTrue)] - no_newline: bool, - /// Pick the seat to work with - #[arg(short = 's', long = "seat")] - seat: Option, - /// Request the given MIME type instead of inferring the MIME type - #[arg(short = 't', long = "type")] - mime_type: Option, - /// Enable verbose logging - #[arg(short = 'v', long = "verbose", action = ArgAction::Count)] - verbose: u8, - } - - let args = Args::parse(); - - let clipboard = if args.primary { - ClipboardType::Primary - } else { - ClipboardType::Regular - }; - - if let Some(seat) = args.seat.as_deref() { - log::debug!( - "'--seat' is not supported by stash (using default seat: {seat})" - ); - } - - if args.list_types { - match get_contents(clipboard, Seat::Unspecified, MimeType::Text) { - Ok((_reader, available_types)) => { - log::info!("{available_types}"); - std::process::exit(0); - }, - Err(e) => { - log::error!("failed to list types: {e}"); - std::process::exit(1); - }, - } - } - - let mime_type = match args.mime_type.as_deref() { - None | Some("text" | "autodetect") => MimeType::Text, - Some(other) => MimeType::Specific(other), - }; - - match get_contents(clipboard, Seat::Unspecified, mime_type) { - Ok((mut reader, _types)) => { - let mut out = io::stdout(); - let mut buf = Vec::new(); - match reader.read_to_end(&mut buf) { - Ok(n) => { - if n == 0 && args.no_newline { - std::process::exit(1); - } - let _ = out.write_all(&buf); - if !args.no_newline && !buf.ends_with(b"\n") { - let _ = out.write_all(b"\n"); - } - }, - Err(e) => { - log::error!("failed to read clipboard: {e}"); - std::process::exit(1); - }, - } - }, - Err(Error::NoSeats) => { - log::error!("no seats available (is a Wayland compositor running?)"); - std::process::exit(1); - }, - Err(Error::ClipboardEmpty) => { - if args.no_newline { - std::process::exit(1); - } - }, - Err(Error::NoMimeType) => { - log::error!("clipboard does not contain requested MIME type"); - std::process::exit(1); - }, - Err(e) => { - log::error!("clipboard error: {e}"); - std::process::exit(1); - }, - } -} diff --git a/src/multicall/mod.rs b/src/multicall/mod.rs new file mode 100644 index 0000000..5f1c795 --- /dev/null +++ b/src/multicall/mod.rs @@ -0,0 +1,6 @@ +// Reference documentation: +// https://wayland.freedesktop.org/docs/html/apa.html#protocol-spec-wl_data_device +// https://docs.rs/wl-clipboard-rs/latest/wl_clipboard_rs +// https://github.com/YaLTeR/wl-clipboard-rs/blob/master/wl-clipboard-rs-tools/src/bin/wl_copy.rs +pub mod wl_copy; +pub mod wl_paste; diff --git a/src/multicall/wl_copy.rs b/src/multicall/wl_copy.rs new file mode 100644 index 0000000..3794420 --- /dev/null +++ b/src/multicall/wl_copy.rs @@ -0,0 +1,296 @@ +use std::io::{self, Read}; + +use clap::{ArgAction, Parser}; +use color_eyre::eyre::{Context, Result, bail}; +use wl_clipboard_rs::{ + copy::{ + ClipboardType as CopyClipboardType, + MimeType as CopyMimeType, + Options, + Seat as CopySeat, + ServeRequests, + Source, + }, + utils::{PrimarySelectionCheckError, is_primary_selection_supported}, +}; + +// Maximum clipboard content size to prevent memory exhaustion (100MB) +const MAX_CLIPBOARD_SIZE: usize = 100 * 1024 * 1024; + +#[derive(Parser, Debug)] +#[command( + name = "wl-copy", + about = "Copy clipboard contents on Wayland.", + version +)] +#[allow(clippy::struct_excessive_bools)] +struct WlCopyArgs { + /// Serve only a single paste request and then exit + #[arg(short = 'o', long = "paste-once", action = ArgAction::SetTrue)] + paste_once: bool, + + /// Stay in the foreground instead of forking + #[arg(short = 'f', long = "foreground", action = ArgAction::SetTrue)] + foreground: bool, + + /// Clear the clipboard instead of copying + #[arg(short = 'c', long = "clear", action = ArgAction::SetTrue)] + clear: bool, + + /// Use the "primary" clipboard + #[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)] + primary: bool, + + /// Use the regular clipboard + #[arg(short = 'r', long = "regular", action = ArgAction::SetTrue)] + regular: bool, + + /// Trim the trailing newline character before copying + #[arg(short = 'n', long = "trim-newline", action = ArgAction::SetTrue)] + trim_newline: bool, + + /// Pick the seat to work with + #[arg(short = 's', long = "seat")] + seat: Option, + + /// Override the inferred MIME type for the content + #[arg(short = 't', long = "type")] + mime_type: Option, + + /// Enable verbose logging + #[arg(short = 'v', long = "verbose", action = ArgAction::Count)] + verbose: u8, + + /// Check if primary selection is supported and exit + #[arg(long = "check-primary", action = ArgAction::SetTrue)] + check_primary: bool, + + /// Do not offer additional text mime types (stash extension) + #[arg(long = "omit-additional-text-mime-types", action = ArgAction::SetTrue, hide = true)] + omit_additional_text_mime_types: bool, + + /// Number of paste requests to serve before exiting (stash extension) + #[arg(short = 'x', long = "serve-requests", hide = true)] + serve_requests: Option, + + /// Text to copy (if not given, read from stdin) + #[arg(value_name = "TEXT TO COPY", action = ArgAction::Append)] + text: Vec, +} + +fn handle_check_primary() { + let exit_code = match is_primary_selection_supported() { + Ok(true) => { + log::info!("primary selection is supported."); + 0 + }, + Ok(false) => { + log::info!("primary selection is NOT supported."); + 1 + }, + Err(PrimarySelectionCheckError::NoSeats) => { + log::error!("could not determine: no seats available."); + 2 + }, + Err(PrimarySelectionCheckError::MissingProtocol) => { + log::error!("data-control protocol not supported by compositor."); + 3 + }, + Err(e) => { + log::error!("error checking primary selection support: {e}"); + 4 + }, + }; + + // Exit with the relevant code + std::process::exit(exit_code); +} + +const fn get_clipboard_type(primary: bool) -> CopyClipboardType { + if primary { + CopyClipboardType::Primary + } else { + CopyClipboardType::Regular + } +} + +fn get_mime_type(mime_arg: Option<&str>) -> CopyMimeType { + match mime_arg { + Some("text" | "text/plain") => CopyMimeType::Text, + Some("autodetect") | None => CopyMimeType::Autodetect, + Some(specific) => CopyMimeType::Specific(specific.to_string()), + } +} + +fn read_input_data(text_args: &[String]) -> Result> { + if text_args.is_empty() { + let mut buffer = Vec::new(); + let mut stdin = io::stdin(); + + // Read with size limit to prevent memory exhaustion + let mut temp_buffer = [0; 8192]; + loop { + let bytes_read = stdin + .read(&mut temp_buffer) + .context("failed to read from stdin")?; + + if bytes_read == 0 { + break; + } + + if buffer.len() + bytes_read > MAX_CLIPBOARD_SIZE { + bail!( + "input exceeds maximum clipboard size of {} bytes", + MAX_CLIPBOARD_SIZE + ); + } + + buffer.extend_from_slice(&temp_buffer[..bytes_read]); + } + + Ok(buffer) + } else { + let content = text_args.join(" "); + if content.len() > MAX_CLIPBOARD_SIZE { + bail!( + "input exceeds maximum clipboard size of {} bytes", + MAX_CLIPBOARD_SIZE + ); + } + Ok(content.into_bytes()) + } +} + +fn configure_copy_options( + args: &WlCopyArgs, + clipboard: CopyClipboardType, +) -> Options { + let mut opts = Options::new(); + opts.clipboard(clipboard); + opts.seat( + args + .seat + .as_deref() + .map_or(CopySeat::All, |s| CopySeat::Specific(s.to_string())), + ); + + if args.trim_newline { + opts.trim_newline(true); + } + + if args.omit_additional_text_mime_types { + opts.omit_additional_text_mime_types(true); + } + + if args.paste_once { + opts.serve_requests(ServeRequests::Only(1)); + } else if let Some(n) = args.serve_requests { + opts.serve_requests(ServeRequests::Only(n)); + } + + opts +} + +fn handle_clear_clipboard( + args: &WlCopyArgs, + clipboard: CopyClipboardType, + mime_type: CopyMimeType, +) -> Result<()> { + let mut opts = Options::new(); + opts.clipboard(clipboard); + opts.seat( + args + .seat + .as_deref() + .map_or(CopySeat::All, |s| CopySeat::Specific(s.to_string())), + ); + + opts + .copy(Source::Bytes(Vec::new().into()), mime_type) + .context("failed to clear clipboard")?; + + Ok(()) +} + +fn fork_and_serve(prepared_copy: wl_clipboard_rs::copy::PreparedCopy) { + // Use proper Unix fork() to create a child process that continues + // serving clipboard content after parent exits. + // XXX: I wanted to choose and approach without fork, but we could not + // ensure persistence after the thread dies. Alas, we gotta fork. + unsafe { + match libc::fork() { + 0 => { + // Child process - serve clipboard content + if let Err(e) = prepared_copy.serve() { + log::error!("background clipboard service failed: {e}"); + std::process::exit(1); + } + std::process::exit(0); + }, + -1 => { + // Fork failed + log::error!("failed to fork background process"); + std::process::exit(1); + }, + _ => { + // Parent process - exit immediately + log::debug!("forked background process to serve clipboard content"); + std::process::exit(0); + }, + } + } +} + +pub fn wl_copy_main() -> Result<()> { + let args = WlCopyArgs::parse(); + + if args.check_primary { + handle_check_primary(); + } + + let clipboard = get_clipboard_type(args.primary); + let mime_type = get_mime_type(args.mime_type.as_deref()); + + // Handle clear operation + if args.clear { + handle_clear_clipboard(&args, clipboard, mime_type)?; + return Ok(()); + } + + // Read input data + let input = + read_input_data(&args.text).context("failed to read input data")?; + + // Configure copy options + let opts = configure_copy_options(&args, clipboard); + + // Handle foreground vs background mode + if args.foreground { + // Foreground mode: copy content and serve in current process + // Use prepare_copy + serve to ensure proper clipboard registration + let mut opts_fg = opts; + opts_fg.foreground(true); + + let prepared_copy = opts_fg + .prepare_copy(Source::Bytes(input.into()), mime_type) + .context("failed to prepare copy")?; + + // Serve in foreground - blocks until interrupted (Ctrl+C, etc.) + prepared_copy + .serve() + .context("failed to serve clipboard content")?; + } else { + // Background mode: spawn child process to serve requests + // First prepare to copy to validate before spawning + let mut opts_fg = opts; + opts_fg.foreground(true); + + let prepared_copy = opts_fg + .prepare_copy(Source::Bytes(input.into()), mime_type) + .context("failed to prepare copy")?; + + fork_and_serve(prepared_copy); + } + + Ok(()) +} diff --git a/src/multicall/wl_paste.rs b/src/multicall/wl_paste.rs new file mode 100644 index 0000000..5daa1fd --- /dev/null +++ b/src/multicall/wl_paste.rs @@ -0,0 +1,531 @@ +// https://wayland.freedesktop.org/docs/html/apa.html#protocol-spec-wl_data_device +// https://docs.rs/wl-clipboard-rs/latest/wl_clipboard_rs +// https://github.com/YaLTeR/wl-clipboard-rs/blob/master/wl-clipboard-rs-tools/src/bin/wl_paste.rs +use std::{ + collections::hash_map::DefaultHasher, + hash::{Hash, Hasher}, + io::{self, Read, Write}, + process::{Command, Stdio}, + sync::{Arc, Mutex}, + thread, + time::{Duration, Instant}, +}; + +use clap::{ArgAction, Parser}; +use color_eyre::eyre::{Context, Result, bail}; +use wl_clipboard_rs::paste::{ + ClipboardType as PasteClipboardType, + Error as PasteError, + MimeType as PasteMimeType, + Seat as PasteSeat, + get_contents, + get_mime_types, +}; + +// Watch mode timing constants +const WATCH_POLL_INTERVAL_MS: u64 = 500; +const WATCH_DEBOUNCE_INTERVAL_MS: u64 = 1000; + +// Maximum clipboard content size to prevent memory exhaustion (100MB) +const MAX_CLIPBOARD_SIZE: usize = 100 * 1024 * 1024; + +#[derive(Parser, Debug)] +#[command( + name = "wl-paste", + about = "Paste clipboard contents on Wayland.", + version, + disable_help_subcommand = true +)] +struct WlPasteArgs { + /// List the offered MIME types instead of pasting + #[arg(short = 'l', long = "list-types", action = ArgAction::SetTrue)] + list_types: bool, + + /// Use the "primary" clipboard + #[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)] + primary: bool, + + /// Do not append a newline character + #[arg(short = 'n', long = "no-newline", action = ArgAction::SetTrue)] + no_newline: bool, + + /// Pick the seat to work with + #[arg(short = 's', long = "seat")] + seat: Option, + + /// Request the given MIME type instead of inferring the MIME type + #[arg(short = 't', long = "type")] + mime_type: Option, + + /// Enable verbose logging + #[arg(short = 'v', long = "verbose", action = ArgAction::Count)] + verbose: u8, + + /// Watch for clipboard changes and run a command + #[arg(short = 'w', long = "watch")] + watch: Option>, +} + +fn get_paste_mime_type(mime_arg: Option<&str>) -> PasteMimeType<'_> { + match mime_arg { + None | Some("text" | "autodetect") => PasteMimeType::Text, + Some(other) => PasteMimeType::Specific(other), + } +} + +fn handle_list_types( + clipboard: PasteClipboardType, + seat: PasteSeat, +) -> Result<()> { + match get_mime_types(clipboard, seat) { + Ok(types) => { + for mime_type in types { + println!("{mime_type}"); + } + + #[allow(clippy::needless_return)] + return Ok(()); + }, + Err(PasteError::NoSeats) => { + bail!("no seats available (is a Wayland compositor running?)"); + }, + Err(e) => { + bail!("failed to list types: {e}"); + }, + } +} + +fn handle_watch_mode( + args: &WlPasteArgs, + clipboard: PasteClipboardType, + seat: PasteSeat, +) -> Result<()> { + let watch_args = args.watch.as_ref().unwrap(); + if watch_args.is_empty() { + bail!("--watch requires a command to run"); + } + + log::info!("starting clipboard watch mode"); + + // Shared state for tracking last content and shutdown signal + let last_content_hash = Arc::new(Mutex::new(None::)); + let shutdown = Arc::new(Mutex::new(false)); + + // Set up signal handler for graceful shutdown + let shutdown_clone = shutdown.clone(); + ctrlc::set_handler(move || { + log::info!("received shutdown signal, stopping watch mode"); + if let Ok(mut shutdown_guard) = shutdown_clone.lock() { + *shutdown_guard = true; + } else { + log::error!("failed to acquire shutdown lock in signal handler"); + } + }) + .context("failed to set signal handler")?; + + let poll_interval = Duration::from_millis(WATCH_POLL_INTERVAL_MS); + let debounce_interval = Duration::from_millis(WATCH_DEBOUNCE_INTERVAL_MS); + let mut last_change_time = Instant::now(); + + loop { + // Check for shutdown signal + match shutdown.lock() { + Ok(shutdown_guard) => { + if *shutdown_guard { + log::info!("shutting down watch mode"); + break Ok(()); + } + }, + Err(e) => { + log::error!("failed to acquire shutdown lock: {e}"); + thread::sleep(poll_interval); + continue; + }, + } + + // Get current clipboard content + let current_hash = match get_clipboard_content_hash(clipboard, seat) { + Ok(hash) => hash, + Err(e) => { + log::error!("failed to get clipboard content hash: {e}"); + thread::sleep(poll_interval); + continue; + }, + }; + + // Check if content has changed + match last_content_hash.lock() { + Ok(mut last_hash_guard) => { + let changed = *last_hash_guard != Some(current_hash); + if changed { + let now = Instant::now(); + + // Debounce rapid changes + if now.duration_since(last_change_time) >= debounce_interval { + *last_hash_guard = Some(current_hash); + last_change_time = now; + drop(last_hash_guard); // Release lock before spawning command + + log::info!("clipboard content changed, executing watch command"); + + // Execute the watch command + if let Err(e) = execute_watch_command(watch_args, clipboard, seat) { + log::error!("failed to execute watch command: {e}"); + // Continue watching even if command fails + } + } + } + changed + }, + Err(e) => { + log::error!("failed to acquire last_content_hash lock: {e}"); + thread::sleep(poll_interval); + continue; + }, + }; + + thread::sleep(poll_interval); + } +} + +fn get_clipboard_content_hash( + clipboard: PasteClipboardType, + seat: PasteSeat, +) -> Result { + match get_contents(clipboard, seat, PasteMimeType::Text) { + Ok((mut reader, _types)) => { + let mut content = Vec::new(); + let mut temp_buffer = [0; 8192]; + + loop { + let bytes_read = reader + .read(&mut temp_buffer) + .context("failed to read clipboard content")?; + + if bytes_read == 0 { + break; + } + + if content.len() + bytes_read > MAX_CLIPBOARD_SIZE { + bail!( + "clipboard content exceeds maximum size of {} bytes", + MAX_CLIPBOARD_SIZE + ); + } + + content.extend_from_slice(&temp_buffer[..bytes_read]); + } + + let mut hasher = DefaultHasher::new(); + content.hash(&mut hasher); + Ok(hasher.finish()) + }, + Err(PasteError::ClipboardEmpty) => { + Ok(0) // Empty clipboard has hash 0 + }, + Err(e) => bail!("clipboard error: {e}"), + } +} + +/// Validate command name to prevent command injection +fn validate_command_name(cmd: &str) -> Result<()> { + if cmd.is_empty() { + bail!("command name cannot be empty"); + } + + // Reject commands with shell metacharacters or path traversal + if cmd.contains(|c| { + ['|', '&', ';', '$', '`', '(', ')', '<', '>', '"', '\'', '\\'].contains(&c) + }) { + bail!("command contains invalid characters: {cmd}"); + } + + // Reject absolute paths and relative path traversal + if cmd.starts_with('/') || cmd.contains("..") { + bail!("command paths are not allowed: {cmd}"); + } + + Ok(()) +} + +/// Set environment variable safely with validation +fn set_clipboard_state_env(has_content: bool) -> Result<()> { + let value = if has_content { "data" } else { "nil" }; + + // Validate the environment variable value + if !matches!(value, "data" | "nil") { + bail!("invalid clipboard state value: {value}"); + } + + // Safe to set environment variable with validated, known-safe value + unsafe { + std::env::set_var("STASH_CLIPBOARD_STATE", value); + } + Ok(()) +} + +fn execute_watch_command( + watch_args: &[String], + clipboard: PasteClipboardType, + seat: PasteSeat, +) -> Result<()> { + if watch_args.is_empty() { + bail!("watch command cannot be empty"); + } + + // Validate command name for security + validate_command_name(&watch_args[0])?; + + let mut cmd = Command::new(&watch_args[0]); + if watch_args.len() > 1 { + cmd.args(&watch_args[1..]); + } + + // Get clipboard content and pipe it to the command + match get_contents(clipboard, seat, PasteMimeType::Text) { + Ok((mut reader, _types)) => { + let mut content = Vec::new(); + let mut temp_buffer = [0; 8192]; + + loop { + let bytes_read = reader + .read(&mut temp_buffer) + .context("failed to read clipboard")?; + + if bytes_read == 0 { + break; + } + + if content.len() + bytes_read > MAX_CLIPBOARD_SIZE { + bail!( + "clipboard content exceeds maximum size of {} bytes", + MAX_CLIPBOARD_SIZE + ); + } + + content.extend_from_slice(&temp_buffer[..bytes_read]); + } + + // Set environment variable safely + set_clipboard_state_env(!content.is_empty())?; + + // Spawn the command with the content as stdin + cmd.stdin(Stdio::piped()); + + let mut child = cmd.spawn()?; + + if let Some(stdin) = child.stdin.take() { + let mut stdin = stdin; + if let Err(e) = stdin.write_all(&content) { + bail!("failed to write to command stdin: {e}"); + } + } + + match child.wait() { + Ok(status) => { + if !status.success() { + log::warn!("watch command exited with status: {status}"); + } + }, + Err(e) => { + bail!("failed to wait for command: {e}"); + }, + } + }, + Err(PasteError::ClipboardEmpty) => { + // Set environment variable safely + set_clipboard_state_env(false)?; + + // Run command with /dev/null as stdin + cmd.stdin(Stdio::null()); + + match cmd.status() { + Ok(status) => { + if !status.success() { + log::warn!("watch command exited with status: {status}"); + } + }, + Err(e) => { + bail!("failed to run command: {e}"); + }, + } + }, + Err(e) => { + bail!("clipboard error: {e}"); + }, + } + + Ok(()) +} + +/// Select the best MIME type from available types when none is specified. +/// Prefers specific content types (image/*, application/*) over generic +/// text representations (TEXT, STRING, `UTF8_STRING`). +fn select_best_mime_type( + types: &std::collections::HashSet, +) -> Option { + if types.is_empty() { + return None; + } + + // If only one type available, use it + if types.len() == 1 { + return types.iter().next().cloned(); + } + + // Prefer specific MIME types with slashes (e.g., image/png, application/pdf) + // over generic X11 selections (TEXT, STRING, UTF8_STRING) + let specific_types: Vec<_> = + types.iter().filter(|t| t.contains('/')).collect(); + + if !specific_types.is_empty() { + // Among specific types, prefer non-text types first + for mime in &specific_types { + if !mime.starts_with("text/") { + return Some((*mime).clone()); + } + } + // If all are text types, prefer text/plain with charset + for mime in &specific_types { + if mime.starts_with("text/plain;charset=") { + return Some((*mime).clone()); + } + } + // Otherwise return first specific type + return Some(specific_types[0].clone()); + } + + // Fall back to generic text selections in order of preference + for fallback in &["UTF8_STRING", "STRING", "TEXT"] { + if types.contains(*fallback) { + return Some((*fallback).to_string()); + } + } + + // Last resort: return any available type + types.iter().next().cloned() +} + +fn handle_regular_paste( + args: &WlPasteArgs, + clipboard: PasteClipboardType, + seat: PasteSeat, +) -> Result<()> { + // If no MIME type specified, select the best available MIME type + let available_types = if args.mime_type.is_none() { + get_mime_types(clipboard, seat).ok() + } else { + None + }; + + let selected_type = available_types.as_ref().and_then(select_best_mime_type); + + let mime_type = if let Some(ref best) = selected_type { + log::debug!("auto-selecting MIME type: {best}"); + PasteMimeType::Specific(best) + } else { + get_paste_mime_type(args.mime_type.as_deref()) + }; + + match get_contents(clipboard, seat, mime_type) { + Ok((mut reader, types)) => { + let mut out = io::stdout(); + let mut buf = Vec::new(); + let mut temp_buffer = [0; 8192]; + + loop { + let bytes_read = reader + .read(&mut temp_buffer) + .context("failed to read clipboard")?; + + if bytes_read == 0 { + break; + } + + if buf.len() + bytes_read > MAX_CLIPBOARD_SIZE { + bail!( + "clipboard content exceeds maximum size of {} bytes", + MAX_CLIPBOARD_SIZE + ); + } + + buf.extend_from_slice(&temp_buffer[..bytes_read]); + } + + if buf.is_empty() && args.no_newline { + bail!("no content available and --no-newline specified"); + } + if let Err(e) = out.write_all(&buf) { + bail!("failed to write to stdout: {e}"); + } + + // Only add newline for text content, not binary data + // Check if the MIME type indicates text content + let is_text_content = if types.is_empty() { + // If no MIME type, check if content is valid UTF-8 + std::str::from_utf8(&buf).is_ok() + } else { + types.starts_with("text/") + || types == "application/json" + || types == "application/xml" + || types == "application/x-sh" + }; + + if !args.no_newline + && is_text_content + && !buf.ends_with(b"\n") + && let Err(e) = out.write_all(b"\n") + { + bail!("failed to write newline to stdout: {e}"); + } + }, + Err(PasteError::NoSeats) => { + bail!("no seats available (is a Wayland compositor running?)"); + }, + Err(PasteError::ClipboardEmpty) => { + if args.no_newline { + bail!("clipboard empty and --no-newline specified"); + } + // Otherwise, exit successfully with no output + }, + Err(PasteError::NoMimeType) => { + bail!("clipboard does not contain requested MIME type"); + }, + Err(e) => { + bail!("clipboard error: {e}"); + }, + } + + Ok(()) +} + +pub fn wl_paste_main() -> Result<()> { + let args = WlPasteArgs::parse(); + + let clipboard = if args.primary { + PasteClipboardType::Primary + } else { + PasteClipboardType::Regular + }; + let seat = args + .seat + .as_deref() + .map_or(PasteSeat::Unspecified, PasteSeat::Specific); + + // Handle list-types option + if args.list_types { + handle_list_types(clipboard, seat)?; + return Ok(()); + } + + // Handle watch mode + if args.watch.is_some() { + handle_watch_mode(&args, clipboard, seat)?; + return Ok(()); + } + + // Regular paste mode + handle_regular_paste(&args, clipboard, seat)?; + + Ok(()) +} diff --git a/src/wayland/mod.rs b/src/wayland/mod.rs index 016d609..38f6ff5 100644 --- a/src/wayland/mod.rs +++ b/src/wayland/mod.rs @@ -1,8 +1,9 @@ use std::{ collections::HashMap, - sync::{LazyLock, Mutex}, + sync::{Arc, LazyLock, Mutex}, }; +use arc_swap::ArcSwapOption; use log::debug; use wayland_client::{ Connection as WaylandConnection, @@ -17,7 +18,7 @@ use wayland_protocols_wlr::foreign_toplevel::v1::client::{ zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1}, }; -static FOCUSED_APP: Mutex> = Mutex::new(None); +static FOCUSED_APP: ArcSwapOption = ArcSwapOption::const_empty(); static TOPLEVEL_APPS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); @@ -32,12 +33,11 @@ pub fn init_wayland_state() { /// Get the currently focused window application name using Wayland protocols pub fn get_focused_window_app() -> Option { - // Try Wayland protocol first - if let Ok(focused) = FOCUSED_APP.lock() { - if let Some(ref app) = *focused { - debug!("Found focused app via Wayland protocol: {app}"); - return Some(app.clone()); - } + // Load the focused app using lock-free arc-swap + let focused = FOCUSED_APP.load(); + if let Some(app) = focused.as_ref() { + debug!("Found focused app via Wayland protocol: {app}"); + return Some(app.to_string()); } debug!("No focused window detection method worked"); @@ -81,11 +81,10 @@ impl Dispatch for AppState { interface, version: _, } = event + && interface == "zwlr_foreign_toplevel_manager_v1" { - if interface == "zwlr_foreign_toplevel_manager_v1" { - let _manager: ZwlrForeignToplevelManagerV1 = - registry.bind(name, 1, qh, ()); - } + let _manager: ZwlrForeignToplevelManagerV1 = + registry.bind(name, 1, qh, ()); } } @@ -153,13 +152,11 @@ impl Dispatch for AppState { }) { debug!("Toplevel activated"); // Update focused app to the `app_id` of this handle - if let (Ok(apps), Ok(mut focused)) = - (TOPLEVEL_APPS.lock(), FOCUSED_APP.lock()) + if let Ok(apps) = TOPLEVEL_APPS.lock() + && let Some(app_id) = apps.get(&handle_id) { - if let Some(app_id) = apps.get(&handle_id) { - debug!("Setting focused app to: {app_id}"); - *focused = Some(app_id.clone()); - } + debug!("Setting focused app to: {app_id}"); + FOCUSED_APP.store(Some(Arc::new(app_id.clone()))); } } },