diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 4bbfe7c..aa30540 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -1,23 +1,13 @@ version: 2 updates: + # Update Cargo deps + - package-ecosystem: cargo + directory: "/" + schedule: + interval: "weekly" + # Update used workflows - package-ecosystem: github-actions directory: "/" schedule: interval: daily - - # Update Cargo deps - - package-ecosystem: cargo - directory: "/" - cooldown: - default-days: 7 - schedule: - interval: "weekly" - - # Update Nixpkgs & Crane - - package-ecosystem: nix - directory: "/" - cooldown: - default-days: 7 - schedule: - interval: daily diff --git a/.github/workflows/nix-cache.yaml b/.github/workflows/nix-cache.yaml index 8936c67..7ece038 100644 --- a/.github/workflows/nix-cache.yaml +++ b/.github/workflows/nix-cache.yaml @@ -1,10 +1,10 @@ -name: Build and Cache with Nix +name: "Populate cachix cache" on: workflow_dispatch: push: branches: [ "main" ] - paths: [ 'src/**.rs', 'build.rs', 'Cargo.toml', 'Cargo.lock', 'nix/package.nix', 'flake.nix', 'flake.lock' ] + paths: [ 'src/**.rs', 'Cargo.toml', 'Cargo.lock', 'nix/package.nix' ] permissions: contents: read @@ -13,17 +13,16 @@ jobs: populate-cache: runs-on: ubuntu-latest steps: - - name: "Checkout" - uses: actions/checkout@v6 + - name: "CHeckout" + uses: actions/checkout@v5 - uses: cachix/install-nix-action@v31 with: nix_path: nixpkgs=channel:nixos-unstable - - uses: cachix/cachix-action@v17 + - uses: cachix/cachix-action@v16 with: name: nyx authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' - - name: "Build with Nix" - run: nix build + - run: nix build diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 62cfe82..4842934 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -9,30 +9,7 @@ permissions: contents: write jobs: - tag-release: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - uses: cachix/install-nix-action@v31 - with: - nix_path: nixpkgs=channel:nixos-unstable - - - name: Read version - run: | - echo -n "stash_version=v" >> "$GITHUB_ENV" - nix run nixpkgs#fq -- -r '.package.version' Cargo.toml >> "$GITHUB_ENV" - cat "$GITHUB_ENV" - - - name: Tag - run: | - set -x - git tag $ndg_version - git push --tags || : - create-release: - needs: tag-release runs-on: ubuntu-latest outputs: upload_url: ${{ steps.create_release.outputs.upload_url }} @@ -40,7 +17,7 @@ jobs: steps: - name: Create Release id: create_release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@v2 with: draft: false prerelease: false @@ -62,7 +39,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v5 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -98,7 +75,7 @@ jobs: cp target/${{ matrix.target }}/release/stash ${{ matrix.name }} - name: Upload Release Asset - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@v2 with: files: ${{ matrix.name }} @@ -106,7 +83,7 @@ jobs: needs: [create-release, build-release] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v5 - name: Download Assets uses: robinraju/release-downloader@v1 @@ -120,7 +97,7 @@ jobs: sha256sum stash-* > SHA256SUMS - name: Upload Checksums - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@v2 with: token: ${{ secrets.GITHUB_TOKEN }} files: SHA256SUMS diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 0ae86c8..360ef75 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@v6 + uses: actions/checkout@v5 - name: Build run: cargo build --verbose diff --git a/.gitignore b/.gitignore index 99b71d1..c5ee468 100644 --- a/.gitignore +++ b/.gitignore @@ -1,34 +1,3 @@ -# Ignore everything by default -/* -!/ - -!/nix -!/src -!/contrib - -# Rust/Cargo -!/Cargo.lock -!/Cargo.toml -!/build.rs - -# Configuration files -!/.config/ -!/.rustfmt.toml -!/.clippy.toml -!/.taplo.toml -!/.gitattributes -!/.gitignore -!/.github -!/.editorconfig - -# Nix -!/flake/**/*.nix -!/flake.nix -!/flake.lock -!/shell.nix -!/default.nix -!/.envrc - -# Misc -!/README.md -!/LICENSE +target/ +.direnv/ +result/ diff --git a/.rustfmt.toml b/.rustfmt.toml index 9d5c77e..cb120a3 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,3 +24,4 @@ unstable_features = true use_field_init_shorthand = true use_try_shorthand = true wrap_comments = true + diff --git a/.taplo.toml b/.taplo.toml deleted file mode 100644 index fae0c57..0000000 --- a/.taplo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[formatting] -align_entries = true -column_width = 110 -compact_arrays = false -reorder_inline_tables = false -reorder_keys = true - -[[rule]] -include = [ "**/Cargo.toml" ] -keys = [ "package" ] - -[rule.formatting] -reorder_keys = false diff --git a/Cargo.lock b/Cargo.lock index 113cb91..51125f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,26 +2,11 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] @@ -34,9 +19,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "anstream" -version = "1.0.0" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" dependencies = [ "anstyle", "anstyle-parse", @@ -49,64 +34,37 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.14" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" [[package]] name = "anstyle-parse" -version = "1.0.0" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.5" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys", + "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.11" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "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]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", + "windows-sys 0.60.2", ] [[package]] @@ -123,9 +81,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.14.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +checksum = "bb812ffb58524bdd10860d7d974e2f01cc0950c2438a74ee5ec2e2280c6c4ffa" dependencies = [ "async-task", "concurrent-queue", @@ -137,9 +95,9 @@ dependencies = [ [[package]] name = "async-fs" -version = "2.2.0" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +checksum = "09f7e37c0ed80b2a977691c47dae8625cfb21e205827106c64f7c588766b2e50" dependencies = [ "async-lock", "blocking", @@ -148,27 +106,27 @@ dependencies = [ [[package]] name = "async-io" -version = "2.6.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +checksum = "19634d6336019ef220f09fd31168ce5c184b295cbf80345437cc36094ef223ca" dependencies = [ - "autocfg", + "async-lock", "cfg-if", "concurrent-queue", "futures-io", "futures-lite", "parking", "polling", - "rustix", + "rustix 1.0.8", "slab", - "windows-sys", + "windows-sys 0.60.2", ] [[package]] name = "async-lock" -version = "3.4.2" +version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" dependencies = [ "event-listener", "event-listener-strategy", @@ -188,9 +146,9 @@ dependencies = [ [[package]] name = "async-process" -version = "2.5.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +checksum = "65daa13722ad51e6ab1a1b9c01299142bc75135b337923cfa10e79bbbd669f00" dependencies = [ "async-channel", "async-io", @@ -201,25 +159,14 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "rustix 1.0.8", ] [[package]] name = "async-signal" -version = "0.2.14" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +checksum = "f567af260ef69e1d52c2b560ce0ea230763e6fbb9214a85d768760a920e3e3c1" dependencies = [ "async-io", "async-lock", @@ -227,10 +174,10 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 1.0.8", "signal-hook-registry", "slab", - "windows-sys", + "windows-sys 0.60.2", ] [[package]] @@ -239,26 +186,6 @@ version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 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]] name = "atomic-waker" version = "1.1.2" @@ -271,42 +198,12 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -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" @@ -315,27 +212,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.9.1" 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" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" [[package]] name = "blocking" @@ -351,16 +230,16 @@ dependencies = [ ] [[package]] -name = "bumpalo" -version = "3.20.2" +name = "byteorder" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] -name = "bytemuck" -version = "1.25.0" +name = "cassowary" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" [[package]] name = "castaway" @@ -373,31 +252,24 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "2352e5597e9c544d5e6d9c95190d5d27738ade584fa8db0a16e130e5c2b5296e" dependencies = [ - "find-msvc-tools", "shlex", ] [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" [[package]] name = "clap" -version = "4.6.0" +version = "4.5.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1fc0e74a703892159f5ae7d3aac52c8e6c392f5ae5f359c70b5881d60aaac318" dependencies = [ "clap_builder", "clap_derive", @@ -415,9 +287,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.5.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8" dependencies = [ "anstream", "anstyle", @@ -427,60 +299,33 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.5.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -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", -] +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" [[package]] name = "colorchoice" -version = "1.0.5" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "compact_str" -version = "0.9.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" dependencies = [ "castaway", "cfg-if", @@ -501,41 +346,64 @@ dependencies = [ [[package]] name = "convert_case" -version = "0.10.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" 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.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.9.1", + "crossterm_winapi", + "mio 1.0.4", + "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 2.11.0", + "bitflags 2.9.1", "crossterm_winapi", "derive_more", "document-features", - "mio", + "mio 1.0.4", "parking_lot", - "rustix", + "rustix 1.0.8", "signal-hook", "signal-hook-mio", "winapi", @@ -550,42 +418,11 @@ dependencies = [ "winapi", ] -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -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" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ "darling_core", "darling_macro", @@ -593,73 +430,48 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ + "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.117", -] - -[[package]] -name = "deltae" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", + "syn", ] [[package]] name = "derive_more" -version = "2.1.1" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.1.1" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ "convert_case", "proc-macro2", "quote", - "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", + "syn", ] [[package]] @@ -680,37 +492,14 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys", -] - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.11.0", - "block2", - "libc", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "windows-sys 0.60.2", ] [[package]] name = "document-features" -version = "0.2.12" +version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" dependencies = [ "litrs", ] @@ -733,38 +522,11 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "env_filter" -version = "1.0.1" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" dependencies = [ "log", "regex", @@ -772,9 +534,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.10" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" dependencies = [ "anstream", "anstyle", @@ -791,21 +553,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.14" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys", -] - -[[package]] -name = "euclid" -version = "0.22.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" -dependencies = [ - "num-traits", + "windows-sys 0.60.2", ] [[package]] @@ -829,16 +582,6 @@ 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" @@ -851,44 +594,11 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" -[[package]] -name = "fancy-regex" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" -dependencies = [ - "bit-set", - "regex", -] - [[package]] name = "fastrand" -version = "2.4.1" +version = "2.3.0" 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.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "finl_unicode" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fixedbitset" @@ -896,12 +606,6 @@ 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" @@ -914,68 +618,17 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-lite" @@ -991,130 +644,55 @@ dependencies = [ ] [[package]] -name = "futures-macro" -version = "0.3.32" +name = "fxhash" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", + "byteorder", ] [[package]] name = "getrandom" -version = "0.2.17" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", ] [[package]] name = "getrandom" -version = "0.3.4" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "libc", - "r-efi 5.3.0", - "wasip2", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", ] -[[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 = [ - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] -[[package]] -name = "hashbrown" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" - [[package]] name = "hashlink" -version = "0.11.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.16.1", + "hashbrown", ] [[package]] @@ -1129,296 +707,129 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" -[[package]] -name = "indenter" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" - [[package]] name = "indexmap" -version = "2.14.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" dependencies = [ "equivalent", - "hashbrown 0.17.0", - "serde", - "serde_core", + "hashbrown", ] [[package]] name = "indoc" -version = "2.0.7" +version = "2.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] +checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" [[package]] name = "inquire" -version = "0.9.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" +checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" dependencies = [ - "bitflags 2.11.0", - "crossterm", + "bitflags 2.9.1", + "crossterm 0.25.0", "dyn-clone", + "fxhash", + "newline-converter", + "once_cell", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] name = "instability" -version = "0.3.12" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" dependencies = [ "darling", "indoc", "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] name = "is_terminal_polyfill" -version = "1.70.2" +version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "itertools" -version = "0.14.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] [[package]] name = "itoa" -version = "1.0.18" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jiff" -version = "0.2.23" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" dependencies = [ "jiff-static", "log", "portable-atomic", "portable-atomic-util", - "serde_core", + "serde", ] [[package]] name = "jiff-static" -version = "0.2.23" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] -[[package]] -name = "js-sys" -version = "0.3.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -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.185" +version = "0.2.175" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" dependencies = [ + "bitflags 2.9.1", "libc", ] [[package]] name = "libsqlite3-sys" -version = "0.37.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" dependencies = [ "cc", "pkg-config", @@ -1426,114 +837,53 @@ dependencies = [ ] [[package]] -name = "line-clipping" -version = "0.3.7" +name = "linux-raw-sys" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" -dependencies = [ - "bitflags 2.11.0", -] +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.12.1" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" [[package]] name = "litrs" -version = "1.0.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" [[package]] name = "lock_api" -version = "0.4.14" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" dependencies = [ + "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.29" +version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" [[package]] name = "lru" -version = "0.16.3" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown 0.16.1", -] - -[[package]] -name = "mac-notification-sys" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29a16783dd1a47849b8c8133c9cd3eb2112cfbc6901670af3dba47c8bbfb07d3" -dependencies = [ - "cc", - "objc2", - "objc2-foundation", - "time", -] - -[[package]] -name = "mac_address" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" -dependencies = [ - "nix 0.29.0", - "winapi", + "hashbrown", ] [[package]] name = "memchr" -version = "2.8.0" +version = "2.7.5" 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" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -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", -] +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" [[package]] name = "minimal-lexical" @@ -1542,49 +892,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] -name = "miniz_oxide" -version = "0.8.9" +name = "mio" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ - "adler2", + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.48.0", ] [[package]] name = "mio" -version = "1.2.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", "log", - "wasi", - "windows-sys", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", ] [[package]] -name = "nix" -version = "0.29.0" +name = "newline-converter" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" dependencies = [ - "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", + "unicode-segmentation", ] [[package]] @@ -1597,123 +934,17 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "notify-rust" -version = "4.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b2c9bc1689653cfbc04400b8719f2562638ff9c545bbd48cc58c657a14526df" -dependencies = [ - "futures-lite", - "log", - "mac-notification-sys", - "serde", - "tauri-winrt-notification", - "zbus", -] - -[[package]] -name = "num-conv" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.11.0", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.11.0", - "block2", - "libc", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" -version = "1.21.4" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" -version = "1.70.2" +version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" [[package]] name = "option-ext" @@ -1721,41 +952,16 @@ 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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - [[package]] name = "os_pipe" -version = "1.2.3" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +checksum = "db335f4760b14ead6290116f2427bf33a14d4f0617d49f78a246de10c1831224" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.59.0", ] -[[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" @@ -1764,9 +970,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.5" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" dependencies = [ "lock_api", "parking_lot_core", @@ -1774,140 +980,44 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.12" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-link 0.2.1", + "windows-targets 0.52.6", ] [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "paste" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -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", -] +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "petgraph" -version = "0.8.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ - "fixedbitset 0.5.7", - "hashbrown 0.15.5", + "fixedbitset", "indexmap", ] -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros", - "phf_shared", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared", - "rand", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn 2.0.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" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "piper" -version = "0.2.5" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" dependencies = [ "atomic-waker", "fastrand", @@ -1916,78 +1026,44 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "polling" -version = "3.11.0" +version = "3.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +checksum = "b5bd19146350fe804f7cb2669c851c03d69da628803dab0d98018142aaa5d829" dependencies = [ "cfg-if", "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix", - "windows-sys", + "rustix 1.0.8", + "windows-sys 0.60.2", ] [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] name = "portable-atomic-util" -version = "0.2.6" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" dependencies = [ "portable-atomic", ] -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "d61789d7719defeb74ea5fe81f2fdfdbd28a803847077cecce2ff14e1472f6f1" dependencies = [ "unicode-ident", ] @@ -2001,20 +1077,11 @@ dependencies = [ "memchr", ] -[[package]] -name = "quick-xml" -version = "0.39.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" -dependencies = [ - "memchr", -] - [[package]] name = "quote" -version = "1.0.45" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] @@ -2025,119 +1092,34 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "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" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" 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", + "bitflags 2.9.1", + "cassowary", "compact_str", - "hashbrown 0.16.1", + "crossterm 0.28.1", "indoc", + "instability", "itertools", - "kasuari", "lru", + "paste", "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", - "line-clipping", - "ratatui-core", - "strum", - "time", - "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.0", ] [[package]] name = "redox_syscall" -version = "0.5.18" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.9.1", ] [[package]] @@ -2146,16 +1128,16 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.17", + "getrandom 0.2.16", "libredox", - "thiserror 2.0.18", + "thiserror", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", @@ -2165,9 +1147,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", @@ -2176,61 +1158,48 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -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", -] +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "rusqlite" -version = "0.39.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.9.1", "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 = "1.1.4" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.9.1", "errno", "libc", - "linux-raw-sys", - "windows-sys", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags 2.9.1", + "errno", + "libc", + "linux-raw-sys 0.9.4", + "windows-sys 0.60.2", ] [[package]] @@ -2241,9 +1210,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.23" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "scopeguard" @@ -2251,84 +1220,36 @@ 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" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.143" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" dependencies = [ "itoa", "memchr", + "ryu", "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn 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]] @@ -2349,36 +1270,30 @@ dependencies = [ [[package]] name = "signal-hook-mio" -version = "0.2.5" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", - "mio", + "mio 0.8.11", + "mio 1.0.4", "signal-hook", ] [[package]] name = "signal-hook-registry" -version = "1.4.8" +version = "1.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" dependencies = [ - "errno", "libc", ] -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - [[package]] name = "slab" -version = "0.4.12" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "smallvec" @@ -2404,57 +1319,27 @@ dependencies = [ ] [[package]] -name = "sqlite-wasm-rs" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +name = "stash" +version = "0.2.4" 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", - "color-eyre", - "crossterm", - "ctrlc", + "crossterm 0.29.0", "dirs", "env_logger", - "futures", - "humantime", "imagesize", "inquire", - "libc", "log", - "mime-sniffer", - "notify-rust", "ratatui", "regex", "rusqlite", "serde", "serde_json", "smol", - "tempfile", - "thiserror 2.0.18", + "thiserror", "unicode-segmentation", - "unicode-width", - "wayland-client", - "wayland-protocols-wlr", + "unicode-width 0.2.0", "wl-clipboard-rs", ] @@ -2472,395 +1357,116 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.27.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.27.2" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "rustversion", + "syn", ] [[package]] name = "syn" -version = "1.0.109" +version = "2.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "7bc3fcb250e53458e712715cf74285c1f889686520d79294a9ef3bd7aa1fc619" 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 0.37.5", - "thiserror 2.0.18", - "windows", - "windows-version", -] - [[package]] name = "tempfile" -version = "3.27.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.3.3", "once_cell", - "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", + "rustix 1.0.8", + "windows-sys 0.59.0", ] [[package]] name = "thiserror" -version = "1.0.69" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "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", + "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.69" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", - "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.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "libc", - "num-conv", - "num_threads", - "powerfmt", - "serde_core", - "time-core", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.11+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow 1.0.1", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow 1.0.1", -] - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -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", + "syn", ] [[package]] name = "tree_magic_mini" -version = "3.2.2" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +checksum = "f943391d896cdfe8eec03a04d7110332d445be7df856db382dd96a730667562c" dependencies = [ "memchr", - "nom 8.0.0", + "nom", + "once_cell", "petgraph", ] -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys", -] - [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-truncate" -version = "2.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "unicode-width" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "utf8parse" @@ -2868,46 +1474,12 @@ 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" @@ -2915,135 +1487,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" +name = "wasi" +version = "0.14.2+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.118" -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", + "wit-bindgen-rt", ] [[package]] name = "wayland-backend" -version = "0.3.15" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" dependencies = [ "cc", "downcast-rs", - "rustix", + "rustix 1.0.8", "smallvec", "wayland-sys", ] [[package]] name = "wayland-client" -version = "0.31.14" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" dependencies = [ - "bitflags 2.11.0", - "log", - "rustix", + "bitflags 2.9.1", + "rustix 1.0.8", "wayland-backend", "wayland-scanner", ] [[package]] name = "wayland-protocols" -version = "0.32.12" +version = "0.32.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.9.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -3051,11 +1534,11 @@ dependencies = [ [[package]] name = "wayland-protocols-wlr" -version = "0.3.12" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.9.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -3064,96 +1547,24 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.10" +version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" dependencies = [ "proc-macro2", - "quick-xml 0.39.2", + "quick-xml", "quote", ] [[package]] name = "wayland-sys" -version = "0.31.11" +version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" 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" @@ -3176,74 +1587,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-link 0.1.3", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core", - "windows-link 0.1.3", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "windows-link" version = "0.1.3" @@ -3251,376 +1594,242 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] -name = "windows-link" -version = "0.2.1" +name = "windows-sys" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-core", - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", + "windows-targets 0.48.5", ] [[package]] name = "windows-sys" -version = "0.61.2" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-link 0.2.1", + "windows-targets 0.52.6", ] [[package]] -name = "windows-threading" -version = "0.1.0" +name = "windows-sys" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-link 0.1.3", + "windows-targets 0.53.3", ] [[package]] -name = "windows-version" -version = "0.1.7" +name = "windows-targets" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", ] [[package]] -name = "winnow" -version = "0.7.15" +name = "windows-targets" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "memchr", + "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 = "winnow" -version = "1.0.1" +name = "windows-targets" +version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "memchr", + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", ] [[package]] -name = "wit-bindgen" -version = "0.51.0" +name = "windows_aarch64_gnullvm" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] -name = "wit-bindgen-core" -version = "0.51.0" +name = "windows_aarch64_gnullvm" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] -name = "wit-bindgen-rust" -version = "0.51.0" +name = "windows_aarch64_gnullvm" +version = "0.53.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", -] +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" [[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" +name = "windows_aarch64_msvc" +version = "0.48.5" 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", -] +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] -name = "wit-component" -version = "0.244.0" +name = "windows_aarch64_msvc" +version = "0.52.6" 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", -] +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] -name = "wit-parser" -version = "0.244.0" +name = "windows_aarch64_msvc" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", + "bitflags 2.9.1", ] [[package]] name = "wl-clipboard-rs" -version = "0.9.3" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +checksum = "8e5ff8d0e60065f549fafd9d6cb626203ea64a798186c80d8e7df4f8af56baeb" dependencies = [ "libc", "log", "os_pipe", - "rustix", - "thiserror 2.0.18", + "rustix 0.38.44", + "tempfile", + "thiserror", "tree_magic_mini", "wayland-backend", "wayland-client", "wayland-protocols", "wayland-protocols-wlr", ] - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zbus" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys", - "winnow 0.7.15", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "5.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.117", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" -dependencies = [ - "serde", - "winnow 0.7.15", - "zvariant", -] - -[[package]] -name = "zerofrom" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zmij" -version = "1.0.21" -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 0.7.15", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.117", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 2.0.117", - "winnow 0.7.15", -] diff --git a/Cargo.toml b/Cargo.toml index 2aae609..50276c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,59 +1,37 @@ [package] -name = "stash-clipboard" -description = "Wayland clipboard manager with fast persistent history and multi-media support" -version = "0.3.6" -edition = "2024" -authors = [ "NotAShelf " ] -license = "MPL-2.0" -readme = true -repository = "https://github.com/notashelf/stash" -rust-version = "1.91.0" - -[[bin]] -name = "stash" # actual binary name for Nix, Cargo, etc. -path = "src/main.rs" +name = "stash" +version = "0.2.4" +edition = "2024" +authors = ["NotAShelf "] +license = "MPL-2.0" +readme = true +repository = "https://github.com/notashelf/stash" +rust-version = "1.85" [dependencies] -arc-swap = { version = "1.9.1", optional = true } -base64 = "0.22.1" -blocking = "1.6.2" -clap = { version = "4.6.0", features = [ "derive", "env" ] } -clap-verbosity-flag = "3.0.4" -color-eyre = "0.6.5" -crossterm = "0.29.0" -ctrlc = "3.5.2" -dirs = "6.0.0" -env_logger = "0.11.10" -humantime = "2.3.0" -imagesize = "0.14.0" -inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] } -libc = "0.2.185" -log = "0.4.29" -mime-sniffer = "0.1.3" -notify-rust = { version = "4.14.0", optional = true } -ratatui = "0.30.0" -regex = "1.12.3" -rusqlite = { version = "0.39.0", features = [ "bundled" ] } -serde = { version = "1.0.228", features = [ "derive" ] } -serde_json = "1.0.149" -smol = "2.0.2" -thiserror = "2.0.18" -unicode-segmentation = "1.13.2" -unicode-width = "0.2.2" -wayland-client = { version = "0.31.14", features = [ "log" ], optional = true } -wayland-protocols-wlr = { version = "0.3.12", default-features = false, optional = true } -wl-clipboard-rs = "0.9.3" - -[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" ] +clap = { version = "4.5.45", features = ["derive"] } +clap-verbosity-flag = "3.0.4" +dirs = "6.0.0" +imagesize = "0.14.0" +inquire = { default-features = false, version = "0.7.5", features = [ + "crossterm", +] } +log = "0.4.27" +env_logger = "0.11.8" +thiserror = "2.0.16" +wl-clipboard-rs = "0.9.2" +rusqlite = { version = "0.37.0", features = ["bundled"] } +smol = "2.0.2" +serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0.143" +base64 = "0.22.1" +regex = "1.11.1" +ratatui = "0.29.0" +crossterm = "0.29.0" +unicode-segmentation = "1.12.0" +unicode-width = "0.2.0" [profile.release] -lto = true +lto = true opt-level = "z" -strip = true +strip = true diff --git a/README.md b/README.md index d29b4f4..240c749 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,13 @@
- + Build Status - + Dependency Status
- 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. + Wayland clipboard "manager" with fast persistent history and multi-media + support. Stores and previews clipboard entries (text, images) on the command + line.

Features
- Installation | Usage | Motivation
+ Installation | Usage
Tips and Tricks
## Features -Stash is a feature-rich, yet simple and lightweight clipboard management utility -with many features such as but not necessarily limited to: +Stash is a feature-rich, yet simple clipboard management utility with many +features such as but not limited to: - Automatic MIME detection for stored entries - Fast persistent storage using SQLite @@ -44,41 +44,25 @@ with many features such as but not necessarily limited to: - Backwards compatible with Cliphist TSV format - Import clipboard history from TSV (e.g., from `cliphist list`) - Image preview (shows dimensions and format) +- Deduplication and entry limit control - Text previews with customizable width -- De-duplication, whitespace prevention and entry limit control -- Automatic clipboard monitoring with - [`stash watch`](#watch-clipboard-for-changes-and-store-automatically) - - Configurable auto-expiry of old entries in watch mode as a safety buffer -- Drop-in replacement for `wl-clipboard` tools (`wl-copy` and `wl-paste`) +- Automatic clipboard monitoring with `stash watch` - Sensitive clipboard filtering via regex (see below) -- Sensitive clipboard filtering by application (see below) -on top of the existing features of Cliphist, which are as follows: - -- Write clipboard changes to a history file. -- Recall history with dmenu, rofi, wofi (or whatever other picker you like). -- Both text and images are supported. -- Clipboard is preserved byte-for-byte. - - Leading/trailing whitespace, no whitespace, or newlines are preserved. - - Won’t break fancy editor selections like Vim wordwise, linewise, or block - mode. - -Most of Stash's usage is documented in the [usage section](#usage) for more -details. Refer to the [Tips & Tricks section](#tips--tricks) for more "advanced" -features, or conveniences provided by Stash. +See [usage section](#usage) for more details. ## Installation ### With Nix -Nix is the recommended way of downloading (and developing!) Stash. You can -install it using Nix flakes using `nix profile add` if on non-nixos or add Stash -as a flake input if you are on NixOS. +Nix is the recommended way of downloading Stash. You can install it using Nix +flakes using `nix profile add` if on non-nixos or add Stash as a flake input if +you are on NixOS. ```nix { # Add Stash to your inputs like so - inputs.stash.url = "github:NotAShelf/stash"; + inputs.stash.url = "github:notashelf/stash"; outputs = { /* ... */ }; } @@ -100,12 +84,10 @@ in { } ``` -If you want to give Stash a try before you switch to it, you may also run it one -time with `nix run`. +You can also run it one time with `nix run` ```sh -# Run directly from the git repository; will be garbage collected -$ nix run github:NotAShelf/stash -- watch # start the watch daemon +nix run github:notashelf/stash -- watch # start the watch daemon ``` ### Without Nix @@ -114,84 +96,29 @@ $ 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, either: +[GitHub Releases]. To install Stash on your system without Nix, eiter: - Download a tagged release from [GitHub Releases] for your platform and place the binary in your `$PATH`. Instructions may differ based on your distribution, but generally you want to download the built binary from - releases and put it somewhere like `/usr/bin` or `~/.local/bin` depending on - your distribution. + releases and put it somewhere like `/usr/bin`. - Build and install from source with Cargo: ```bash - cargo install stash --locked + cargo install --git https://github.com/notashelf/stash ``` -Additionally, you may get Stash from source via `cargo install` using -`cargo install --git https://github.com/notashelf/stash --locked` or you may -check out to the repository, and use Cargo to build it. You'll need Rust 1.91.0 -or above. Most distributions should package this version already. You may, of -course, prefer to package the built releases if you'd like. - ## Usage -> [!IMPORTANT] +Command interface is only slightly different from Cliphist. In most cases, it +will be as simple as replacing `cliphist` with `stash` in your commands, aliases +or scripts. + +> [!NOTE] > It is not a priority to provide 1:1 backwards compatibility with Cliphist. -> While the interface is generally similar, Stash chooses to build upon +> While the interface is _almost_ identical, Stash chooses to build upon > Cliphist's design and extend existing design choices. See -> [Migrating from Cliphist](#migrating-from-cliphist) for more details. Refer to -> help text if confused. - -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 -``` - - +> [Migrating from Cliphist](#migrating-from-cliphist) for more details. ### Store an entry @@ -205,39 +132,18 @@ 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 +stash decode --input "1234" ``` -> [!TIP] -> Decoding from dmenu-compatible tools: -> -> ```bash -> stash list | tofi | stash decode -> ``` - ### Delete entries matching a query ```bash -stash delete --type [id | query] +stash delete --type query --arg "some text" ``` -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 @@ -246,33 +152,10 @@ stash delete --type id < ids.txt ### Wipe all entries -> [!WARNING] -> This command is deprecated, and will be removed in v0.4.0. Use `stash db wipe` -> instead. - ```bash stash wipe ``` -### Database management - -Stash provides a `db` subcommand for database maintenance operations: - -```bash -stash db wipe [--expired] [--ask] -stash db vacuum -stash db stats -``` - -- `stash db wipe`: Remove all entries from the database. Use `--expired` to only - wipe expired entries instead of all entries. Requires `--ask` confirmation by - default. -- `stash db vacuum`: Optimize the database using SQLite's VACUUM command, - reclaiming space and improving performance. -- `stash db stats`: Display database statistics including total/active/expired - entry counts, storage size, and page information. This is provided purely for - convenience and the rule of the cool. - ### Watch clipboard for changes and store automatically ```bash @@ -282,63 +165,9 @@ 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 `contrib/`. Packagers are encouraged to vendor the +premade Systemd service in `vendor/`. Packagers are encouraged to vendor the service unless adding their own. -#### Automatic Clipboard Clearing on Expiration - -When `stash watch` is running and a clipboard entry expires, Stash will detect -if the current clipboard still contains that expired content and automatically -clear it. This prevents stale data from remaining in your clipboard after an -entry has expired from history. - -> [!NOTE] -> This behavior only applies when the watch daemon is actively running. Manual -> expiration or deletion of entries will not clear the clipboard. - -#### MIME Type Preference for Watch - -`stash watch` supports a `--mime-type` (short `-t`) option that lets you -prioritise which MIME type the daemon should request from the clipboard when -multiple representations are available. - -- `any` (default): Request any available representation (current behaviour). -- `text`: Prefer text representations (e.g. `text/plain`, `text/html`). -- `image`: Prefer image representations (e.g. `image/png`, `image/jpeg`) so that - image copies from browsers or file managers are stored as images rather than - HTML fragments. - -Example: prefer images when running the watch daemon - -```bash -stash watch --mime-type image -``` - -This is useful when copying images from browsers or file managers where the -clipboard may offer both HTML and image representations; selecting `image` will -ask the compositor for image data first. Most users will be fine using the -default value (`any`) but in the case your browser (or other applications!) -regularly misrepresent data, you might wish to prioritize a different type. - -#### Clipboard Persistence - -By default, when you copy something and close the source application, Wayland -clears the clipboard. Stash can optionally keep the clipboard contents available -after the source closes using the `--persist` flag. - -```bash -stash watch --persist -``` - -When enabled, Stash will fork a background process to serve the clipboard -contents, keeping them available even after the original application exits. - -> [!NOTE] -> This feature is **opt-in** and disabled by default, as it may not be desirable -> for all users and can leave clipboard data in memory longer than expected. You -> must start the `stash watch` daemon with `--persist` for clipboard -> persistence. - ### Options Some commands take additional flags to modify Stash's behavior. See each @@ -350,84 +179,35 @@ commands `--help` text for more details. The following are generally standard: - `--preview-width `: Text preview max width for `list` - `--version`: Print the current version and exit -### Sensitive Clipboard Filtering +#### Sensitive Clipboard Filtering Stash can be configured to avoid storing clipboard entries that match a sensitive pattern, using a regular expression. This is useful for preventing accidental storage of secrets, passwords, or other sensitive data. You don't want sensitive data ending up in your persistent clipboard, right? -The filter can be configured in one of three ways, as part of two separate -features. +The filter can be configured in one of two ways: -#### Clipboard Filtering by Entry Regex +- **Environment variable**: Set `STASH_SENSITIVE_REGEX` to a valid regex + pattern. If clipboard text matches, it will not be stored. +- **Systemd LoadCredential**: If running as a service, you can provide a regex + pattern via a credential file. For example, add to your `stash.service`: -This can be configured in one of two ways. You can use the **environment -variable** `STASTH_SENSITIVE_REGEX` to a valid regex pattern, and if the -clipboard text matches the regex it will not be stored. This can be used for -trivial secrets such as but not limited to GitHub tokens or secrets that follow -a rule, e.g. a prefix. You would typically set this in your `~/.bashrc` or -similar but in some cases this might be a security flaw. + ```ini + LoadCredential=clipboard_filter:/etc/stash/clipboard_filter + ``` -The safer alternative to this is using **Systemd LoadCrediental**. If Stash is -running as a Systemd service, you can provide a regex pattern using a crediental -file. For example, add to your `stash.service`: - -```dosini -LoadCredential=clipboard_filter:/etc/stash/clipboard_filter -``` - -The file `/etc/stash/clipboard_filter` should contain your regex pattern (no -quotes). This is done automatically in the -[vendored Systemd service](./contrib/stash.service). Remember to set the -appropriate file permissions if using this option. + The file `/etc/stash/clipboard_filter` should contain your regex pattern (no + quotes). This is done automatically in the vendored Systemd service. Remember + to set the appropriate file permissions if using this option. The service will check the credential file first, then the environment variable. If a clipboard entry matches the regex, it will be skipped and a warning will be logged. -> [!TIP] -> **Example regex to block common password patterns**: -> -> `(password|secret|api[_-]?key|token)[=: ]+[^\s]+` -> -> For security reasons, you are recommended to use the regex only for generic -> tokens that follow a specific rule, for example a generic prefix or suffix. +**Example regex to block common password patterns**: -#### Clipboard Filtering by Application Class - -Stash allows blocking an entry from the persistent history if it has been copied -from certain applications. This depends on the `use-toplevel` feature flag and -uses the the `wlr-foreign-toplevel-management-v1` protocol for precise focus -detection. While this feature flag is enabled (the default) you may use -`--excluded-apps` in, e.g., `stash watch` or set the `STASH_EXCLUDED_APPS` -environment variable to block entries from persisting in the database if they -are coming from your password manager for example. The entry is still copied to -the clipboard, but it will never be put inside the database. - -This is a more robust alternative to using the regex method above, since you -likely do not want to catch your passwords with a regex. Simply pass your -password manager's **window class** to `--excluded-apps` and your passwords will -be only copied to the clipboard. - -> [!TIP] -> **Example startup command for Stash daemon**: -> -> `stash --excluded-apps Bitwarden watch` - -## Motivation - -I've been a long-time user of Cliphist. You can probably tell by the number of -times it has been mentioned in the README, if not for the attributions section, -that Stash is _clearly_ inspired and adapted from it. It's actually a great -clipboard manager if your needs are simple, but mine aren't. I need an -**all-in-one** solution, that I can freely hack on, with simple solutions to -complex problems that I've had with managing my clipboard. I wanted it to be -scriptable _and_ interactive, I wanted it to be performant, I wanted it to be... - -You get the point. Perhaps you also share similar needs, or just like Rust -software in general on your desktop. In either case, Stash hopes to serve as an -excellent clipboard manager for your needs, with _excellent_ performance. +- `(password|secret|api[_-]?key|token)[=: ]+[^\s]+` ## Tips & Tricks @@ -452,7 +232,7 @@ should know. - Stash adds a `watch` command to automatically store clipboard changes. This is an alternative to `wl-paste --watch cliphist list`. You can avoid shelling out and depending on `wl-paste` as Stash implements it through `wl-clipboard-rs` - crate and provides its own `wl-copy` and `wl-paste` binaries. + crate. ### TSV Export and Import @@ -505,116 +285,3 @@ figured out something new, e.g. a neat shell trick, feel free to add it here! ```bash cliphist list --db ~/.cache/cliphist/db | stash import ``` - -3. Stash provides its own implementation of `wl-copy` and `wl-paste` commands - backed by `wl-clipboard-rs`. Those implementations are backwards compatible - with `wl-clipboard`, and may be used as **drop-in** replacements. The default - build wrapper in `build.rs` links `stash` to `stash-copy` and `stash-paste`, - which are also available as `wl-copy` and `wl-paste` respectively. The Nix - package automatically links those to `$out/bin` for you, which means they are - installed by default but other package managers may need additional steps by - the packagers. While building from source, you may link - `target/release/stash` manually. - -### Entry Expiration - -Stash supports time-to-live (TTL) for clipboard entries. When an entry's -expiration time is reached, it is marked as expired rather than immediately -deleted. This allows for inspection of expired entries and automatic clipboard -cleanup. - -#### How Expiration Works - -When `stash watch` is running with `--expire-after`, it monitors the clipboard -and processes expired entries periodically. Upon expiration: - -1. The entry's `is_expired` flag is set to `1` in the database -2. If the current clipboard content matches the expired entry, Stash clears the - clipboard to prevent pasting stale data -3. Expired entries are excluded from normal list operations unless `--expired` - is specified - -> [!NOTE] -> By default, entries do not expire. Use `stash watch --expire-after DURATION` -> to enable expiration (e.g., `--expire-after 24h` for 24-hour TTL). - -#### Viewing Expired Entries - -Use `stash list --expired` to include expired entries in the output. This is -useful for: - -- Inspecting what has expired from your clipboard history -- Verifying that sensitive data has been properly expired -- Debugging expiration behavior - -```bash -# View all entries including expired ones -stash list --expired - -# View expired entries in JSON format -stash list --expired --format json -``` - -#### Cleaning Up Expired Entries - -The watch daemon automatically cleans up expired entries when it processes them. -For manual cleanup, use: - -```bash -# Remove all expired entries from the database -stash db wipe --expired -``` - -> [!NOTE] -> If you have a large number of expired entries, consider running -> `stash db vacuum` afterward to reclaim disk space. - -#### Automatic Clipboard Clearing - -When `stash watch` is running and an entry expires, Stash checks if the current -clipboard still contains that expired content. If it matches, Stash clears the -clipboard automatically. This prevents accidentally pasting outdated content. - -> [!TIP] -> This behavior only applies when the watch daemon is actively running. Manual -> expiration or deletion of entries will not clear the clipboard. - -#### Database Maintenance - -Stash uses SQLite for persistent storage. Over time, deleted entries and -fragmentation can affect performance. Use the `stash db` command to maintain -your database: - -- **Check statistics**: `stash db stats` shows entry counts and storage usage. - Use this to monitor growth and decide when to clean up. -- **Remove expired entries**: `stash db wipe --expired` removes entries that - have reached their TTL. The daemon normally handles this, but this is useful - for manual cleanup. -- **Optimize storage**: `stash db vacuum` runs SQLite's VACUUM command to - reclaim space and defragment the database. This is safe to run periodically. - -It is recommended to run `stash db vacuum` occasionally (e.g., monthly) to keep -the database compact, especially after deleting many entries. You can, of -course, wipe the database entirely if it has grown too large. - -## Attributions - -My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the -[wl-clipboard-rs](https://github.com/YaLTeR/wl-clipboard-rs) crate. Stash is -powered by [several crates](./Cargo.toml), but none of them were as detrimental -in Stash's design process. - -Secondly, but by no means less importantly, I would like to thank -[cliphist](https://github.com/sentriz/cliphist) for the excellent reference it -has provided to me as a "solid clipboard manager." The interface of Stash is -inspired by Cliphist, and it has served me very well for a very long time. - -Additional and definitely heartfelt thanks to my testers, who have tested -earlier versions of Stash, helped with packaging and provided feedback. Thank -you :) - -## License - -This project is made available under Mozilla Public License (MPL) version 2.0. -See [LICENSE](LICENSE) for more details on the exact conditions. An online copy -is provided [here](https://www.mozilla.org/en-US/MPL/2.0/). diff --git a/flake.lock b/flake.lock index d437322..02d5006 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1776635034, - "narHash": "sha256-OEOJrT3ZfwbChzODfIH4GzlNTtOFuZFWPtW7jIeR8xU=", + "lastModified": 1754269165, + "narHash": "sha256-0tcS8FHd4QjbCVoxN9jI+PjHgA4vc/IjkUSp+N3zy0U=", "owner": "ipetkov", "repo": "crane", - "rev": "dc7496d8ea6e526b1254b55d09b966e94673750f", + "rev": "444e81206df3f7d92780680e45858e31d2f07a08", "type": "github" }, "original": { @@ -17,11 +17,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1775710090, - "narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=", + "lastModified": 1754725699, + "narHash": "sha256-iAcj9T/Y+3DBy2J0N+yF9XQQQ8IEb5swLFzs23CdP88=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "4c1018dae018162ec878d42fec712642d214fdfa", + "rev": "85dbfc7aaf52ecb755f87e577ddbe6dbbdbc1054", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index b41dbf9..9be3b14 100644 --- a/flake.nix +++ b/flake.nix @@ -1,8 +1,6 @@ { - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs?ref=nixos-unstable"; - crane.url = "github:ipetkov/crane"; - }; + inputs.nixpkgs.url = "github:NixOS/nixpkgs?ref=nixos-unstable"; + inputs.crane.url = "github:ipetkov/crane"; outputs = { self, @@ -13,16 +11,10 @@ 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 { - stash = pkgsForEach.${system}.callPackage ./nix/package.nix {inherit craneLib;}; - default = self.packages.${system}.stash; + packages = forEachSystem (system: { + default = pkgsForEach.${system}.callPackage ./nix/package.nix { + craneLib = crane.mkLib pkgsForEach.${system}; + }; }); devShells = forEachSystem (system: { diff --git a/nix/modules/nixos.nix b/nix/modules/nixos.nix deleted file mode 100644 index 23072a0..0000000 --- a/nix/modules/nixos.nix +++ /dev/null @@ -1,78 +0,0 @@ -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 b27a730..0366838 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -1,14 +1,9 @@ { lib, craneLib, - stdenv, - mold, - versionCheckHook, - useMold ? stdenv.isLinux, - createSymlinks ? true, }: let pname = "stash"; - version = (lib.importTOML ../Cargo.toml).package.version; + version = (builtins.fromTOML (builtins.readFile ../Cargo.toml)).package.version; src = let fs = lib.fileset; s = ../.; @@ -33,39 +28,18 @@ in strictDeps = true; - # Since Crane doesn't have a good way of enforcing that our symlinks - # generated by the build wrapper are correctly linked, we should link - # them *manually*. The postInstallCheck phase that follows will check - # to verify if all of those links are in place. - postInstall = lib.optionalString createSymlinks '' + # Install Systemd service for Stash into $out/share. + # This can be used to use Stash in 'systemd.packages' + postInstall = '' mkdir -p $out - for bin in stash-copy stash-paste wl-copy wl-paste; do - ln -sf $out/bin/stash $out/bin/$bin - done + install -Dm755 ${../vendor/stash.service} $out/share/stash.service ''; - nativeInstallCheckInputs = [versionCheckHook]; - doInstallCheck = true; - - # After the version check, let's see if all binaries are linked correctly. - # We could probably add a check phase to get the versions of each. - postInstallCheck = lib.optionalString createSymlinks '' - for bin in stash stash-copy stash-paste wl-copy wl-paste; do - [ -x "$out/bin/$bin" ] || { echo "$bin missing"; exit 1; } - done - ''; - - env = lib.optionalAttrs useMold { - CARGO_LINKER = "clang"; - CARGO_RUSTFLAGS = "-Clink-arg=-fuse-ld=${mold}/bin/mold"; - }; - meta = { description = "Wayland clipboard manager with fast persistent history and multi-media support"; homepage = "https://github.com/notashelf/stash"; license = lib.licenses.mpl20; maintainers = [lib.maintainers.NotAShelf]; mainProgram = "stash"; - platforms = lib.platforms.linux; }; } diff --git a/nix/shell.nix b/nix/shell.nix index 273d74a..9df0432 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -6,7 +6,6 @@ clippy, taplo, rust-analyzer-unwrapped, - cargo-nextest, rustPlatform, }: mkShell { @@ -21,9 +20,6 @@ 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 deleted file mode 100644 index 2648ce5..0000000 --- a/src/clipboard/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod persist; - -pub use persist::{ClipboardData, get_serving_pid, persist_clipboard}; diff --git a/src/clipboard/persist.rs b/src/clipboard/persist.rs deleted file mode 100644 index a677f50..0000000 --- a/src/clipboard/persist.rs +++ /dev/null @@ -1,262 +0,0 @@ -use std::{ - process::exit, - sync::atomic::{AtomicI32, Ordering}, -}; - -use wl_clipboard_rs::copy::{ - ClipboardType, - MimeType as CopyMimeType, - Options, - PreparedCopy, - ServeRequests, - Source, -}; - -/// Maximum number of paste requests to serve before exiting. This (hopefully) -/// prevents runaway processes while still providing persistence. -const MAX_SERVE_REQUESTS: usize = 1000; - -/// PID of the current clipboard persistence child process. Used to detect when -/// clipboard content is from our own serve process. -static SERVING_PID: AtomicI32 = AtomicI32::new(0); - -/// Get the current serving PID if any. Used by the watch loop to avoid -/// duplicate persistence processes. -pub fn get_serving_pid() -> Option { - let pid = SERVING_PID.load(Ordering::SeqCst); - if pid != 0 { Some(pid) } else { None } -} - -/// Result type for persistence operations. -pub type PersistenceResult = Result; - -/// Errors that can occur during clipboard persistence. -#[derive(Debug, thiserror::Error)] -pub enum PersistenceError { - #[error("Failed to prepare copy: {0}")] - PrepareFailed(String), - - #[error("Failed to fork: {0}")] - ForkFailed(String), - - #[error("Clipboard data too large: {0} bytes")] - DataTooLarge(usize), - - #[error("Clipboard content is empty")] - EmptyContent, - - #[error("No MIME types to offer")] - NoMimeTypes, -} - -/// Clipboard data with all MIME types for persistence. -#[derive(Debug, Clone)] -pub struct ClipboardData { - /// The actual clipboard content. - pub content: Vec, - - /// All MIME types offered by the source. Preserves order. - pub mime_types: Vec, - - /// The MIME type that was selected for storage. - pub selected_mime: String, -} - -impl ClipboardData { - /// Create new clipboard data. - pub fn new( - content: Vec, - mime_types: Vec, - selected_mime: String, - ) -> Self { - Self { - content, - mime_types, - selected_mime, - } - } - - /// Check if data is valid for persistence. - pub fn is_valid(&self) -> Result<(), PersistenceError> { - const MAX_SIZE: usize = 100 * 1024 * 1024; // 100MB - - if self.content.is_empty() { - return Err(PersistenceError::EmptyContent); - } - - if self.content.len() > MAX_SIZE { - return Err(PersistenceError::DataTooLarge(self.content.len())); - } - - if self.mime_types.is_empty() { - return Err(PersistenceError::NoMimeTypes); - } - - Ok(()) - } -} - -/// Persist clipboard data by forking a background process that serves it. -/// -/// 1. Prepares a clipboard copy operation with all MIME types -/// 2. Forks a child process -/// 3. The child serves clipboard data indefinitely (until MAX_SERVE_REQUESTS) -/// 4. The parent returns immediately -/// -/// # Safety -/// -/// This function uses `libc::fork()` which is unsafe. The child process -/// must not modify any shared state or file descriptors. -pub unsafe fn persist_clipboard(data: ClipboardData) -> PersistenceResult<()> { - // Validate data - data.is_valid()?; - - // Prepare the copy operation - let prepared = prepare_clipboard_copy(&data)?; - - // Fork and serve - unsafe { fork_and_serve(prepared) } -} - -/// Prepare a clipboard copy operation with all MIME types. -fn prepare_clipboard_copy( - data: &ClipboardData, -) -> PersistenceResult { - let mut opts = Options::new(); - opts.clipboard(ClipboardType::Regular); - opts.serve_requests(ServeRequests::Only(MAX_SERVE_REQUESTS)); - opts.foreground(true); // we'll fork manually for better control - - // Determine MIME type for the primary offer - let mime_type = if data.selected_mime.starts_with("text/") { - CopyMimeType::Text - } else { - CopyMimeType::Specific(data.selected_mime.clone()) - }; - - // Prepare the copy - let prepared = opts - .prepare_copy(Source::Bytes(data.content.clone().into()), mime_type) - .map_err(|e| PersistenceError::PrepareFailed(e.to_string()))?; - - Ok(prepared) -} - -/// Fork a child process to serve clipboard data. -/// -/// The child process will: -/// -/// 1. Register its process ID with the self-detection module -/// 2. Serve clipboard requests until MAX_SERVE_REQUESTS -/// 3. Exit cleanly -/// -/// The parent stores the child `PID` in `SERVING_PID` and returns immediately. -unsafe fn fork_and_serve(prepared: PreparedCopy) -> PersistenceResult<()> { - // Enable automatic child reaping to prevent zombie processes - unsafe { - libc::signal(libc::SIGCHLD, libc::SIG_IGN); - } - - match unsafe { libc::fork() } { - 0 => { - // Child process - clear serving PID - // Look at me. I'm the server now. - SERVING_PID.store(0, Ordering::SeqCst); - serve_clipboard_child(prepared); - exit(0); - }, - - -1 => { - // Oops. - Err(PersistenceError::ForkFailed( - "libc::fork() returned -1".to_string(), - )) - }, - - pid => { - // Parent process, store child PID for loop detection - log::debug!("forked clipboard persistence process (pid: {pid})"); - SERVING_PID.store(pid, Ordering::SeqCst); - Ok(()) - }, - } -} - -/// Child process entry point for serving clipboard data. -fn serve_clipboard_child(prepared: PreparedCopy) { - let pid = std::process::id() as i32; - log::debug!("clipboard persistence child process started (pid: {pid})"); - - // Serve clipboard requests. The PreparedCopy::serve() method blocks and - // handles all the Wayland protocol interactions internally via - // wl-clipboard-rs - match prepared.serve() { - Ok(()) => { - log::debug!("clipboard persistence: serve completed normally"); - }, - - Err(e) => { - log::error!("clipboard persistence: serve failed: {e}"); - exit(1); - }, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_clipboard_data_validation() { - // Valid data - let valid = ClipboardData::new( - b"hello".to_vec(), - vec!["text/plain".to_string()], - "text/plain".to_string(), - ); - assert!(valid.is_valid().is_ok()); - - // Empty content - let empty = ClipboardData::new( - vec![], - vec!["text/plain".to_string()], - "text/plain".to_string(), - ); - assert!(matches!( - empty.is_valid(), - Err(PersistenceError::EmptyContent) - )); - - // No MIME types - let no_mimes = - ClipboardData::new(b"hello".to_vec(), vec![], "text/plain".to_string()); - assert!(matches!( - no_mimes.is_valid(), - Err(PersistenceError::NoMimeTypes) - )); - - // Too large - let huge = ClipboardData::new( - vec![0u8; 101 * 1024 * 1024], // 101MB - vec!["text/plain".to_string()], - "text/plain".to_string(), - ); - assert!(matches!( - huge.is_valid(), - Err(PersistenceError::DataTooLarge(_)) - )); - } - - #[test] - fn test_clipboard_data_creation() { - let data = ClipboardData::new( - b"test content".to_vec(), - vec!["text/plain".to_string(), "text/html".to_string()], - "text/plain".to_string(), - ); - - assert_eq!(data.content, b"test content"); - assert_eq!(data.mime_types.len(), 2); - assert_eq!(data.selected_mime, "text/plain"); - } -} diff --git a/src/commands/decode.rs b/src/commands/decode.rs index f989a18..9dc9116 100644 --- a/src/commands/decode.rs +++ b/src/commands/decode.rs @@ -26,30 +26,30 @@ impl DecodeCommand for SqliteClipboardDb { let mut buf = String::new(); in_ .read_to_string(&mut buf) - .map_err(|e| StashError::DecodeRead(e.to_string().into()))?; + .map_err(|e| StashError::DecodeRead(e.to_string()))?; buf }; // 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) { let mut buf = Vec::new(); reader.read_to_end(&mut buf).map_err(|e| { - StashError::DecodeRead( - format!("Failed to read clipboard for relay: {e}").into(), - ) + StashError::DecodeRead(format!( + "Failed to read clipboard for relay: {e}" + )) })?; out.write_all(&buf).map_err(|e| { - StashError::DecodeWrite( - format!("Failed to write clipboard relay: {e}").into(), - ) + StashError::DecodeWrite(format!( + "Failed to write clipboard relay: {e}" + )) })?; } else { return Err(StashError::DecodeGet( - "Failed to get clipboard contents for relay".into(), + "Failed to get clipboard contents for relay".to_string(), )); } return Ok(()); @@ -69,14 +69,14 @@ impl DecodeCommand for SqliteClipboardDb { { let mut buf = Vec::new(); reader.read_to_end(&mut buf).map_err(|err| { - StashError::DecodeRead( - format!("Failed to read clipboard for relay: {err}").into(), - ) + StashError::DecodeRead(format!( + "Failed to read clipboard for relay: {err}" + )) })?; out.write_all(&buf).map_err(|err| { - StashError::DecodeWrite( - format!("Failed to write clipboard relay: {err}").into(), - ) + StashError::DecodeWrite(format!( + "Failed to write clipboard relay: {err}" + )) })?; Ok(()) } else { diff --git a/src/commands/delete.rs b/src/commands/delete.rs index ba358ad..dd84989 100644 --- a/src/commands/delete.rs +++ b/src/commands/delete.rs @@ -9,7 +9,7 @@ pub trait DeleteCommand { impl DeleteCommand for SqliteClipboardDb { fn delete(&self, input: impl Read) -> Result { let deleted = self.delete_entries(input)?; - log::info!("deleted {deleted} entries"); + log::info!("Deleted {deleted} entries"); Ok(deleted) } } diff --git a/src/commands/import.rs b/src/commands/import.rs index 4a3a2a7..05833d7 100644 --- a/src/commands/import.rs +++ b/src/commands/import.rs @@ -1,6 +1,12 @@ use std::io::{self, BufRead}; -use crate::db::{ClipboardDb, Entry, SqliteClipboardDb, StashError}; +use crate::db::{ + ClipboardDb, + Entry, + SqliteClipboardDb, + StashError, + detect_mime, +}; pub trait ImportCommand { /// Import clipboard entries from TSV format. @@ -21,24 +27,24 @@ impl ImportCommand for SqliteClipboardDb { let mut imported = 0; for (lineno, line) in reader.lines().enumerate() { let line = line.map_err(|e| { - StashError::Store(format!("Failed to read line {lineno}: {e}").into()) + StashError::Store(format!("Failed to read line {lineno}: {e}")) })?; let mut parts = line.splitn(2, '\t'); let (Some(id_str), Some(val)) = (parts.next(), parts.next()) else { - return Err(StashError::Store( - format!("Malformed TSV line {lineno}: {line:?}").into(), - )); + return Err(StashError::Store(format!( + "Malformed TSV line {lineno}: {line:?}" + ))); }; let Ok(_id) = id_str.parse::() else { - return Err(StashError::Store( - format!("Failed to parse id from line {lineno}: {id_str}").into(), - )); + return Err(StashError::Store(format!( + "Failed to parse id from line {lineno}: {id_str}" + ))); }; let entry = Entry { contents: val.as_bytes().to_vec(), - mime: crate::mime::detect_mime(val.as_bytes()), + mime: detect_mime(val.as_bytes()), }; self @@ -48,18 +54,18 @@ impl ImportCommand for SqliteClipboardDb { rusqlite::params![entry.contents, entry.mime], ) .map_err(|e| { - StashError::Store( - format!("Failed to insert entry at line {lineno}: {e}").into(), - ) + StashError::Store(format!( + "Failed to insert entry at line {lineno}: {e}" + )) })?; imported += 1; } - log::info!("imported {imported} records from TSV into SQLite database."); + log::info!("Imported {imported} records from TSV into SQLite database."); // Trim database to max_items after import self.trim_db(max_items)?; - log::info!("trimmed clipboard database to max_items = {max_items}"); + log::info!("Trimmed clipboard database to max_items = {max_items}"); Ok(()) } diff --git a/src/commands/list.rs b/src/commands/list.rs index b3041e5..75c1ce5 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -6,13 +6,8 @@ use unicode_width::UnicodeWidthStr; use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; pub trait ListCommand { - fn list( - &self, - out: impl Write, - preview_width: u32, - include_expired: bool, - reverse: bool, - ) -> Result<(), StashError>; + fn list(&self, out: impl Write, preview_width: u32) + -> Result<(), StashError>; } impl ListCommand for SqliteClipboardDb { @@ -20,278 +15,18 @@ impl ListCommand for SqliteClipboardDb { &self, out: impl Write, preview_width: u32, - include_expired: bool, - reverse: bool, ) -> Result<(), StashError> { - self - .list_entries(out, preview_width, include_expired, reverse) - .map(|_| ()) + self.list_entries(out, preview_width).map(|_| ()) } } -/// All mutable state for the TUI list view. -struct TuiState { - /// Total number of entries matching the current filter in the DB. - total: usize, - - /// Global cursor position: index into the full ordered result set. - cursor: usize, - - /// DB offset of `window[0]`, i.e., the first row currently loaded. - viewport_offset: usize, - - /// The loaded slice of entries: `(id, preview, mime)`. - window: Vec<(i64, String, String)>, - - /// How many rows the window holds (== visible list height). - window_size: usize, - - /// Whether the window needs to be re-fetched from the DB. - dirty: bool, - - /// Current search query. Empty string means no filter. - search_query: String, - - /// Whether we're currently in search input mode. - search_mode: bool, - - /// Whether to show entries in reverse order (oldest first). - reverse: bool, - - /// ID of entry currently being copied. - copying_entry: Option, -} - -impl TuiState { - /// Create initial state: count total rows, load the first window. - fn new( - db: &SqliteClipboardDb, - include_expired: bool, - window_size: usize, - preview_width: u32, - reverse: bool, - ) -> Result { - let total = db.count_entries(include_expired, None)?; - let window = if total > 0 { - db.fetch_entries_window( - include_expired, - 0, - window_size, - preview_width, - None, - reverse, - )? - } else { - Vec::new() - }; - Ok(Self { - total, - cursor: 0, - viewport_offset: 0, - window, - window_size, - dirty: false, - search_query: String::new(), - search_mode: false, - reverse, - copying_entry: None, - }) - } - - /// Return the current search filter (`None` if empty). - fn search_filter(&self) -> Option<&str> { - if self.search_query.is_empty() { - None - } else { - Some(&self.search_query) - } - } - - /// Update search query and reset cursor. Returns true if search changed. - fn set_search(&mut self, query: String) -> bool { - let changed = self.search_query != query; - if changed { - self.search_query = query; - self.cursor = 0; - self.viewport_offset = 0; - self.dirty = true; - } - changed - } - - /// Clear search and reset state. Returns true if was searching. - fn clear_search(&mut self) -> bool { - let had_search = !self.search_query.is_empty(); - self.search_query.clear(); - self.search_mode = false; - if had_search { - self.cursor = 0; - self.viewport_offset = 0; - self.dirty = true; - } - had_search - } - - /// Toggle search mode. - fn toggle_search_mode(&mut self) { - self.search_mode = !self.search_mode; - if self.search_mode { - // When entering search mode, clear query if there was one - // or start fresh - self.search_query.clear(); - self.dirty = true; - } - } - - /// Return the cursor position relative to the current window - /// (`window[local_cursor]` == the selected entry). - #[inline] - fn local_cursor(&self) -> usize { - self.cursor.saturating_sub(self.viewport_offset) - } - - /// Return the selected `(id, preview, mime)` if any entry is selected. - fn selected_entry(&self) -> Option<&(i64, String, String)> { - if self.total == 0 { - return None; - } - self.window.get(self.local_cursor()) - } - - /// Move the cursor down by one, wrapping to 0 at the bottom. - fn move_down(&mut self) { - if self.total == 0 { - return; - } - self.cursor = if self.cursor + 1 >= self.total { - 0 - } else { - self.cursor + 1 - }; - self.dirty = true; - } - - /// Move the cursor up by one, wrapping to `total - 1` at the top. - fn move_up(&mut self) { - if self.total == 0 { - return; - } - self.cursor = if self.cursor == 0 { - self.total - 1 - } else { - self.cursor - 1 - }; - self.dirty = true; - } - - /// Resize the window (e.g. terminal resized). Marks dirty so the - /// viewport is reloaded on the next frame. - fn resize(&mut self, new_size: usize) { - if new_size != self.window_size { - self.window_size = new_size; - self.dirty = true; - } - } - - /// After a delete the total shrinks by one and the cursor may need - /// clamping. The caller is responsible for the DB deletion itself. - fn on_delete(&mut self) { - if self.total == 0 { - return; - } - self.total -= 1; - if self.total == 0 { - self.cursor = 0; - } else if self.cursor >= self.total { - self.cursor = self.total - 1; - } - self.dirty = true; - } - - /// Reload the window from the DB if `dirty` is set or if the cursor - /// has drifted outside the currently loaded range. - fn sync( - &mut self, - db: &SqliteClipboardDb, - include_expired: bool, - preview_width: u32, - ) -> Result<(), StashError> { - let cursor_out_of_window = self.cursor < self.viewport_offset - || self.cursor >= self.viewport_offset + self.window.len().max(1); - - if !self.dirty && !cursor_out_of_window { - return Ok(()); - } - - // Re-anchor the viewport so the cursor sits in the upper half when - // scrolling downward, or at a sensible position when wrapping. - let half = self.window_size / 2; - self.viewport_offset = if self.cursor >= half { - (self.cursor - half).min(self.total.saturating_sub(self.window_size)) - } else { - 0 - }; - - let search = self.search_filter(); - self.window = if self.total > 0 { - db.fetch_entries_window( - include_expired, - self.viewport_offset, - self.window_size, - preview_width, - search, - self.reverse, - )? - } else { - Vec::new() - }; - self.dirty = false; - Ok(()) - } -} - -/// Query the maximum id digit-width and maximum mime byte-length across -/// all entries. This is pretty damn fast as it touches only index/metadata, -/// not blobs. -fn global_column_widths( - db: &SqliteClipboardDb, - include_expired: bool, -) -> Result<(usize, usize), StashError> { - let filter = if include_expired { - "" - } else { - "WHERE (is_expired IS NULL OR is_expired = 0)" - }; - let query = format!( - "SELECT COALESCE(MAX(LENGTH(CAST(id AS TEXT))), 2), \ - COALESCE(MAX(LENGTH(mime)), 8) FROM clipboard {filter}" - ); - let (id_w, mime_w): (i64, i64) = db - .conn - .query_row(&query, [], |r| Ok((r.get(0)?, r.get(1)?))) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - Ok((id_w.max(2) as usize, mime_w.max(8) as usize)) -} - impl SqliteClipboardDb { #[allow(clippy::too_many_lines)] - pub fn list_tui( - &self, - preview_width: u32, - include_expired: bool, - reverse: bool, - ) -> Result<(), StashError> { + pub fn list_tui(&self, preview_width: u32) -> Result<(), StashError> { use std::io::stdout; use crossterm::{ - event::{ - self, - DisableMouseCapture, - EnableMouseCapture, - Event, - KeyCode, - KeyModifiers, - }, + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, execute, terminal::{ EnterAlternateScreen, @@ -300,7 +35,6 @@ impl SqliteClipboardDb { enable_raw_mode, }, }; - use notify_rust::Notification; use ratatui::{ Terminal, backend::CrosstermBackend, @@ -308,175 +42,75 @@ impl SqliteClipboardDb { text::{Line, Span}, widgets::{Block, Borders, List, ListItem, ListState}, }; - use wl_clipboard_rs::copy::{MimeType, Options, Source}; - // One-time column-width metadata (no blob reads). - let (max_id_width, max_mime_width) = - global_column_widths(self, include_expired)?; + // 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()))?; + let mut rows = stmt + .query([]) + .map_err(|e| StashError::ListDecode(e.to_string()))?; - enable_raw_mode() - .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()))? + { + let id: u64 = row + .get(0) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let contents: Vec = row + .get(1) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let mime: Option = row + .get(2) + .map_err(|e| StashError::ListDecode(e.to_string()))?; + let preview = + crate::db::preview_entry(&contents, mime.as_deref(), preview_width); + let mime_str = mime.as_deref().unwrap_or("").to_string(); + let id_str = id.to_string(); + max_id_width = max_id_width.max(id_str.width()); + max_mime_width = max_mime_width.max(mime_str.width()); + entries.push((id, preview, mime_str)); + } + + enable_raw_mode().map_err(|e| StashError::ListDecode(e.to_string()))?; let mut stdout = stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + .map_err(|e| StashError::ListDecode(e.to_string()))?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + .map_err(|e| StashError::ListDecode(e.to_string()))?; - // Derive initial window size from current terminal height. - let initial_height = terminal - .size() - .map(|r| r.height.saturating_sub(2) as usize) - .unwrap_or(24); - let initial_height = initial_height.max(1); - - let mut tui = TuiState::new( - self, - include_expired, - initial_height, - preview_width, - reverse, - )?; - - // ratatui ListState; only tracks selection within the *window* slice. - let mut list_state = ListState::default(); - if tui.total > 0 { - list_state.select(Some(0)); + let mut state = ListState::default(); + if !entries.is_empty() { + state.select(Some(0)); } - /// Accumulated actions from draining the event queue. - struct EventActions { - quit: bool, - net_down: i64, // positive=down, negative=up, 0=none - copy: bool, - delete: bool, - toggle_search: bool, // enter/exit search mode - search_input: Option, // character typed in search mode - search_backspace: bool, // backspace in search mode - clear_search: bool, // clear search query (ESC in search mode) - } - - /// Drain all pending key events and return what actions to perform. - /// Navigation is capped to +-1 per frame to prevent jumpy scrolling when - /// the key-repeat rate exceeds the render frame rate. - fn drain_events(tui: &TuiState) -> Result { - let mut actions = EventActions { - quit: false, - net_down: 0, - copy: false, - delete: false, - toggle_search: false, - search_input: None, - search_backspace: false, - clear_search: false, - }; - - while event::poll(std::time::Duration::from_millis(0)) - .map_err(|e| StashError::ListDecode(e.to_string().into()))? - { - if let Event::Key(key) = event::read() - .map_err(|e| StashError::ListDecode(e.to_string().into()))? - { - if tui.search_mode { - // In search mode, handle text input - match (key.code, key.modifiers) { - (KeyCode::Esc, _) => { - actions.clear_search = true; - }, - (KeyCode::Enter, _) => { - actions.toggle_search = true; // exit search mode - }, - (KeyCode::Backspace, _) => { - actions.search_backspace = true; - }, - (KeyCode::Char(c), _) => { - actions.search_input = Some(c); - }, - _ => {}, - } - } else { - // Normal mode navigation commands - match (key.code, key.modifiers) { - (KeyCode::Char('q') | KeyCode::Esc, _) => actions.quit = true, - (KeyCode::Down | KeyCode::Char('j'), _) => { - // Cap at +1 per frame for smooth scrolling - if actions.net_down < 1 { - actions.net_down += 1; - } - }, - (KeyCode::Up | KeyCode::Char('k'), _) => { - // Cap at -1 per frame for smooth scrolling - if actions.net_down > -1 { - actions.net_down -= 1; - } - }, - (KeyCode::Enter, _) => actions.copy = true, - (KeyCode::Char('D'), KeyModifiers::SHIFT) => { - actions.delete = true; - }, - (KeyCode::Char('/'), _) => actions.toggle_search = true, - _ => {}, - } - } - } - } - Ok(actions) - } - - let draw_frame = - |terminal: &mut Terminal>, - tui: &mut TuiState, - list_state: &mut ListState, - max_id_width: usize, - max_mime_width: usize| - -> Result<(), StashError> { - // Reserve 2 rows for search bar when in search mode - let search_bar_height = if tui.search_mode { 2 } else { 0 }; - let term_height = terminal - .size() - .map(|r| r.height.saturating_sub(2 + search_bar_height) as usize) - .unwrap_or(24) - .max(1); - tui.resize(term_height); - tui.sync(self, include_expired, preview_width)?; - - if tui.total == 0 { - list_state.select(None); - } else { - list_state.select(Some(tui.local_cursor())); - } - + let res = (|| -> Result<(), StashError> { + loop { terminal .draw(|f| { let area = f.area(); - - // Build title based on search state - let title = if tui.search_mode { - format!("Search: {}", tui.search_query) - } else if tui.search_query.is_empty() { - "Clipboard Entries (j/k/↑/↓ to move, / to search, Enter to copy, \ - Shift+D to delete, q/ESC to quit)" - .to_string() - } else { - format!( - "Clipboard Entries (filtered: '{}' - {} results, / to search, \ - ESC to clear, q to quit)", - tui.search_query, tui.total - ) - }; - - let block = Block::default().title(title).borders(Borders::ALL); + let block = Block::default() + .title("Clipboard Entries (j/k/↑/↓ to move, q/ESC to quit)") + .borders(Borders::ALL); let border_width = 2; let highlight_symbol = ">"; let highlight_width = 1; let content_width = area.width as usize - border_width; + // Minimum widths for columns let min_id_width = 2; let min_mime_width = 6; let min_preview_width = 4; - let spaces = 3; + let spaces = 3; // [id][ ][preview][ ][mime] + // Dynamically allocate widths let mut id_col = max_id_width.max(min_id_width); let mut mime_col = max_mime_width.max(min_mime_width); let mut preview_col = content_width @@ -485,6 +119,7 @@ impl SqliteClipboardDb { .saturating_sub(mime_col) .saturating_sub(spaces); + // If not enough space, shrink columns if preview_col < min_preview_width { let needed = min_preview_width - preview_col; if mime_col > min_mime_width { @@ -507,13 +142,13 @@ impl SqliteClipboardDb { preview_col = min_preview_width; } - let selected = list_state.selected(); + let selected = state.selected(); - let list_items: Vec = tui - .window + let list_items: Vec = entries .iter() .enumerate() .map(|(i, entry)| { + // Truncate preview by grapheme clusters and display width let mut preview = String::new(); let mut width = 0; for g in entry.1.graphemes(true) { @@ -525,6 +160,7 @@ impl SqliteClipboardDb { preview.push_str(g); width += g_width; } + // Truncate and pad mimetype let mut mime = String::new(); let mut mwidth = 0; for g in entry.2.graphemes(true) { @@ -537,6 +173,8 @@ impl SqliteClipboardDb { mwidth += g_width; } + // Compose the row as highlight + id + space + preview + space + + // mimetype let mut spans = Vec::new(); let (id, preview, mime) = entry; if Some(i) == selected { @@ -583,162 +221,49 @@ impl SqliteClipboardDb { .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ) - .highlight_symbol(""); + .highlight_symbol(""); // handled manually - f.render_stateful_widget(list, area, list_state); + f.render_stateful_widget(list, area, &mut state); }) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - Ok(()) - }; + .map_err(|e| StashError::ListDecode(e.to_string()))?; - // 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()))? + .map_err(|e| StashError::ListDecode(e.to_string()))? { - 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; - } - let opts = Options::new(); - let mime_type = match mime { - Some(ref m) if m == "text/plain" => MimeType::Text, - Some(ref m) => MimeType::Specific(m.clone().clone()), - None => MimeType::Text, - }; - let copy_result = opts - .copy(Source::Bytes(contents.clone().into()), mime_type); - match copy_result { - Ok(()) => { - let _ = Notification::new() - .summary("Stash") - .body("Copied entry to clipboard") - .show(); - }, - Err(e) => { - log::error!("failed to copy entry to clipboard: {e}"); - let _ = Notification::new() - .summary("Stash") - .body(&format!("Failed to copy to clipboard: {e}")) - .show(); - }, + if let Event::Key(key) = + event::read().map_err(|e| StashError::ListDecode(e.to_string()))? + { + match key.code { + 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 } }, - Err(e) => { - log::error!("failed to fetch entry {id}: {e}"); - let _ = Notification::new() - .summary("Stash") - .body(&format!("Failed to fetch entry: {e}")) - .show(); + 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 + } }, - } - tui.copying_entry = None; - } + None => 0, + }; + state.select(Some(i)); + }, + _ => {}, } } - - // Redraw once after processing all accumulated input. - draw_frame( - &mut terminal, - &mut tui, - &mut list_state, - max_id_width, - max_mime_width, - )?; } } Ok(()) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 86b8c99..67e9950 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,3 +5,4 @@ pub mod list; pub mod query; pub mod store; pub mod watch; +pub mod wipe; diff --git a/src/commands/query.rs b/src/commands/query.rs index e1bd465..c5b5851 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 4495754..6ddfb60 100644 --- a/src/commands/store.rs +++ b/src/commands/store.rs @@ -2,7 +2,6 @@ use std::io::Read; use crate::db::{ClipboardDb, SqliteClipboardDb}; -#[allow(clippy::too_many_arguments)] pub trait StoreCommand { fn store( &self, @@ -10,9 +9,6 @@ pub trait StoreCommand { max_dedupe_search: u64, max_items: u64, state: Option, - excluded_apps: &[String], - min_size: Option, - max_size: usize, ) -> Result<(), crate::db::StashError>; } @@ -23,25 +19,13 @@ impl StoreCommand for SqliteClipboardDb { max_dedupe_search: u64, 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"); + self.store_entry(input, max_dedupe_search, max_items)?; + log::info!("Entry stored"); } Ok(()) } diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 71cdc17..01e922e 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -1,712 +1,80 @@ -use std::{collections::BinaryHeap, hash::Hasher, io::Read, time::Duration}; +use std::{io::Read, time::Duration}; use smol::Timer; -use wl_clipboard_rs::{ - copy::{MimeType as CopyMimeType, Options, Source}, - paste::{ - ClipboardType, - MimeType as PasteMimeType, - Seat, - get_contents, - get_mime_types_ordered, - }, -}; +use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents}; -use crate::{ - clipboard::{self, ClipboardData, get_serving_pid}, - db::{SqliteClipboardDb, nonblocking::AsyncClipboardDb}, - hash::Fnv1aHasher, -}; +use crate::db::{ClipboardDb, Entry, SqliteClipboardDb}; -/// Wrapper to provide [`Ord`] implementation for `f64` by negating values. -/// This allows [`BinaryHeap`], which is a max-heap, to function as a min-heap. -/// Also see: -/// - -/// - -/// - -#[derive(Debug, Clone, Copy)] -struct Neg(f64); - -impl Neg { - fn inner(&self) -> f64 { - self.0 - } -} - -impl std::cmp::PartialEq for Neg { - fn eq(&self, other: &Self) -> bool { - self.0 == other.0 - } -} - -impl std::cmp::Eq for Neg {} - -impl std::cmp::PartialOrd for Neg { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl std::cmp::Ord for Neg { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - // Reverse ordering for min-heap behavior - other - .0 - .partial_cmp(&self.0) - .unwrap_or(std::cmp::Ordering::Equal) - } -} - -/// Min-heap for tracking entry expirations with sub-second precision. -/// Uses Neg wrapper to turn `BinaryHeap` (max-heap) into min-heap behavior. -#[derive(Debug, Default)] -struct ExpirationQueue { - heap: BinaryHeap<(Neg, i64)>, -} - -impl ExpirationQueue { - /// Create a new empty expiration queue - fn new() -> Self { - Self { - heap: BinaryHeap::new(), - } - } - - /// Push a new expiration into the queue - fn push(&mut self, expires_at: f64, id: i64) { - self.heap.push((Neg(expires_at), id)); - } - - /// Peek at the next expiration timestamp without removing it - fn peek_next(&self) -> Option { - self.heap.peek().map(|(neg, _)| neg.inner()) - } - - /// Remove and return all entries that have expired by `now` - fn pop_expired(&mut self, now: f64) -> Vec { - let mut expired = Vec::new(); - while let Some((neg_exp, id)) = self.heap.peek() { - let expires_at = neg_exp.inner(); - if expires_at <= now { - expired.push(*id); - self.heap.pop(); - } else { - break; - } - } - expired - } - - /// Check if the queue is empty - fn is_empty(&self) -> bool { - self.heap.is_empty() - } - - /// Get the number of entries in the queue - fn len(&self) -> usize { - self.heap.len() - } -} - -/// Get clipboard contents using the source application's preferred MIME type. -/// -/// See, `MimeType::Any` lets wl-clipboard-rs pick a type in arbitrary order, -/// which causes issues when applications offer multiple types (e.g. file -/// managers offering `text/uri-list` + `text/plain`, or Firefox offering -/// `text/html` + `image/png` + `text/plain`). -/// -/// This queries the ordered types via [`get_mime_types_ordered`], which -/// preserves the Wayland protocol's offer order (source application's -/// preference) and then requests the first type with [`MimeType::Specific`]. -/// -/// The two-step approach has a theoretical race (clipboard could change between -/// the calls), but the wl-clipboard-rs API has no single-call variant that -/// respects source ordering. A race simply produces an error that the polling -/// loop handles like any other clipboard-empty/error case. -/// -/// When `preference` is `"text"`, uses `MimeType::Text` directly (single call). -/// When `preference` is `"image"`, picks the first offered `image/*` type. -/// Otherwise picks the source's first offered type. -/// -/// # Returns -/// -/// The content reader, the selected MIME type, and ALL offered MIME -/// types. -#[expect(clippy::type_complexity)] -fn negotiate_mime_type( - preference: &str, -) -> Result<(Box, String, Vec), wl_clipboard_rs::paste::Error> -{ - // Get all offered MIME types first (needed for persistence) - let offered = - get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified)?; - - if preference == "text" { - let (reader, mime_str) = get_contents( - ClipboardType::Regular, - Seat::Unspecified, - PasteMimeType::Text, - )?; - return Ok((Box::new(reader) as Box, mime_str, offered)); - } - - let chosen = if preference == "image" { - // Pick the first offered image type, fall back to first overall - offered - .iter() - .find(|m| m.starts_with("image/")) - .or_else(|| offered.first()) - } else { - // XXX: When preference is "any", deprioritize text/html if a more - // concrete type is available. Browsers and Electron apps put - // text/html first even for "Copy Image", but the HTML is just - // a wrapper (), i.e., never what the user wants in a - // clipboard manager. Prefer image/* first, then any non-html - // type, and fall back to text/html only as a last resort. - let has_image = offered.iter().any(|m| m.starts_with("image/")); - if has_image { - offered - .iter() - .find(|m| m.starts_with("image/")) - .or_else(|| offered.first()) - } else if offered.first().is_some_and(|m| m == "text/html") { - offered - .iter() - .find(|m| *m != "text/html") - .or_else(|| offered.first()) - } else { - offered.first() - } - }; - - match chosen { - Some(mime_str) => { - let (reader, actual_mime) = get_contents( - ClipboardType::Regular, - Seat::Unspecified, - PasteMimeType::Specific(mime_str), - )?; - - Ok((Box::new(reader) as Box, actual_mime, offered)) - }, - None => Err(wl_clipboard_rs::paste::Error::NoSeats), - } -} - -#[allow(clippy::too_many_arguments)] pub trait WatchCommand { - async fn watch( - &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, - ); + fn watch(&self, max_dedupe_search: u64, max_items: u64); } impl WatchCommand for SqliteClipboardDb { - 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, - ) { - let async_db = AsyncClipboardDb::new(self.db_path.clone()); - log::info!( - "Starting clipboard watch daemon with MIME type preference: \ - {mime_type_preference}" - ); + fn watch(&self, max_dedupe_search: u64, max_items: u64) { + smol::block_on(async { + log::info!("Starting clipboard watch daemon"); - if persist { - log::info!("clipboard persistence enabled"); - } + // Preallocate buffer for clipboard contents + let mut last_contents: Option> = None; + let mut buf = Vec::with_capacity(4096); // reasonable default, hopefully - // Build expiration queue from existing entries - let mut exp_queue = ExpirationQueue::new(); - - // Load all expirations from database asynchronously - match async_db.load_all_expirations().await { - Ok(expirations) => { - for (expires_at, id) in expirations { - exp_queue.push(expires_at, id); + // Initialize with current clipboard to avoid duplicating on startup + if let Ok((mut reader, _)) = get_contents( + ClipboardType::Regular, + Seat::Unspecified, + wl_clipboard_rs::paste::MimeType::Any, + ) { + buf.clear(); + if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() { + last_contents = Some(buf.clone()); } - if !exp_queue.is_empty() { - log::info!("loaded {} expirations from database", exp_queue.len()); - } - }, - Err(e) => { - log::warn!("failed to load expirations: {e}"); - }, - } - - // We use hashes for comparison instead of storing full contents - let mut last_hash: Option = None; - let mut buf = Vec::with_capacity(4096); - - // Helper to hash clipboard contents using FNV-1a (deterministic across - // runs) - let hash_contents = |data: &[u8]| -> u64 { - let mut hasher = Fnv1aHasher::new(); - hasher.write(data); - hasher.finish() - }; - - // Initialize with current clipboard using smart MIME negotiation - if let Ok((mut reader, ..)) = negotiate_mime_type(mime_type_preference) { - buf.clear(); - if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() { - last_hash = Some(hash_contents(&buf)); } - } - let poll_interval = Duration::from_millis(500); + loop { + match get_contents( + ClipboardType::Regular, + Seat::Unspecified, + wl_clipboard_rs::paste::MimeType::Any, + ) { + Ok((mut reader, mime_type)) => { + buf.clear(); + if let Err(e) = reader.read_to_end(&mut buf) { + log::error!("Failed to read clipboard contents: {e}"); + Timer::after(Duration::from_millis(500)).await; + continue; + } - loop { - // Process any pending expirations that are due now - if let Some(next_exp) = exp_queue.peek_next() { - let now = SqliteClipboardDb::now(); - if next_exp <= now { - // Expired entries to process - let expired_ids = exp_queue.pop_expired(now); - for id in expired_ids { - // Verify entry still exists and get its content_hash - let expired_hash: Option = - match async_db.get_content_hash(id).await { - Ok(hash) => hash, - Err(e) => { - log::warn!("failed to get content hash for entry {id}: {e}"); - None - }, + // Only store if changed and not empty + if !buf.is_empty() && (last_contents.as_ref() != Some(&buf)) { + last_contents = Some(std::mem::take(&mut buf)); + let mime = Some(mime_type.to_string()); + let entry = Entry { + contents: last_contents.as_ref().unwrap().clone(), + mime, }; - - 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"); + let id = self.next_sequence(); + match self.store_entry( + &entry.contents[..], + max_dedupe_search, + max_items, + ) { + Ok(_) => log::info!("Stored new clipboard entry (id: {id})"), + Err(e) => log::error!("Failed to store clipboard entry: {e}"), } - // 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}" - ); - } - } - } - } + // Drop clipboard contents after storing + last_contents = None; } - } + }, + Err(e) => { + let error_msg = e.to_string(); + if !error_msg.contains("empty") { + log::error!("Failed to get clipboard contents: {e}"); + } + }, } + Timer::after(Duration::from_millis(500)).await; } - - // 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 new file mode 100644 index 0000000..c0bb9ee --- /dev/null +++ b/src/commands/wipe.rs @@ -0,0 +1,13 @@ +use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; + +pub trait WipeCommand { + fn wipe(&self) -> Result<(), StashError>; +} + +impl WipeCommand for SqliteClipboardDb { + fn wipe(&self) -> Result<(), StashError> { + self.wipe_db()?; + log::info!("Database wiped"); + Ok(()) + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 65eb097..efbbd0c 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -3,273 +3,83 @@ use std::{ fmt, fs, io::{BufRead, BufReader, Read, Write}, - path::PathBuf, str, - 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 log::{debug, error, info, warn}; -use mime_sniffer::MimeTypeSniffer; +use base64::{Engine, engine::general_purpose::STANDARD}; +use imagesize::{ImageSize, ImageType}; +use log::{error, info, warn}; use regex::Regex; use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; +use serde_json::json; 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), - #[error("Entry excluded by app filter: {0}")] - ExcludedByApp(Box), + Store(String), #[error("Error reading entry during deduplication: {0}")] - DeduplicationRead(Box), + DeduplicationRead(String), #[error("Error decoding entry during deduplication: {0}")] - DeduplicationDecode(Box), + DeduplicationDecode(String), #[error("Failed to remove entry during deduplication: {0}")] - DeduplicationRemove(Box), + DeduplicationRemove(String), #[error("Failed to trim entry: {0}")] - Trim(Box), + Trim(String), #[error("No entries to delete")] NoEntriesToDelete, #[error("Failed to delete last entry: {0}")] - DeleteLast(Box), + DeleteLast(String), #[error("Failed to wipe database: {0}")] - Wipe(Box), + Wipe(String), #[error("Failed to decode entry during list: {0}")] - ListDecode(Box), + ListDecode(String), #[error("Failed to read input for decode: {0}")] - DecodeRead(Box), + DecodeRead(String), #[error("Failed to extract id for decode: {0}")] - DecodeExtractId(Box), + DecodeExtractId(String), #[error("Failed to get entry for decode: {0}")] - DecodeGet(Box), + DecodeGet(String), #[error("Failed to write decoded entry: {0}")] - DecodeWrite(Box), + DecodeWrite(String), #[error("Failed to delete entry during query delete: {0}")] - QueryDelete(Box), + QueryDelete(String), #[error("Failed to delete entry with id {0}: {1}")] - DeleteEntry(i64, Box), + DeleteEntry(u64, String), } pub trait ClipboardDb { - /// Store a new clipboard entry. - /// - /// # Arguments - /// * `input` - Reader for the clipboard content - /// * `max_dedupe_search` - Maximum number of recent entries to check for - /// duplicates - /// * `max_items` - Maximum total entries to keep in database - /// * `excluded_apps` - List of app names to exclude - /// * `min_size` - Minimum content size (None for no minimum) - /// * `max_size` - Maximum content size - /// * `content_hash` - Optional pre-computed content hash (avoids re-hashing) - /// * `mime_types` - Optional list of all MIME types offered (for persistence) - #[allow(clippy::too_many_arguments)] fn store_entry( &self, input: impl Read, max_dedupe_search: u64, max_items: u64, - excluded_apps: Option<&[String]>, - min_size: Option, - max_size: usize, - content_hash: Option, - mime_types: Option<&[String]>, - ) -> Result; - - fn deduplicate_by_hash( - &self, - content_hash: i64, - max: u64, - ) -> Result; - fn trim_db(&self, max_items: u64) -> Result<(), StashError>; + ) -> Result; + fn deduplicate(&self, buf: &[u8], max: u64) -> Result; + fn trim_db(&self, max: u64) -> Result<(), StashError>; fn delete_last(&self) -> Result<(), StashError>; fn wipe_db(&self) -> Result<(), StashError>; fn list_entries( &self, out: impl Write, preview_width: u32, - include_expired: bool, - reverse: bool, ) -> Result; fn decode_entry( &self, - input: impl Read, + in_: impl Read, out: impl Write, - id_hint: Option, + input: Option, ) -> Result<(), StashError>; fn delete_query(&self, query: &str) -> Result; - fn delete_entries(&self, input: impl Read) -> Result; - fn copy_entry( - &self, - id: i64, - ) -> Result<(i64, Vec, Option), StashError>; + fn delete_entries(&self, in_: impl Read) -> Result; + fn next_sequence(&self) -> u64; } #[derive(Serialize, Deserialize)] @@ -286,322 +96,56 @@ impl fmt::Display for Entry { } pub struct SqliteClipboardDb { - pub conn: Connection, - pub db_path: PathBuf, + pub conn: Connection, } impl SqliteClipboardDb { - pub fn new( - mut conn: Connection, - db_path: PathBuf, - ) -> Result { + pub fn new(conn: Connection) -> Result { conn - .pragma_update(None, "synchronous", "OFF") - .map_err(|e| { - StashError::Store( - format!("Failed to set synchronous pragma: {e}").into(), - ) - })?; - conn - .pragma_update(None, "journal_mode", "MEMORY") - .map_err(|e| { - StashError::Store( - format!("Failed to set journal_mode pragma: {e}").into(), - ) - })?; - conn.pragma_update(None, "cache_size", "-256") // 256KB cache - .map_err(|e| StashError::Store(format!("Failed to set cache_size pragma: {e}").into()))?; - conn - .pragma_update(None, "temp_store", "memory") - .map_err(|e| { - StashError::Store( - format!("Failed to set temp_store pragma: {e}").into(), - ) - })?; - conn.pragma_update(None, "mmap_size", "0") // disable mmap - .map_err(|e| StashError::Store(format!("Failed to set mmap_size pragma: {e}").into()))?; - conn.pragma_update(None, "page_size", "512") // small(er) pages - .map_err(|e| StashError::Store(format!("Failed to set page_size pragma: {e}").into()))?; - - 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( + .execute_batch( "CREATE TABLE IF NOT EXISTS clipboard ( id INTEGER PRIMARY KEY AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT );", ) - .map_err(|e| { - StashError::Store( - format!("Failed to create clipboard table: {e}").into(), - ) - })?; - - tx.execute("PRAGMA user_version = 1", []).map_err(|e| { - StashError::Store(format!("Failed to set schema version: {e}").into()) - })?; - } - - // Add content_hash column if it doesn't exist. Migration MUST be done to - // avoid breaking existing installations. - 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, db_path }) + .map_err(|e| StashError::Store(e.to_string()))?; + Ok(Self { conn }) } } impl SqliteClipboardDb { - pub fn list_json( - &self, - include_expired: bool, - reverse: bool, - ) -> Result { - let builder = ListQueryBuilder::new(include_expired, reverse); - let query = builder.select_star_query(); + pub fn list_json(&self) -> Result { let mut stmt = self .conn - .prepare(&query) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") + .map_err(|e| StashError::ListDecode(e.to_string()))?; let mut rows = stmt .query([]) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + .map_err(|e| StashError::ListDecode(e.to_string()))?; let mut entries = Vec::new(); while let Some(row) = rows .next() - .map_err(|e| StashError::ListDecode(e.to_string().into()))? + .map_err(|e| StashError::ListDecode(e.to_string()))? { - let id: i64 = row + let id: u64 = row .get(0) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + .map_err(|e| StashError::ListDecode(e.to_string()))?; let contents: Vec = row .get(1) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + .map_err(|e| StashError::ListDecode(e.to_string()))?; let mime: Option = row .get(2) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - + .map_err(|e| StashError::ListDecode(e.to_string()))?; let contents_str = match mime.as_deref() { Some(m) if m.starts_with("text/") || m == "application/json" => { - String::from_utf8_lossy(&contents).into_owned() + String::from_utf8_lossy(&contents).to_string() }, - _ => base64::prelude::BASE64_STANDARD.encode(&contents), + _ => STANDARD.encode(&contents), }; - entries.push(serde_json::json!({ + entries.push(json!({ "id": id, "contents": contents_str, "mime": mime, @@ -609,7 +153,7 @@ impl SqliteClipboardDb { } serde_json::to_string_pretty(&entries) - .map_err(|e| StashError::ListDecode(e.to_string().into())) + .map_err(|e| StashError::ListDecode(e.to_string())) } } @@ -619,148 +163,95 @@ impl ClipboardDb for SqliteClipboardDb { mut input: impl Read, max_dedupe_search: u64, max_items: u64, - excluded_apps: Option<&[String]>, - min_size: Option, - max_size: usize, - content_hash: Option, - mime_types: Option<&[String]>, - ) -> Result { + ) -> Result { let mut buf = Vec::new(); - if input.read_to_end(&mut buf).is_err() || buf.is_empty() { + if input.read_to_end(&mut buf).is_err() + || buf.is_empty() + || buf.len() > 5 * 1_000_000 + { return Err(StashError::EmptyOrTooLarge); } - - let size = buf.len(); - - if let Some(min) = min_size - && size < min - { - return Err(StashError::TooSmall(min)); - } - - if size > max_size { - return Err(StashError::TooLarge(max_size)); - } - if buf.iter().all(u8::is_ascii_whitespace) { return Err(StashError::AllWhitespace); } - // Use pre-computed hash if provided, otherwise calculate it - let content_hash = content_hash.unwrap_or_else(|| { - let mut hasher = Fnv1aHasher::new(); - hasher.write(&buf); - #[allow(clippy::cast_possible_wrap)] - let hash = hasher.finish() as i64; - hash - }); - - let mime = crate::mime::detect_mime(&buf); + let mime = match detect_mime(&buf) { + None => { + // If valid UTF-8, treat as text/plain + if std::str::from_utf8(&buf).is_ok() { + Some("text/plain".to_string()) + } else { + None + } + }, + other => other, + }; // 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) - && 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) { + if re.is_match(s) { + warn!("Clipboard entry matches sensitive regex, skipping store."); + return Err(StashError::Store( + "Filtered by sensitive regex".to_string(), + )); + } } } - // Check if clipboard should be excluded based on running apps - if should_exclude_by_app(excluded_apps) { - warn!("Clipboard entry excluded by app filter"); - return Err(StashError::ExcludedByApp( - "Clipboard entry from excluded app".into(), - )); - } - - 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.deduplicate(&buf, max_dedupe_search)?; self .conn .execute( - "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 - ], + "INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)", + params![buf, mime], ) - .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()))?; + .map_err(|e| StashError::Store(e.to_string()))?; self.trim_db(max_items)?; - Ok(id) + Ok(self.next_sequence()) } - fn deduplicate_by_hash( - &self, - content_hash: i64, - max: u64, - ) -> Result { + fn deduplicate(&self, buf: &[u8], max: u64) -> Result { let mut stmt = self .conn - .prepare( - "SELECT id FROM clipboard WHERE content_hash = ?1 ORDER BY id DESC \ - LIMIT ?2", - ) - .map_err(|e| StashError::DeduplicationRead(e.to_string().into()))?; + .prepare("SELECT id, contents FROM clipboard ORDER BY id DESC LIMIT ?1") + .map_err(|e| StashError::DeduplicationRead(e.to_string()))?; let mut rows = stmt - .query(params![ - content_hash, - i64::try_from(max).unwrap_or(i64::MAX) - ]) - .map_err(|e| StashError::DeduplicationRead(e.to_string().into()))?; + .query(params![i64::try_from(max).unwrap_or(i64::MAX)]) + .map_err(|e| StashError::DeduplicationRead(e.to_string()))?; let mut deduped = 0; while let Some(row) = rows .next() - .map_err(|e| StashError::DeduplicationRead(e.to_string().into()))? + .map_err(|e| StashError::DeduplicationRead(e.to_string()))? { - let id: i64 = row + let id: u64 = row .get(0) - .map_err(|e| StashError::DeduplicationDecode(e.to_string().into()))?; - self - .conn - .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) - .map_err(|e| StashError::DeduplicationRemove(e.to_string().into()))?; - deduped += 1; + .map_err(|e| StashError::DeduplicationDecode(e.to_string()))?; + let contents: Vec = row + .get(1) + .map_err(|e| StashError::DeduplicationDecode(e.to_string()))?; + if contents == buf { + self + .conn + .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) + .map_err(|e| StashError::DeduplicationRemove(e.to_string()))?; + deduped += 1; + } } Ok(deduped) } fn trim_db(&self, max: u64) -> Result<(), StashError> { - let count: i64 = self + let count: u64 = self .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) - .map_err(|e| StashError::Trim(e.to_string().into()))?; - 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)] + .map_err(|e| StashError::Trim(e.to_string()))?; + if count > max { + let to_delete = count - max; self .conn .execute( @@ -768,13 +259,13 @@ impl ClipboardDb for SqliteClipboardDb { BY id ASC LIMIT ?1)", params![i64::try_from(to_delete).unwrap_or(i64::MAX)], ) - .map_err(|e| StashError::Trim(e.to_string().into()))?; + .map_err(|e| StashError::Trim(e.to_string()))?; } Ok(()) } 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", @@ -782,12 +273,12 @@ impl ClipboardDb for SqliteClipboardDb { |row| row.get(0), ) .optional() - .map_err(|e| StashError::DeleteLast(e.to_string().into()))?; + .map_err(|e| StashError::DeleteLast(e.to_string()))?; if let Some(id) = id { self .conn .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) - .map_err(|e| StashError::DeleteLast(e.to_string().into()))?; + .map_err(|e| StashError::DeleteLast(e.to_string()))?; Ok(()) } else { Err(StashError::NoEntriesToDelete) @@ -798,11 +289,11 @@ impl ClipboardDb for SqliteClipboardDb { self .conn .execute("DELETE FROM clipboard", []) - .map_err(|e| StashError::Wipe(e.to_string().into()))?; + .map_err(|e| StashError::Wipe(e.to_string()))?; self .conn .execute("DELETE FROM sqlite_sequence WHERE name = 'clipboard'", []) - .map_err(|e| StashError::Wipe(e.to_string().into()))?; + .map_err(|e| StashError::Wipe(e.to_string()))?; Ok(()) } @@ -810,34 +301,28 @@ impl ClipboardDb for SqliteClipboardDb { &self, mut out: impl Write, preview_width: u32, - include_expired: bool, - reverse: bool, ) -> Result { - let builder = ListQueryBuilder::new(include_expired, reverse); - let query = builder.select_star_query(); let mut stmt = self .conn - .prepare(&query) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") + .map_err(|e| StashError::ListDecode(e.to_string()))?; let mut rows = stmt .query([]) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + .map_err(|e| StashError::ListDecode(e.to_string()))?; let mut listed = 0; - while let Some(row) = rows .next() - .map_err(|e| StashError::ListDecode(e.to_string().into()))? + .map_err(|e| StashError::ListDecode(e.to_string()))? { - let id: i64 = row + let id: u64 = row .get(0) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + .map_err(|e| StashError::ListDecode(e.to_string()))?; let contents: Vec = row .get(1) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + .map_err(|e| StashError::ListDecode(e.to_string()))?; let mime: Option = row .get(2) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - + .map_err(|e| StashError::ListDecode(e.to_string()))?; let preview = preview_entry(&contents, mime.as_deref(), preview_width); if writeln!(out, "{id}\t{preview}").is_ok() { listed += 1; @@ -848,22 +333,21 @@ impl ClipboardDb for SqliteClipboardDb { fn decode_entry( &self, - input: impl Read, + mut in_: impl Read, mut out: impl Write, - id_hint: Option, + input: Option, ) -> Result<(), StashError> { - let input_str = if let Some(s) = id_hint { - s - } else { - let mut input = BufReader::new(input); - let mut buf = String::new(); + let s = if let Some(input) = input { input + } else { + let mut buf = String::new(); + in_ .read_to_string(&mut buf) - .map_err(|e| StashError::DecodeExtractId(e.to_string().into()))?; + .map_err(|e| StashError::DecodeRead(e.to_string()))?; buf }; - let id: i64 = extract_id(&input_str) - .map_err(|e| StashError::DecodeExtractId(e.into()))?; + let id = + extract_id(&s).map_err(|e| StashError::DecodeExtractId(e.to_string()))?; let (contents, _mime): (Vec, Option) = self .conn .query_row( @@ -871,11 +355,11 @@ impl ClipboardDb for SqliteClipboardDb { params![id], |row| Ok((row.get(0)?, row.get(1)?)), ) - .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; + .map_err(|e| StashError::DecodeGet(e.to_string()))?; out .write_all(&contents) - .map_err(|e| StashError::DecodeWrite(e.to_string().into()))?; - log::info!("decoded entry with id {id}"); + .map_err(|e| StashError::DecodeWrite(e.to_string()))?; + info!("Decoded entry with id {id}"); Ok(()) } @@ -883,26 +367,26 @@ impl ClipboardDb for SqliteClipboardDb { let mut stmt = self .conn .prepare("SELECT id, contents FROM clipboard") - .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; + .map_err(|e| StashError::QueryDelete(e.to_string()))?; let mut rows = stmt .query([]) - .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; + .map_err(|e| StashError::QueryDelete(e.to_string()))?; let mut deleted = 0; while let Some(row) = rows .next() - .map_err(|e| StashError::QueryDelete(e.to_string().into()))? + .map_err(|e| StashError::QueryDelete(e.to_string()))? { - let id: i64 = row + let id: u64 = row .get(0) - .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; + .map_err(|e| StashError::QueryDelete(e.to_string()))?; let contents: Vec = row .get(1) - .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; + .map_err(|e| StashError::QueryDelete(e.to_string()))?; if contents.windows(query.len()).any(|w| w == query.as_bytes()) { self .conn .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) - .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; + .map_err(|e| StashError::QueryDelete(e.to_string()))?; deleted += 1; } } @@ -917,321 +401,128 @@ impl ClipboardDb for SqliteClipboardDb { self .conn .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) - .map_err(|e| StashError::DeleteEntry(id, e.to_string().into()))?; + .map_err(|e| StashError::DeleteEntry(id, e.to_string()))?; deleted += 1; } } Ok(deleted) } - fn copy_entry( - &self, - id: i64, - ) -> Result<(i64, Vec, Option), StashError> { - let (contents, mime, content_hash): (Vec, Option, Option) = - self - .conn - .query_row( - "SELECT contents, mime, content_hash FROM clipboard WHERE id = ?1", - params![id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; - - if let Some(hash) = content_hash { - let most_recent_id: Option = self - .conn - .query_row( - "SELECT id FROM clipboard WHERE content_hash = ?1 AND last_accessed \ - = (SELECT MAX(last_accessed) FROM clipboard WHERE content_hash = \ - ?1)", - params![hash], - |row| row.get(0), - ) - .optional() - .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; - - if most_recent_id != Some(id) { - self - .conn - .execute( - "UPDATE clipboard SET last_accessed = CAST(strftime('%s', 'now') \ - AS INTEGER) WHERE id = ?1", - params![id], - ) - .map_err(|e| StashError::Store(e.to_string().into()))?; - } + 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, } - - 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" - )) - } -} +// Helper functions /// Try to load a sensitive regex from systemd credential or env. /// /// # Returns -/// /// `Some(Regex)` if present and valid, `None` otherwise. -/// -/// # Note -/// -/// This function checks environment variables on every call to pick up -/// changes made after daemon startup. Regex compilation is cached by -/// pattern to avoid recompilation. fn load_sensitive_regex() -> Option { - // Get the current pattern from env vars - let pattern = if let Ok(regex_path) = env::var("CREDENTIALS_DIRECTORY") { + 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() - }?; - - // 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())); - - // Check cache first - if let Ok(cache) = cache.lock() - && let Some(regex) = cache.get(&pattern) - { - return Some(regex.clone()); + if let Ok(contents) = fs::read_to_string(&file) { + if let Ok(re) = Regex::new(contents.trim()) { + return Some(re); + } + } } - // Compile and cache - Regex::new(&pattern).ok().inspect(|regex| { - if let Ok(mut cache) = cache.lock() { - cache.insert(pattern.clone(), regex.clone()); + // Fallback to an environment variable + if let Ok(pattern) = env::var("STASH_SENSITIVE_REGEX") { + if let Ok(re) = Regex::new(&pattern) { + return Some(re); } - }) + } + + None } -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(data: &[u8]) -> Option { + if let Ok(img_type) = imagesize::image_type(data) { + Some( + match img_type { + ImageType::Png => "image/png", + ImageType::Jpeg => "image/jpeg", + ImageType::Gif => "image/gif", + ImageType::Bmp => "image/bmp", + ImageType::Tiff => "image/tiff", + ImageType::Webp => "image/webp", + ImageType::Aseprite => "image/x-aseprite", + ImageType::Dds => "image/vnd.ms-dds", + ImageType::Exr => "image/aces", + ImageType::Farbfeld => "image/farbfeld", + ImageType::Hdr => "image/vnd.radiance", + ImageType::Ico => "image/x-icon", + ImageType::Ilbm => "image/ilbm", + ImageType::Jxl => "image/jxl", + ImageType::Ktx2 => "image/ktx2", + ImageType::Pnm => "image/x-portable-anymap", + ImageType::Psd => "image/vnd.adobe.photoshop", + ImageType::Qoi => "image/qoi", + ImageType::Tga => "image/x-tga", + ImageType::Vtf => "image/x-vtf", + ImageType::Heif(_) => "image/heif", + _ => "application/octet-stream", + } + .to_string(), + ) + } else { + None + } +} + pub fn preview_entry(data: &[u8], mime: Option<&str>, width: u32) -> String { if let Some(mime) = mime { if mime.starts_with("image/") { - return format!("[[ binary data {} {} ]]", size_str(data.len()), mime); - } else if mime == "application/json" || mime.starts_with("text/") { - let Ok(s) = str::from_utf8(data) else { - return format!("[[ invalid UTF-8 {} ]]", size_str(data.len())); - }; - - let trimmed = s.trim(); - if trimmed.len() <= width as usize - && !trimmed.chars().any(|c| c.is_whitespace() && c != ' ') + if let Ok(ImageSize { + width: img_width, + height: img_height, + }) = imagesize::blob_size(data) { - return trimmed.to_string(); + return format!( + "[[ binary data {} {} {}x{} ]]", + size_str(data.len()), + mime, + img_width, + img_height + ); } - - // Only allocate new string if we need to replace whitespace - let mut result = String::with_capacity(width as usize + 1); - for (char_count, c) in trimmed.chars().enumerate() { - if char_count >= width as usize { - result.push('…'); - break; - } - - if c.is_whitespace() { - result.push(' '); - } else { - result.push(c); - } - } - return result; + } else if mime == "application/json" || mime.starts_with("text/") { + let s = match str::from_utf8(data) { + Ok(s) => s, + Err(e) => { + error!("Failed to decode UTF-8 clipboard data: {e}"); + "" + }, + }; + let s = s.trim().replace(|c: char| c.is_whitespace(), " "); + return truncate(&s, width as usize, "…"); } } + let s = String::from_utf8_lossy(data); + truncate(s.trim(), width as usize, "…") +} - // 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); +pub fn truncate(s: &str, max: usize, ellip: &str) -> String { + if s.chars().count() > max { + s.chars().take(max).collect::() + ellip + } else { + s.to_string() } - - // 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 { @@ -1249,993 +540,3 @@ pub fn size_str(size: usize) -> String { } format!("{:.0} {}", fsize, units[i]) } - -/// Check if clipboard should be excluded based on excluded apps configuration. -/// Uses timing correlation and focused window detection to identify source app. -fn should_exclude_by_app(excluded_apps: Option<&[String]>) -> bool { - let excluded = match excluded_apps { - Some(apps) if !apps.is_empty() => apps, - _ => return false, - }; - - // Try multiple detection strategies - if detect_excluded_app_activity(excluded) { - return true; - } - - false -} - -/// Detect if clipboard likely came from an excluded app using multiple -/// strategies. -fn detect_excluded_app_activity(excluded_apps: &[String]) -> bool { - debug!("Checking clipboard exclusion against: {excluded_apps:?}"); - - // Strategy 1: Check focused window (compositor-dependent) - if let Some(focused_app) = get_focused_window_app() { - debug!("Focused window detected: {focused_app}"); - if app_matches_exclusion(&focused_app, excluded_apps) { - debug!("Clipboard excluded: focused window matches {focused_app}"); - return true; - } - } else { - debug!("No focused window detected"); - } - - // Strategy 2: Check recently active processes (timing correlation) - // 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; - } - debug!("No recently active excluded apps found"); - - debug!("Clipboard not excluded"); - false -} - -/// Try to get the currently focused window application name. -fn get_focused_window_app() -> Option { - // Try Wayland protocol first - #[cfg(feature = "use-toplevel")] - if let Some(app) = crate::wayland::get_focused_window_app() { - return Some(app); - } - - // Fallback: Check WAYLAND_CLIENT_NAME environment variable - if let Ok(client) = env::var("WAYLAND_CLIENT_NAME") - && !client.is_empty() - { - debug!("Found WAYLAND_CLIENT_NAME: {client}"); - return Some(client); - } - - debug!("No focused window detection method worked"); - None -} - -/// Check for recently active excluded apps using CPU and I/O activity. -/// 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"); - if !proc_dir.exists() { - return None; - } - - let mut candidates = Vec::new(); - - 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::() - && let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) - { - let process_name = comm.trim(); - - // Check process name against exclusion list - if app_matches_exclusion(process_name, excluded_apps) - && has_recent_activity(pid) - { - candidates - .push((process_name.to_string(), get_process_activity_score(pid))); - } - } - } - } - - // Return the most active excluded app - candidates - .into_iter() - .max_by_key(|(_, score)| *score) - .map(|(name, _)| name) -} - -/// Check if a process has had recent activity (simple heuristic). -fn has_recent_activity(pid: u32) -> bool { - // Check /proc/PID/stat for recent CPU usage - if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { - let fields: Vec<&str> = stat.split_whitespace().collect(); - if fields.len() > 14 { - // Fields 14 and 15 are utime and stime - if let (Ok(utime), Ok(stime)) = - (fields[13].parse::(), fields[14].parse::()) - { - let total_time = utime + stime; - // Simple heuristic: if process has any significant CPU time, consider - // it active - return total_time > 100; // arbitrary threshold - } - } - } - - // Check /proc/PID/io for recent I/O activity - if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) { - for line in io_stats.lines() { - if (line.starts_with("write_bytes:") || line.starts_with("read_bytes:")) - && let Some(value_str) = line.split(':').nth(1) - && let Ok(value) = value_str.trim().parse::() - && value > 1024 * 1024 - { - // 1MB threshold - return true; - } - } - } - - false -} - -/// Get a simple activity score for process prioritization. -fn get_process_activity_score(pid: u32) -> u64 { - let mut score = 0; - - // Add CPU time to score - if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { - let fields: Vec<&str> = stat.split_whitespace().collect(); - if fields.len() > 14 - && let (Ok(utime), Ok(stime)) = - (fields[13].parse::(), fields[14].parse::()) - { - score += utime + stime; - } - } - - // Add I/O activity to score - if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) { - for line in io_stats.lines() { - if (line.starts_with("write_bytes:") || line.starts_with("read_bytes:")) - && let Some(value_str) = line.split(':').nth(1) - && let Ok(value) = value_str.trim().parse::() - { - score += value / 1024; // convert to KB - } - } - } - - score -} - -/// Check if an app name matches any in the exclusion list. -/// Supports basic string matching and simple regex patterns. -fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool { - debug!("Checking if '{app_name}' matches exclusion list: {excluded_apps:?}"); - - for excluded in excluded_apps { - // Basic string matching (case-insensitive) - if app_name.to_lowercase() == excluded.to_lowercase() { - debug!("Matched exact string: {app_name} == {excluded}"); - return true; - } - - // Simple pattern matching for common cases - if excluded.starts_with('^') && excluded.ends_with('$') { - // Exact match pattern like ^AppName$ - let pattern = &excluded[1..excluded.len() - 1]; - if app_name == pattern { - debug!("Matched exact pattern: {app_name} == {pattern}"); - return true; - } - } else if excluded.contains('*') { - // Simple wildcard matching - let pattern = excluded.replace('*', ".*"); - if let Ok(regex) = regex::Regex::new(&pattern) - && regex.is_match(app_name) - { - debug!("Matched wildcard pattern: {app_name} matches {excluded}"); - return true; - } - } - } - - 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 deleted file mode 100644 index d62e0dd..0000000 --- a/src/db/nonblocking.rs +++ /dev/null @@ -1,375 +0,0 @@ -use std::path::PathBuf; - -use rusqlite::OptionalExtension; - -use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; - -/// Async wrapper for database operations that runs blocking operations -/// on a thread pool to avoid blocking the async runtime. Since -/// [`rusqlite::Connection`] is not Send, we store the database path and open a -/// new connection for each operation. -pub struct AsyncClipboardDb { - db_path: PathBuf, -} - -impl AsyncClipboardDb { - pub fn new(db_path: PathBuf) -> Self { - Self { db_path } - } - - #[expect(clippy::too_many_arguments)] - pub async fn store_entry( - &self, - data: Vec, - max_dedupe_search: u64, - max_items: u64, - excluded_apps: Option>, - min_size: Option, - max_size: usize, - content_hash: Option, - mime_types: Option>, - ) -> Result { - let path = self.db_path.clone(); - blocking::unblock(move || { - let db = Self::open_db_internal(&path)?; - db.store_entry( - std::io::Cursor::new(data), - max_dedupe_search, - max_items, - excluded_apps.as_deref(), - min_size, - max_size, - content_hash, - mime_types.as_deref(), - ) - }) - .await - } - - pub async fn set_expiration( - &self, - id: i64, - expires_at: f64, - ) -> Result<(), StashError> { - let path = self.db_path.clone(); - blocking::unblock(move || { - let db = Self::open_db_internal(&path)?; - db.set_expiration(id, expires_at) - }) - .await - } - - pub async fn load_all_expirations( - &self, - ) -> Result, StashError> { - let path = self.db_path.clone(); - blocking::unblock(move || { - let db = Self::open_db_internal(&path)?; - let mut stmt = db - .conn - .prepare( - "SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT NULL \ - AND (is_expired IS NULL OR is_expired = 0) ORDER BY expires_at ASC", - ) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - - let mut rows = stmt - .query([]) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let mut expirations = Vec::new(); - - while let Some(row) = rows - .next() - .map_err(|e| StashError::ListDecode(e.to_string().into()))? - { - let exp = row - .get::<_, f64>(0) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let id = row - .get::<_, i64>(1) - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - expirations.push((exp, id)); - } - Ok(expirations) - }) - .await - } - - pub async fn get_content_hash( - &self, - id: i64, - ) -> Result, StashError> { - let path = self.db_path.clone(); - blocking::unblock(move || { - let db = Self::open_db_internal(&path)?; - let result: Option = db - .conn - .query_row( - "SELECT content_hash FROM clipboard WHERE id = ?1", - [id], - |row| row.get(0), - ) - .optional() - .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - Ok(result) - }) - .await - } - - pub async fn mark_expired(&self, id: i64) -> Result<(), StashError> { - let path = self.db_path.clone(); - blocking::unblock(move || { - let db = Self::open_db_internal(&path)?; - db.conn - .execute("UPDATE clipboard SET is_expired = 1 WHERE id = ?1", [id]) - .map_err(|e| StashError::Store(e.to_string().into()))?; - Ok(()) - }) - .await - } - - fn open_db_internal(path: &PathBuf) -> Result { - let conn = rusqlite::Connection::open(path).map_err(|e| { - StashError::Store(format!("Failed to open database: {e}").into()) - })?; - SqliteClipboardDb::new(conn, path.clone()) - } -} - -impl Clone for AsyncClipboardDb { - fn clone(&self) -> Self { - Self { - db_path: self.db_path.clone(), - } - } -} - -#[cfg(test)] -mod tests { - use std::{collections::HashSet, hash::Hasher}; - - use tempfile::tempdir; - - use super::*; - use crate::hash::Fnv1aHasher; - - fn setup_test_db() -> (AsyncClipboardDb, tempfile::TempDir) { - let temp_dir = tempdir().expect("Failed to create temp dir"); - let db_path = temp_dir.path().join("test.db"); - - // Create initial database - { - let conn = - rusqlite::Connection::open(&db_path).expect("Failed to open database"); - crate::db::SqliteClipboardDb::new(conn, db_path.clone()) - .expect("Failed to create database"); - } - - let async_db = AsyncClipboardDb::new(db_path); - (async_db, temp_dir) - } - - #[test] - fn test_async_store_entry() { - smol::block_on(async { - let (async_db, _temp_dir) = setup_test_db(); - let data = b"async test data"; - - let id = async_db - .store_entry( - data.to_vec(), - 100, - 1000, - None, - None, - 5_000_000, - None, - None, - ) - .await - .expect("Failed to store entry"); - - assert!(id > 0, "Should return positive id"); - - // Verify it was stored by checking content hash - let hash = async_db - .get_content_hash(id) - .await - .expect("Failed to get hash") - .expect("Hash should exist"); - - // Calculate expected hash - let mut hasher = Fnv1aHasher::new(); - hasher.write(data); - let expected_hash = hasher.finish() as i64; - - assert_eq!(hash, expected_hash, "Stored hash should match"); - }); - } - - #[test] - fn test_async_set_expiration_and_load() { - smol::block_on(async { - let (async_db, _temp_dir) = setup_test_db(); - let data = b"expiring entry"; - - let id = async_db - .store_entry( - data.to_vec(), - 100, - 1000, - None, - None, - 5_000_000, - None, - None, - ) - .await - .expect("Failed to store entry"); - - let expires_at = 1234567890.5; - async_db - .set_expiration(id, expires_at) - .await - .expect("Failed to set expiration"); - - // Load all expirations - let expirations = async_db - .load_all_expirations() - .await - .expect("Failed to load expirations"); - - assert_eq!(expirations.len(), 1, "Should have one expiration"); - assert!( - (expirations[0].0 - expires_at).abs() < 0.001, - "Expiration time should match" - ); - assert_eq!(expirations[0].1, id, "Expiration id should match"); - }); - } - - #[test] - fn test_async_mark_expired() { - smol::block_on(async { - let (async_db, _temp_dir) = setup_test_db(); - let data = b"entry to expire"; - - let id = async_db - .store_entry( - data.to_vec(), - 100, - 1000, - None, - None, - 5_000_000, - None, - None, - ) - .await - .expect("Failed to store entry"); - - async_db - .mark_expired(id) - .await - .expect("Failed to mark as expired"); - - // Load expirations, this should be empty since entry is now marked - // expired - let expirations = async_db - .load_all_expirations() - .await - .expect("Failed to load expirations"); - - assert!( - expirations.is_empty(), - "Expired entries should not be loaded" - ); - }); - } - - #[test] - fn test_async_get_content_hash_not_found() { - smol::block_on(async { - let (async_db, _temp_dir) = setup_test_db(); - - let hash = async_db - .get_content_hash(999999) - .await - .expect("Should not fail on non-existent entry"); - - assert!(hash.is_none(), "Hash should be None for non-existent entry"); - }); - } - - #[test] - fn test_async_clone() { - let (async_db, _temp_dir) = setup_test_db(); - let cloned = async_db.clone(); - - smol::block_on(async { - // Both should work independently - let data = b"clone test"; - - let id1 = async_db - .store_entry( - data.to_vec(), - 100, - 1000, - None, - None, - 5_000_000, - None, - None, - ) - .await - .expect("Failed with original"); - - let id2 = cloned - .store_entry( - data.to_vec(), - 100, - 1000, - None, - None, - 5_000_000, - None, - None, - ) - .await - .expect("Failed with clone"); - - assert_ne!(id1, id2, "Should store as separate entries"); - }); - } - - #[test] - fn test_async_concurrent_operations() { - smol::block_on(async { - let (async_db, _temp_dir) = setup_test_db(); - - // Spawn multiple concurrent store operations - let futures: Vec<_> = (0..5) - .map(|i| { - let db = async_db.clone(); - let data = format!("concurrent test {}", i).into_bytes(); - smol::spawn(async move { - db.store_entry(data, 100, 1000, None, None, 5_000_000, None, None) - .await - }) - }) - .collect(); - - let results: Result, _> = futures::future::join_all(futures) - .await - .into_iter() - .collect(); - - let ids = results.expect("All stores should succeed"); - assert_eq!(ids.len(), 5, "Should have 5 entries"); - - // All IDs should be unique - let unique_ids: HashSet<_> = ids.iter().collect(); - assert_eq!(unique_ids.len(), 5, "All IDs should be unique"); - }); - } -} diff --git a/src/hash.rs b/src/hash.rs deleted file mode 100644 index f017a51..0000000 --- a/src/hash.rs +++ /dev/null @@ -1,101 +0,0 @@ -/// FNV-1a hasher for deterministic hashing across process runs. -/// -/// Unlike `std::collections::hash_map::DefaultHasher` (which uses SipHash -/// with a random seed), this produces stable hashes suitable for persistent -/// storage and cross-process comparison. -/// -/// # Example -/// -/// ``` -/// use std::hash::Hasher; -/// -/// use stash::hash::Fnv1aHasher; -/// -/// let mut hasher = Fnv1aHasher::new(); -/// hasher.write(b"hello"); -/// let hash = hasher.finish(); -/// ``` -#[derive(Clone, Copy, Debug)] -pub struct Fnv1aHasher { - state: u64, -} - -impl Fnv1aHasher { - const FNV_OFFSET: u64 = 0xCBF29CE484222325; - const FNV_PRIME: u64 = 0x100000001B3; - - /// Creates a new hasher initialized with the FNV-1a offset basis. - #[must_use] - pub fn new() -> Self { - Self { - state: Self::FNV_OFFSET, - } - } -} - -impl Default for Fnv1aHasher { - fn default() -> Self { - Self::new() - } -} - -impl std::hash::Hasher for Fnv1aHasher { - fn write(&mut self, bytes: &[u8]) { - for byte in bytes { - self.state ^= u64::from(*byte); - self.state = self.state.wrapping_mul(Self::FNV_PRIME); - } - } - - fn finish(&self) -> u64 { - self.state - } -} - -#[cfg(test)] -mod tests { - use std::hash::Hasher; - - use super::*; - - #[test] - fn test_fnv1a_basic() { - let mut hasher = Fnv1aHasher::new(); - hasher.write(b"hello"); - // FNV-1a hash for "hello" (little-endian u64) - assert_eq!(hasher.finish(), 0xA430D84680AABD0B); - } - - #[test] - fn test_fnv1a_empty() { - let hasher = Fnv1aHasher::new(); - // Empty input should return offset basis - assert_eq!(hasher.finish(), Fnv1aHasher::FNV_OFFSET); - } - - #[test] - fn test_fnv1a_deterministic() { - // Same input must produce same hash - let mut h1 = Fnv1aHasher::new(); - let mut h2 = Fnv1aHasher::new(); - h1.write(b"test data"); - h2.write(b"test data"); - assert_eq!(h1.finish(), h2.finish()); - } - - #[test] - fn test_default_trait() { - let h1 = Fnv1aHasher::new(); - let h2 = Fnv1aHasher::default(); - assert_eq!(h1.finish(), h2.finish()); - } - - #[test] - fn test_copy_trait() { - let mut hasher = Fnv1aHasher::new(); - hasher.write(b"data"); - let copied = hasher; - // Both should have same state after copy - assert_eq!(hasher.finish(), copied.finish()); - } -} diff --git a/src/main.rs b/src/main.rs index f006d36..0b40450 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,39 +1,25 @@ -mod clipboard; -mod commands; -mod db; -mod hash; -mod mime; -mod multicall; - use std::{ env, io::{self, IsTerminal}, path::PathBuf, - time::Duration, + process, }; use clap::{CommandFactory, Parser, Subcommand}; -use color_eyre::eyre; -use humantime::parse_duration; use inquire::Confirm; -// While the module is named "wayland", the Wayland module is *strictly* for the -// use-toplevel feature as it requires some low-level wayland crates that are -// not required *by default*. The module is named that way because "toplevel" -// sounded too silly. Stash is strictly a Wayland clipboard manager. -#[cfg(feature = "use-toplevel")] mod wayland; +mod commands; +mod db; -use crate::{ - commands::{ - decode::DecodeCommand, - delete::DeleteCommand, - import::ImportCommand, - list::ListCommand, - query::QueryCommand, - store::StoreCommand, - watch::WatchCommand, - }, - db::{ClipboardDb, DEFAULT_MAX_ENTRY_SIZE}, +use crate::commands::{ + decode::DecodeCommand, + delete::DeleteCommand, + import::ImportCommand, + list::ListCommand, + query::QueryCommand, + store::StoreCommand, + watch::WatchCommand, + wipe::WipeCommand, }; #[derive(Parser)] @@ -49,33 +35,18 @@ struct Cli { /// Number of recent entries to check for duplicates when storing new /// clipboard data. - #[arg(long, default_value_t = 20)] + #[arg(long, default_value_t = 100)] 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, env = "STASH_DB_PATH")] + #[arg(long)] db_path: Option, - /// Application names to exclude from clipboard history - #[cfg(feature = "use-toplevel")] - #[arg(long, value_delimiter = ',', env = "STASH_EXCLUDED_APPS")] - excluded_apps: Vec, - /// Ask for confirmation before destructive operations #[arg(long)] ask: bool, @@ -94,14 +65,6 @@ enum Command { /// Output format: "tsv" (default) or "json" #[arg(long, value_parser = ["tsv", "json"])] format: Option, - - /// Show only expired entries (diagnostic, does not remove them) - #[arg(long)] - expired: bool, - - /// Reverse the order of entries (oldest first instead of newest first) - #[arg(long)] - reverse: bool, }, /// Decode and output clipboard entry by id @@ -123,10 +86,11 @@ enum Command { ask: bool, }, - /// Database management operations - Db { - #[command(subcommand)] - action: DbAction, + /// Wipe all clipboard history + Wipe { + /// Ask for confirmation before wiping + #[arg(long)] + ask: bool, }, /// Import clipboard data from stdin (default: TSV format) @@ -140,40 +104,8 @@ enum Command { ask: bool, }, - /// Start a process to watch clipboard for changes and store automatically. - Watch { - /// Expire new entries after duration (e.g., "3s", "500ms", "1h30m"). - #[arg(long, value_parser = parse_duration)] - expire_after: Option, - - /// MIME type preference for clipboard reading. - #[arg(short = 't', long, default_value = "any")] - mime_type: String, - - /// Persist clipboard contents after the source application closes. - #[arg(long)] - persist: bool, - }, -} - -#[derive(Subcommand)] -enum DbAction { - /// Wipe database entries - Wipe { - /// Only wipe expired entries instead of all entries - #[arg(long)] - expired: bool, - - /// Ask for confirmation before wiping - #[arg(long)] - ask: bool, - }, - - /// Optimize database using VACUUM - Vacuum, - - /// Show database statistics - Stats, + /// Watch clipboard for changes and store automatically + Watch, } fn report_error( @@ -189,126 +121,80 @@ fn report_error( } } -fn confirm(prompt: &str) -> bool { - Confirm::new(prompt) - .with_default(false) - .prompt() - .unwrap_or_else(|e| { - log::error!("confirmation prompt failed: {e}"); - false - }) -} - #[allow(clippy::too_many_lines)] // whatever -fn main() -> eyre::Result<()> { - color_eyre::install()?; - - // 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(()); - } - } - - // Normal CLI handling +fn main() { smol::block_on(async { let cli = Cli::parse(); env_logger::Builder::new() .filter_level(cli.verbosity.into()) .init(); - let db_path = match cli.db_path { - Some(path) => path, - None => { - let cache_dir = dirs::cache_dir().ok_or_else(|| { - eyre::eyre!( - "Could not determine cache directory. Set --db-path or \ - $STASH_DB_PATH explicitly." - ) - })?; - cache_dir.join("stash").join("db") - }, - }; + let db_path = cli.db_path.unwrap_or_else(|| { + dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("stash") + .join("db") + }); if let Some(parent) = db_path.parent() { - std::fs::create_dir_all(parent)?; + 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)?; - let db = db::SqliteClipboardDb::new(conn, db_path)?; + let conn = rusqlite::Connection::open(&db_path).unwrap_or_else(|e| { + log::error!("Failed to open SQLite database: {e}"); + process::exit(1); + }); + + let db = match db::SqliteClipboardDb::new(conn) { + Ok(db) => db, + Err(e) => { + log::error!("Failed to initialize SQLite database: {e}"); + process::exit(1); + }, + }; match cli.command { Some(Command::Store) => { let state = env::var("STASH_CLIPBOARD_STATE").ok(); report_error( - db.store( - io::stdin(), - cli.max_dedupe_search, - cli.max_items, - state, - #[cfg(feature = "use-toplevel")] - &cli.excluded_apps, - #[cfg(not(feature = "use-toplevel"))] - &[], - cli.min_size, - cli.max_size, - ), - "failed to store entry", + db.store(io::stdin(), cli.max_dedupe_search, cli.max_items, state), + "Failed to store entry", ); }, - Some(Command::List { - format, - expired, - reverse, - }) => { + Some(Command::List { format }) => { match format.as_deref() { Some("tsv") => { report_error( - db.list(io::stdout(), cli.preview_width, expired, reverse), - "failed to list entries", + db.list(io::stdout(), cli.preview_width), + "Failed to list entries", ); }, Some("json") => { - match db.list_json(expired, reverse) { + match db.list_json() { Ok(json) => { println!("{json}"); }, 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, expired, reverse), - "failed to list entries in TUI", + db.list_tui(cli.preview_width), + "Failed to list entries in TUI", ); } else { report_error( - db.list(io::stdout(), cli.preview_width, expired, reverse), - "failed to list entries", + db.list(io::stdout(), cli.preview_width), + "Failed to list entries", ); } }, @@ -317,17 +203,20 @@ fn main() -> eyre::Result<()> { Some(Command::Decode { input }) => { report_error( db.decode(io::stdin(), io::stdout(), input), - "failed to decode entry", + "Failed to decode entry", ); }, Some(Command::Delete { arg, r#type, ask }) => { let mut should_proceed = true; if ask { should_proceed = - confirm("Are you sure you want to delete clipboard entries?"); + Confirm::new("Are you sure you want to delete clipboard entries?") + .with_default(false) + .prompt() + .unwrap_or(false); if !should_proceed { - log::info!("aborted by user."); + log::info!("Aborted by user."); } } if should_proceed { @@ -340,13 +229,13 @@ fn main() -> eyre::Result<()> { "Failed to delete entry by id", ); } else { - log::error!("argument is not a valid id"); + log::error!("Argument is not a valid id"); } }, (Some(s), Some("query")) => { report_error( db.query_delete(&s), - "failed to delete entry by query", + "Failed to delete entry by query", ); }, (Some(s), None) => { @@ -354,90 +243,57 @@ fn main() -> eyre::Result<()> { use std::io::Cursor; report_error( db.delete(Cursor::new(format!("{id}\n"))), - "failed to delete entry by id", + "Failed to delete entry by id", ); } else { report_error( db.query_delete(&s), - "failed to delete entry by query", + "Failed to delete entry by query", ); } }, (None, _) => { report_error( db.delete(io::stdin()), - "failed to delete entry from stdin", + "Failed to delete entry from stdin", ); }, (_, Some(_)) => { - log::error!("unknown type for --type. Use \"id\" or \"query\"."); + log::error!("Unknown type for --type. Use \"id\" or \"query\"."); }, } } }, - - Some(Command::Db { action }) => { - match action { - DbAction::Wipe { expired, ask } => { - let mut should_proceed = true; - if ask { - let message = if expired { - "Are you sure you want to wipe all expired clipboard entries?" - } else { - "Are you sure you want to wipe ALL clipboard history?" - }; - should_proceed = confirm(message); - if !should_proceed { - log::info!("db wipe command aborted by user."); - } - } - if should_proceed { - if expired { - match db.cleanup_expired() { - Ok(count) => { - log::info!("wiped {count} expired entries"); - }, - Err(e) => { - log::error!("failed to wipe expired entries: {e}"); - }, - } - } else { - report_error(db.wipe_db(), "failed to wipe database"); - } - } - }, - DbAction::Vacuum => { - match db.vacuum() { - Ok(()) => { - log::info!("database optimized successfully"); - }, - Err(e) => { - log::error!("failed to vacuum database: {e}"); - }, - } - }, - DbAction::Stats => { - match db.stats() { - Ok(stats) => { - println!("{stats}"); - }, - Err(e) => { - log::error!("failed to get database stats: {e}"); - }, - } - }, + Some(Command::Wipe { ask }) => { + let mut should_proceed = true; + if ask { + should_proceed = Confirm::new( + "Are you sure you want to wipe all clipboard history?", + ) + .with_default(false) + .prompt() + .unwrap_or(false); + if !should_proceed { + log::info!("Aborted by user."); + } + } + if should_proceed { + report_error(db.wipe(), "Failed to wipe database"); } }, Some(Command::Import { r#type, ask }) => { let mut should_proceed = true; if ask { - should_proceed = confirm( + should_proceed = Confirm::new( "Are you sure you want to import clipboard data? This may \ overwrite existing entries.", - ); + ) + .with_default(false) + .prompt() + .unwrap_or(false); if !should_proceed { - log::info!("import command aborted by user."); + log::info!("Aborted by user."); } } if should_proceed { @@ -447,41 +303,24 @@ fn main() -> eyre::Result<()> { if let Err(e) = ImportCommand::import_tsv(&db, io::stdin(), cli.max_items) { - log::error!("failed to import TSV: {e}"); + log::error!("Failed to import TSV: {e}"); } }, _ => { - log::error!("unsupported import format: {format}"); + log::error!("Unsupported import format: {format}"); }, } } }, - Some(Command::Watch { - expire_after, - mime_type, - persist, - }) => { - db.watch( - cli.max_dedupe_search, - cli.max_items, - #[cfg(feature = "use-toplevel")] - &cli.excluded_apps, - #[cfg(not(feature = "use-toplevel"))] - &[], - expire_after, - &mime_type, - cli.min_size, - cli.max_size, - persist, - ) - .await; + Some(Command::Watch) => { + db.watch(cli.max_dedupe_search, cli.max_items); }, - None => { - Cli::command().print_help()?; + if let Err(e) = Cli::command().print_help() { + log::error!("Failed to print help: {e}"); + } println!(); }, } - Ok(()) - }) + }); } diff --git a/src/mime.rs b/src/mime.rs deleted file mode 100644 index 3761ab3..0000000 --- a/src/mime.rs +++ /dev/null @@ -1,273 +0,0 @@ -use imagesize::ImageType; - -/// Detect MIME type of clipboard data. We try binary detection first using -/// [`imagesize`] followed by a check for text/uri-list for file manager copies -/// and finally fall back to text/plain for UTF-8 or [`None`] for binary. -pub fn detect_mime(data: &[u8]) -> Option { - if data.is_empty() { - return None; - } - - // Try image detection first - if let Ok(img_type) = imagesize::image_type(data) { - return Some(image_type_to_mime(img_type)); - } - - // Check if it's UTF-8 text - if let Ok(text) = std::str::from_utf8(data) { - let trimmed = text.trim(); - - // Check for text/uri-list format (file paths from file managers) - if is_uri_list(trimmed) { - return Some("text/uri-list".to_string()); - } - - // Default to plain text - return Some("text/plain".to_string()); - } - - // Unknown binary data - None -} - -/// Convert [`imagesize`] [`ImageType`] to MIME type string -fn image_type_to_mime(img_type: ImageType) -> String { - let mime = match img_type { - ImageType::Png => "image/png", - ImageType::Jpeg => "image/jpeg", - ImageType::Gif => "image/gif", - ImageType::Bmp => "image/bmp", - ImageType::Tiff => "image/tiff", - ImageType::Webp => "image/webp", - ImageType::Aseprite => "image/x-aseprite", - ImageType::Dds => "image/vnd.ms-dds", - ImageType::Exr => "image/aces", - ImageType::Farbfeld => "image/farbfeld", - ImageType::Hdr => "image/vnd.radiance", - ImageType::Ico => "image/x-icon", - ImageType::Ilbm => "image/ilbm", - ImageType::Jxl => "image/jxl", - ImageType::Ktx2 => "image/ktx2", - ImageType::Pnm => "image/x-portable-anymap", - ImageType::Psd => "image/vnd.adobe.photoshop", - ImageType::Qoi => "image/qoi", - ImageType::Tga => "image/x-tga", - ImageType::Vtf => "image/x-vtf", - ImageType::Heif(imagesize::Compression::Hevc) => "image/heic", - ImageType::Heif(_) => "image/heif", - _ => "application/octet-stream", - }; - mime.to_string() -} - -/// Check if text is a URI list per RFC 2483. -/// -/// Used when copying files from file managers - they provide file paths -/// as text/uri-list format (`file://` URIs, one per line, `#` for comments). -fn is_uri_list(text: &str) -> bool { - if text.is_empty() { - return false; - } - - // Must start with a URI scheme to even consider it - if !text.starts_with("file://") - && !text.starts_with("http://") - && !text.starts_with("https://") - && !text.starts_with("ftp://") - && !text.starts_with('#') - { - return false; - } - - let lines: Vec<&str> = text.lines().map(str::trim).collect(); - - // Check first non-comment line is a URI - let first_content = - lines.iter().find(|l| !l.is_empty() && !l.starts_with('#')); - - if let Some(line) = first_content { - line.starts_with("file://") - || line.starts_with("http://") - || line.starts_with("https://") - || line.starts_with("ftp://") - } else { - false - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_empty_data() { - assert_eq!(detect_mime(b""), None); - } - - #[test] - fn test_plain_text() { - let data = b"Hello, world!"; - assert_eq!(detect_mime(data), Some("text/plain".to_string())); - } - - #[test] - fn test_uri_list_single_file() { - let data = b"file:///home/user/document.pdf"; - assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); - } - - #[test] - fn test_uri_list_multiple_files() { - let data = b"file:///home/user/file1.txt\nfile:///home/user/file2.txt"; - assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); - } - - #[test] - fn test_uri_list_with_comments() { - let data = b"# Comment\nfile:///home/user/file.txt"; - assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); - } - - #[test] - fn test_uri_list_http() { - let data = b"https://example.com/image.png"; - assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); - } - - #[test] - fn test_not_uri_list() { - let data = b"This is just text with file:// in the middle"; - assert_eq!(detect_mime(data), Some("text/plain".to_string())); - } - - #[test] - fn test_unknown_binary() { - // Binary data that's not UTF-8 and not a known format - let data = b"\x80\x81\x82\x83\x84\x85\x86\x87"; - assert_eq!(detect_mime(data), None); - } - - #[test] - fn test_uri_list_trailing_newline() { - let data = b"file:///foo\n"; - assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); - } - - #[test] - fn test_uri_list_ftp() { - let data = b"ftp://host/path"; - assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); - } - - #[test] - fn test_uri_list_mixed_schemes() { - let data = b"file:///home/user/doc.pdf\nhttps://example.com/file.zip"; - assert_eq!(detect_mime(data), Some("text/uri-list".to_string())); - } - - #[test] - fn test_plain_url_in_text() { - let data = b"visit http://example.com for info"; - assert_eq!(detect_mime(data), Some("text/plain".to_string())); - } - - #[test] - fn test_png_magic_bytes() { - // Real PNG header: 8-byte signature + minimal IHDR chunk - let data: &[u8] = &[ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature - 0x00, 0x00, 0x00, 0x0D, // IHDR chunk length - 0x49, 0x48, 0x44, 0x52, // "IHDR" - 0x00, 0x00, 0x00, 0x01, // width: 1 - 0x00, 0x00, 0x00, 0x01, // height: 1 - 0x08, 0x02, // bit depth: 8, color type: 2 (RGB) - 0x00, 0x00, 0x00, // compression, filter, interlace - 0x90, 0x77, 0x53, 0xDE, // CRC - ]; - assert_eq!(detect_mime(data), Some("image/png".to_string())); - } - - #[test] - fn test_jpeg_magic_bytes() { - // JPEG SOI marker + APP0 (JFIF) marker - let data: &[u8] = &[ - 0xFF, 0xD8, 0xFF, 0xE0, // SOI + APP0 - 0x00, 0x10, // Length - 0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0" - 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, - ]; - assert_eq!(detect_mime(data), Some("image/jpeg".to_string())); - } - - #[test] - fn test_gif_magic_bytes() { - // GIF89a header - let data: &[u8] = &[ - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // "GIF89a" - 0x01, 0x00, 0x01, 0x00, // 1x1 - 0x80, 0x00, 0x00, // GCT flag, bg, aspect - ]; - assert_eq!(detect_mime(data), Some("image/gif".to_string())); - } - - #[test] - fn test_webp_magic_bytes() { - // RIFF....WEBP header - let data: &[u8] = &[ - 0x52, 0x49, 0x46, 0x46, // "RIFF" - 0x24, 0x00, 0x00, 0x00, // file size - 0x57, 0x45, 0x42, 0x50, // "WEBP" - 0x56, 0x50, 0x38, 0x20, // "VP8 " - 0x18, 0x00, 0x00, 0x00, // chunk size - 0x30, 0x01, 0x00, 0x9D, 0x01, 0x2A, // VP8 bitstream - 0x01, 0x00, 0x01, 0x00, // width/height - ]; - assert_eq!(detect_mime(data), Some("image/webp".to_string())); - } - - #[test] - fn test_whitespace_only() { - let data = b" \n\t "; - // Valid UTF-8 text, even if only whitespace. [`detect_mime`] doesn't reject - // it (store_entry rejects it separately). As text it's text/plain. - assert_eq!(detect_mime(data), Some("text/plain".to_string())); - } - - #[test] - fn test_image_type_to_mime_coverage() { - assert_eq!(image_type_to_mime(ImageType::Png), "image/png"); - assert_eq!(image_type_to_mime(ImageType::Jpeg), "image/jpeg"); - assert_eq!(image_type_to_mime(ImageType::Gif), "image/gif"); - assert_eq!(image_type_to_mime(ImageType::Bmp), "image/bmp"); - assert_eq!(image_type_to_mime(ImageType::Tiff), "image/tiff"); - assert_eq!(image_type_to_mime(ImageType::Webp), "image/webp"); - assert_eq!(image_type_to_mime(ImageType::Aseprite), "image/x-aseprite"); - assert_eq!(image_type_to_mime(ImageType::Dds), "image/vnd.ms-dds"); - assert_eq!(image_type_to_mime(ImageType::Exr), "image/aces"); - assert_eq!(image_type_to_mime(ImageType::Farbfeld), "image/farbfeld"); - assert_eq!(image_type_to_mime(ImageType::Hdr), "image/vnd.radiance"); - assert_eq!(image_type_to_mime(ImageType::Ico), "image/x-icon"); - assert_eq!(image_type_to_mime(ImageType::Ilbm), "image/ilbm"); - assert_eq!(image_type_to_mime(ImageType::Jxl), "image/jxl"); - assert_eq!(image_type_to_mime(ImageType::Ktx2), "image/ktx2"); - assert_eq!( - image_type_to_mime(ImageType::Pnm), - "image/x-portable-anymap" - ); - assert_eq!( - image_type_to_mime(ImageType::Psd), - "image/vnd.adobe.photoshop" - ); - assert_eq!(image_type_to_mime(ImageType::Qoi), "image/qoi"); - assert_eq!(image_type_to_mime(ImageType::Tga), "image/x-tga"); - assert_eq!(image_type_to_mime(ImageType::Vtf), "image/x-vtf"); - assert_eq!( - image_type_to_mime(ImageType::Heif(imagesize::Compression::Hevc)), - "image/heic" - ); - assert_eq!( - image_type_to_mime(ImageType::Heif(imagesize::Compression::Av1)), - "image/heif" - ); - } -} diff --git a/src/multicall/mod.rs b/src/multicall/mod.rs deleted file mode 100644 index 5f1c795..0000000 --- a/src/multicall/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -// 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 deleted file mode 100644 index 3794420..0000000 --- a/src/multicall/wl_copy.rs +++ /dev/null @@ -1,296 +0,0 @@ -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 deleted file mode 100644 index 5daa1fd..0000000 --- a/src/multicall/wl_paste.rs +++ /dev/null @@ -1,531 +0,0 @@ -// 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 deleted file mode 100644 index 38f6ff5..0000000 --- a/src/wayland/mod.rs +++ /dev/null @@ -1,179 +0,0 @@ -use std::{ - collections::HashMap, - sync::{Arc, LazyLock, Mutex}, -}; - -use arc_swap::ArcSwapOption; -use log::debug; -use wayland_client::{ - Connection as WaylandConnection, - Dispatch, - Proxy, - QueueHandle, - backend::ObjectId, - protocol::wl_registry, -}; -use wayland_protocols_wlr::foreign_toplevel::v1::client::{ - zwlr_foreign_toplevel_handle_v1::{self, ZwlrForeignToplevelHandleV1}, - zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1}, -}; - -static FOCUSED_APP: ArcSwapOption = ArcSwapOption::const_empty(); -static TOPLEVEL_APPS: LazyLock>> = - LazyLock::new(|| Mutex::new(HashMap::new())); - -/// Initialize Wayland state for window management in a background thread -pub fn init_wayland_state() { - std::thread::spawn(|| { - if let Err(e) = run_wayland_event_loop() { - debug!("Wayland event loop error: {e}"); - } - }); -} - -/// Get the currently focused window application name using Wayland protocols -pub fn get_focused_window_app() -> Option { - // Load the focused app using lock-free arc-swap - let focused = FOCUSED_APP.load(); - if let Some(app) = focused.as_ref() { - debug!("Found focused app via Wayland protocol: {app}"); - return Some(app.to_string()); - } - - debug!("No focused window detection method worked"); - None -} - -/// Run the Wayland event loop -fn run_wayland_event_loop() -> Result<(), Box> { - let conn = match WaylandConnection::connect_to_env() { - Ok(conn) => conn, - Err(e) => { - debug!("Failed to connect to Wayland: {e}"); - return Ok(()); - }, - }; - - let display = conn.display(); - let mut event_queue = conn.new_event_queue(); - let qh = event_queue.handle(); - - let _registry = display.get_registry(&qh, ()); - - loop { - event_queue.blocking_dispatch(&mut AppState)?; - } -} - -struct AppState; - -impl Dispatch for AppState { - fn event( - _state: &mut Self, - registry: &wl_registry::WlRegistry, - event: wl_registry::Event, - _data: &(), - _conn: &WaylandConnection, - qh: &QueueHandle, - ) { - if let wl_registry::Event::Global { - name, - interface, - version: _, - } = event - && interface == "zwlr_foreign_toplevel_manager_v1" - { - let _manager: ZwlrForeignToplevelManagerV1 = - registry.bind(name, 1, qh, ()); - } - } - - fn event_created_child( - _opcode: u16, - qhandle: &QueueHandle, - ) -> std::sync::Arc { - qhandle.make_data::(()) - } -} - -impl Dispatch for AppState { - fn event( - _state: &mut Self, - _manager: &ZwlrForeignToplevelManagerV1, - event: zwlr_foreign_toplevel_manager_v1::Event, - _data: &(), - _conn: &WaylandConnection, - _qh: &QueueHandle, - ) { - if let zwlr_foreign_toplevel_manager_v1::Event::Toplevel { toplevel } = - event - { - // New toplevel created - // We'll track it for focus events - let _: ZwlrForeignToplevelHandleV1 = toplevel; - } - } - - fn event_created_child( - _opcode: u16, - qhandle: &QueueHandle, - ) -> std::sync::Arc { - qhandle.make_data::(()) - } -} - -impl Dispatch for AppState { - fn event( - _state: &mut Self, - handle: &ZwlrForeignToplevelHandleV1, - event: zwlr_foreign_toplevel_handle_v1::Event, - _data: &(), - _conn: &WaylandConnection, - _qh: &QueueHandle, - ) { - let handle_id = handle.id(); - - match event { - zwlr_foreign_toplevel_handle_v1::Event::AppId { app_id } => { - debug!("Toplevel app_id: {app_id}"); - // Store the app_id for this handle - if let Ok(mut apps) = TOPLEVEL_APPS.lock() { - apps.insert(handle_id, app_id); - } - }, - zwlr_foreign_toplevel_handle_v1::Event::State { - state: toplevel_state, - } => { - // Check if this toplevel is activated (focused) - let states: Vec = toplevel_state; - // Check for activated state (value 2 in the enum) - if states.chunks_exact(4).any(|chunk| { - u32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]) == 2 - }) { - debug!("Toplevel activated"); - // Update focused app to the `app_id` of this handle - if let Ok(apps) = TOPLEVEL_APPS.lock() - && let Some(app_id) = apps.get(&handle_id) - { - debug!("Setting focused app to: {app_id}"); - FOCUSED_APP.store(Some(Arc::new(app_id.clone()))); - } - } - }, - zwlr_foreign_toplevel_handle_v1::Event::Closed => { - // Clean up when toplevel is closed - if let Ok(mut apps) = TOPLEVEL_APPS.lock() { - apps.remove(&handle_id); - } - }, - _ => {}, - } - } - - fn event_created_child( - _opcode: u16, - qhandle: &QueueHandle, - ) -> std::sync::Arc { - qhandle.make_data::(()) - } -} diff --git a/contrib/stash.service b/vendor/stash.service similarity index 100% rename from contrib/stash.service rename to vendor/stash.service