mirror of
https://github.com/NotAShelf/stash.git
synced 2026-04-21 01:40:10 +00:00
Compare commits
200 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
3c61cc19f6 |
|||
|
|
cd692ba002 |
||
|
ac7fbe293b |
|||
|
84cf1b46ad |
|||
|
81683ded03 |
|||
|
20504a6e8b |
|||
|
f139bda7b2 |
|||
|
|
32cf1936b6 | ||
|
b0ee7f59a3 |
|||
|
75ca501e29 |
|||
|
5cb6c84f08 |
|||
|
da9bf5ea3e |
|||
|
9702e67599 |
|||
| 77ac70f0d3 | |||
| d643376cd7 | |||
|
a2a609f07d |
|||
|
d9bee33aba |
|||
|
030be21ea5 |
|||
|
fe86356399 |
|||
|
0c57f9b4bd |
|||
|
aabf40ac6e |
|||
|
|
909bb53afa |
||
|
208359dc0c |
|||
|
|
3faadd709f |
||
|
8754921106 |
|||
|
be6cde092a |
|||
|
b1f43bdf7f |
|||
|
373affabee |
|||
|
0865a1f139 |
|||
|
cf5b1e8205 |
|||
|
95bf1766ce |
|||
|
7184c8b682 |
|||
|
ffdc13e8f5 |
|||
|
|
5e0599dc71 |
||
|
181edcefb1 |
|||
|
ebf46de99d |
|||
|
ba2e29d5b7 |
|||
|
3a14860ae1 |
|||
|
02ba05dc95 |
|||
|
469fccbef6 |
|||
|
117e9d11ef |
|||
|
23bf9d4044 |
|||
|
b850a54f7b |
|||
|
88c1f0f158 |
|||
|
0215ebeb6c |
|||
|
ce98b6db09 |
|||
|
4d58cae50d |
|||
|
2e3c73957a |
|||
|
d367728b39 |
|||
|
2edecf4c17 |
|||
|
134da06fd0 |
|||
|
2227ef7e89 |
|||
|
2e086800d0 |
|||
|
cff9f7bbba |
|||
|
23bb89e3ea |
|||
|
9afbe9ceca |
|||
|
3fd48896c1 |
|||
|
b4dd704961 |
|||
|
bb8e882565 |
|||
|
5c8591b2e5 |
|||
|
ff2f272055 |
|||
|
ded38723d4 |
|||
|
e185ecd32a |
|||
|
b00e9b5f3a |
|||
|
5731fb08a5 |
|||
|
2e555ee043 |
|||
|
b070d4d93d |
|||
|
d40b547c07 |
|||
|
f4936e56ff |
|||
|
dd7a55c760 |
|||
|
71fc1ff40f |
|||
|
bb1c5dc50b |
|||
|
441334a250 |
|||
|
4ab9ce4a71 |
|||
|
047445b143 |
|||
|
3d22a271bc |
|||
|
c65073e0d1 |
|||
|
3165543580 |
|||
|
20b6a12461 |
|||
|
dca7cca455 |
|||
|
59423f9ae4 |
|||
|
65a8eebd46 |
|||
|
f2274aa524 |
|||
|
bbfe583423 |
|||
|
1f0312b2f6 |
|||
|
f6818c9e6f |
|||
|
c2182d21dc |
|||
|
8a25a03486 |
|||
|
f838365314 |
|||
|
bb88c89a0f |
|||
|
|
c8ead9a308 |
||
|
a68946d54d |
|||
|
2d8ccf2a4f |
|||
|
96089f364b |
|||
|
61ff65e9e8 |
|||
|
b71801f7df |
|||
|
|
9fc118a924 |
||
|
5a71640e5f |
|||
|
d59ac77b9f |
|||
|
43a3aae496 |
|||
|
c95d9a4567 |
|||
|
78acc38044 |
|||
|
e94d931e67 |
|||
|
955a5d51f8 |
|||
|
7a4f6378e9 |
|||
|
d3911dd81a |
|||
|
b50702480f |
|||
|
a9da424e70 |
|||
|
0a8fda66a0 |
|||
|
a94ef7f5b4 |
|||
|
a59e207e76 |
|||
|
7f6949b001 |
|||
|
c2427c138a |
|||
|
78fa23a764 |
|||
|
6496d3963d |
|||
|
74f9374a4e |
|||
|
f8440926b1 |
|||
|
d8b1ac1f37 |
|||
|
4c0782f80e |
|||
|
514572b804 |
|||
|
dd4a9b5894 |
|||
|
|
3d0810c824 |
||
|
91be1ad241 |
|||
|
0a803a6a40 |
|||
|
|
23d585a34c |
||
|
|
b847460b3c |
||
|
868a4c7fca |
|||
|
4c36496a47 |
|||
|
|
8c95ec6051 |
||
|
|
556e7d2ba1 |
||
|
a70c7d7014 |
|||
|
301a678f56 |
|||
|
a41d72fb6b |
|||
|
d05ad311a9 |
|||
|
acb6657e73 |
|||
|
f40e11195c |
|||
|
e92cdc444d |
|||
|
7857dc2d2d |
|||
|
2bbd8d11c2 |
|||
|
36c183742d |
|||
|
e5204c4a3a |
|||
|
d1e348df9e |
|||
|
b0fbdcf3ea |
|||
|
|
e82f2911d0 |
||
|
ad808c73c7 |
|||
|
|
3e9aa6b2a3 |
||
|
3176c96514 |
|||
|
|
d65d85676f |
||
|
c9b19f1e64 |
|||
|
|
57dcea219d |
||
|
c9a73b462d |
|||
|
ae98cc0b86 |
|||
|
7aa28a871e |
|||
|
|
ef0d05cbad |
||
|
03550b884d |
|||
|
d1a4fe7baa |
|||
|
|
f50e59fb20 |
||
|
|
9c52602f04 |
||
|
56ea445190 |
|||
|
f019b9a8d3 |
|||
|
a23c8aa495 |
|||
|
b7b1ca074c |
|||
|
|
6471080f91 |
||
|
|
3291605463 |
||
|
|
3f09f4d043 |
||
|
|
d8b75f78f2 |
||
|
ea721a6eb2 |
|||
|
df8e58ed75 |
|||
|
b1a220400d |
|||
|
1ed518a3b6 |
|||
|
8423dffdfe |
|||
|
da8f01b286 |
|||
|
4aa6ef94d8 |
|||
|
383731e47c |
|||
|
bafe272a83 |
|||
|
f39937d3ca |
|||
|
6a5cd9b95d |
|||
|
404990f928 |
|||
|
2db9a2904d |
|||
|
47fd5e4964 |
|||
|
7c26947437 |
|||
|
83d45c6414 |
|||
|
7ccaf13bda |
|||
|
|
864b5cb93e |
||
|
b4762f3050 |
|||
|
|
d939d8be01 |
||
|
dd1c3b22da |
|||
|
|
464daf3d71 |
||
|
|
d2996ba521 |
||
|
40f4c1196d |
|||
|
0547376a9e |
|||
|
261b154527 |
|||
|
bbe3a0fd8d |
|||
|
f3089148e0 |
|||
|
0c0547b6e8 |
|||
|
86001652cd |
|||
|
f6bf5586ad |
|||
|
673bcb01be |
|||
|
47b85472fa |
|||
|
989ab7e4c3 |
38 changed files with 10422 additions and 1500 deletions
23
.github/dependabot.yaml
vendored
Normal file
23
.github/dependabot.yaml
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
version: 2
|
||||
updates:
|
||||
# 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
|
||||
29
.github/workflows/nix-cache.yaml
vendored
Normal file
29
.github/workflows/nix-cache.yaml
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
name: Build and Cache with Nix
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths: [ 'src/**.rs', 'build.rs', 'Cargo.toml', 'Cargo.lock', 'nix/package.nix', 'flake.nix', 'flake.lock' ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
populate-cache:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "Checkout"
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- uses: cachix/cachix-action@v17
|
||||
with:
|
||||
name: nyx
|
||||
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
|
||||
|
||||
- name: "Build with Nix"
|
||||
run: nix build
|
||||
33
.github/workflows/release.yaml
vendored
33
.github/workflows/release.yaml
vendored
|
|
@ -9,7 +9,30 @@ 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 }}
|
||||
|
|
@ -17,7 +40,7 @@ jobs:
|
|||
steps:
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
|
@ -39,7 +62,7 @@ jobs:
|
|||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
|
@ -75,7 +98,7 @@ jobs:
|
|||
cp target/${{ matrix.target }}/release/stash ${{ matrix.name }}
|
||||
|
||||
- name: Upload Release Asset
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: ${{ matrix.name }}
|
||||
|
||||
|
|
@ -83,7 +106,7 @@ jobs:
|
|||
needs: [create-release, build-release]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download Assets
|
||||
uses: robinraju/release-downloader@v1
|
||||
|
|
@ -97,7 +120,7 @@ jobs:
|
|||
sha256sum stash-* > SHA256SUMS
|
||||
|
||||
- name: Upload Checksums
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
files: SHA256SUMS
|
||||
|
|
|
|||
15
.github/workflows/rust.yml
vendored
15
.github/workflows/rust.yml
vendored
|
|
@ -1,10 +1,13 @@
|
|||
name: Build with Cargo
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
paths: [ "src/**.rs", "Cargo.toml", "Cargo.lock"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
|
@ -13,6 +16,8 @@ jobs:
|
|||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
|
|
|
|||
37
.gitignore
vendored
37
.gitignore
vendored
|
|
@ -1,3 +1,34 @@
|
|||
target/
|
||||
.direnv/
|
||||
result/
|
||||
# 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
|
||||
|
|
|
|||
26
.rustfmt.toml
Normal file
26
.rustfmt.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
condense_wildcard_suffixes = true
|
||||
doc_comment_code_block_width = 80
|
||||
edition = "2024" # Keep in sync with Cargo.toml.
|
||||
enum_discrim_align_threshold = 60
|
||||
force_explicit_abi = false
|
||||
force_multiline_blocks = true
|
||||
format_code_in_doc_comments = true
|
||||
format_macro_matchers = true
|
||||
format_strings = true
|
||||
group_imports = "StdExternalCrate"
|
||||
hex_literal_case = "Upper"
|
||||
imports_granularity = "Crate"
|
||||
imports_layout = "HorizontalVertical"
|
||||
inline_attribute_width = 60
|
||||
match_block_trailing_comma = true
|
||||
max_width = 80
|
||||
newline_style = "Unix"
|
||||
normalize_comments = true
|
||||
normalize_doc_attributes = true
|
||||
overflow_delimited_expr = true
|
||||
struct_field_align_threshold = 60
|
||||
tab_spaces = 2
|
||||
unstable_features = true
|
||||
use_field_init_shorthand = true
|
||||
use_try_shorthand = true
|
||||
wrap_comments = true
|
||||
13
.taplo.toml
Normal file
13
.taplo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[formatting]
|
||||
align_entries = true
|
||||
column_width = 110
|
||||
compact_arrays = false
|
||||
reorder_inline_tables = false
|
||||
reorder_keys = true
|
||||
|
||||
[[rule]]
|
||||
include = [ "**/Cargo.toml" ]
|
||||
keys = [ "package" ]
|
||||
|
||||
[rule.formatting]
|
||||
reorder_keys = false
|
||||
3104
Cargo.lock
generated
3104
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
76
Cargo.toml
76
Cargo.toml
|
|
@ -1,31 +1,59 @@
|
|||
[package]
|
||||
name = "stash"
|
||||
version = "0.2.3"
|
||||
edition = "2024"
|
||||
authors = ["NotAShelf <raf@notashelf.dev>"]
|
||||
license = "MPL-2.0"
|
||||
readme = true
|
||||
repository = "https://github.com/notashelf/stash"
|
||||
rust-version = "1.85"
|
||||
name = "stash-clipboard"
|
||||
description = "Wayland clipboard manager with fast persistent history and multi-media support"
|
||||
version = "0.3.6"
|
||||
edition = "2024"
|
||||
authors = [ "NotAShelf <raf@notashelf.dev>" ]
|
||||
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"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5.44", features = ["derive"] }
|
||||
dirs = "6.0.0"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
rmp-serde = "1.3.0"
|
||||
imagesize = "0.14"
|
||||
log = "0.4.27"
|
||||
env_logger = "0.11.8"
|
||||
clap-verbosity-flag = "3.0.3"
|
||||
thiserror = "2.0.14"
|
||||
wl-clipboard-rs = "0.9.2"
|
||||
rusqlite = { version = "0.37.0", features = ["bundled"] }
|
||||
smol = "2.0.2"
|
||||
serde_json = "1.0.142"
|
||||
base64 = "0.22.1"
|
||||
arc-swap = { version = "1.9.1", optional = true }
|
||||
base64 = "0.22.1"
|
||||
blocking = "1.6.2"
|
||||
clap = { version = "4.6.0", features = [ "derive", "env" ] }
|
||||
clap-verbosity-flag = "3.0.4"
|
||||
color-eyre = "0.6.5"
|
||||
crossterm = "0.29.0"
|
||||
ctrlc = "3.5.2"
|
||||
dirs = "6.0.0"
|
||||
env_logger = "0.11.10"
|
||||
humantime = "2.3.0"
|
||||
imagesize = "0.14.0"
|
||||
inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] }
|
||||
libc = "0.2.184"
|
||||
log = "0.4.29"
|
||||
mime-sniffer = "0.1.3"
|
||||
notify-rust = { version = "4.14.0", optional = true }
|
||||
ratatui = "0.30.0"
|
||||
regex = "1.12.3"
|
||||
rusqlite = { version = "0.39.0", features = [ "bundled" ] }
|
||||
serde = { version = "1.0.228", features = [ "derive" ] }
|
||||
serde_json = "1.0.149"
|
||||
smol = "2.0.2"
|
||||
thiserror = "2.0.18"
|
||||
unicode-segmentation = "1.13.2"
|
||||
unicode-width = "0.2.2"
|
||||
wayland-client = { version = "0.31.14", features = [ "log" ], optional = true }
|
||||
wayland-protocols-wlr = { version = "0.3.12", default-features = false, optional = true }
|
||||
wl-clipboard-rs = "0.9.3"
|
||||
|
||||
[dev-dependencies]
|
||||
futures = "0.3.32"
|
||||
tempfile = "3.27.0"
|
||||
|
||||
[features]
|
||||
default = [ "notifications", "use-toplevel" ]
|
||||
notifications = [ "dep:notify-rust" ]
|
||||
use-toplevel = [ "dep:arc-swap", "dep:wayland-client", "dep:wayland-protocols-wlr" ]
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
lto = true
|
||||
opt-level = "z"
|
||||
strip = true
|
||||
strip = true
|
||||
|
|
|
|||
328
LICENSE
Normal file
328
LICENSE
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
Mozilla Public License, version 2.0
|
||||
|
||||
1. Definitions
|
||||
|
||||
1.1. “Contributor”
|
||||
means each individual or legal entity that creates, contributes to the
|
||||
creation of, or owns Covered Software.
|
||||
|
||||
1.2. “Contributor Version”
|
||||
means the combination of the Contributions of others (if any) used by a
|
||||
Contributor and that particular Contributor’s Contribution.
|
||||
|
||||
1.3. “Contribution”
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. “Covered Software”
|
||||
means Source Code Form to which the initial Contributor has attached the
|
||||
notice in Exhibit A, the Executable Form of such Source Code Form,
|
||||
and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. “Incompatible With Secondary Licenses”
|
||||
means
|
||||
|
||||
a. that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
b. that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the terms
|
||||
of a Secondary License.
|
||||
|
||||
1.6. “Executable Form”
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. “Larger Work”
|
||||
means a work that combines Covered Software with other material,
|
||||
in a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. “License”
|
||||
means this document.
|
||||
|
||||
1.9. “Licensable”
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently,
|
||||
any and all of the rights conveyed by this License.
|
||||
|
||||
1.10. “Modifications”
|
||||
means any of the following:
|
||||
|
||||
a. any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered Software; or
|
||||
|
||||
b. any new file in Source Code Form that contains any Covered Software.
|
||||
|
||||
1.11. “Patent Claims” of a Contributor
|
||||
means any patent claim(s), including without limitation, method, process,
|
||||
and apparatus claims, in any patent Licensable by such Contributor that
|
||||
would be infringed, but for the grant of the License, by the making,
|
||||
using, selling, offering for sale, having made, import, or transfer of
|
||||
either its Contributions or its Contributor Version.
|
||||
|
||||
1.12. “Secondary License”
|
||||
means either the GNU General Public License, Version 2.0, the
|
||||
GNU Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those licenses.
|
||||
|
||||
1.13. “Source Code Form”
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. “You” (or “Your”)
|
||||
means an individual or a legal entity exercising rights under this License.
|
||||
For legal entities, “You” includes any entity that controls,
|
||||
is controlled by, or is under common control with You. For purposes of
|
||||
this definition, “control” means (a) the power, direct or indirect,
|
||||
to cause the direction or management of such entity, whether by contract
|
||||
or otherwise, or (b) ownership of more than fifty percent (50%) of the
|
||||
outstanding shares or beneficial ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
|
||||
2.1. Grants
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
a. under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications,
|
||||
or as part of a Larger Work; and
|
||||
|
||||
b. under Patent Claims of such Contributor to make, use, sell,
|
||||
offer for sale, have made, import, and otherwise transfer either
|
||||
its Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor
|
||||
first distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
The licenses granted in this Section 2 are the only rights granted
|
||||
under this License. No additional rights or licenses will be implied
|
||||
from the distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted
|
||||
by a Contributor:
|
||||
|
||||
a. for any code that a Contributor has removed from
|
||||
Covered Software; or
|
||||
|
||||
b. for infringements caused by: (i) Your and any other third party’s
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its
|
||||
Contributor Version); or
|
||||
|
||||
c. under Patent Claims infringed by Covered Software in the
|
||||
absence of its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License
|
||||
(if permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing,
|
||||
or other equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the
|
||||
licenses granted in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
All distribution of Covered Software in Source Code Form, including
|
||||
any Modifications that You create or to which You contribute, must be
|
||||
under the terms of this License. You must inform recipients that the
|
||||
Source Code Form of the Covered Software is governed by the terms
|
||||
of this License, and how they can obtain a copy of this License.
|
||||
You may not attempt to alter or restrict the recipients’ rights
|
||||
in the Source Code Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
a. such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more than
|
||||
the cost of distribution to the recipient; and
|
||||
|
||||
b. You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients’ rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of
|
||||
Covered Software with a work governed by one or more Secondary Licenses,
|
||||
and the Covered Software is not Incompatible With Secondary Licenses,
|
||||
this License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the
|
||||
Covered Software under the terms of either this License or such
|
||||
Secondary License(s).
|
||||
|
||||
3.4. Notices
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of
|
||||
Covered Software. However, You may do so only on Your own behalf,
|
||||
and not on behalf of any Contributor. You must make it absolutely clear
|
||||
that any such warranty, support, indemnity, or liability obligation is
|
||||
offered by You alone, and You hereby agree to indemnify every Contributor
|
||||
for any liability incurred by such Contributor as a result of warranty,
|
||||
support, indemnity or liability terms You offer. You may include
|
||||
additional disclaimers of warranty and limitations of liability
|
||||
specific to any jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this License
|
||||
with respect to some or all of the Covered Software due to statute,
|
||||
judicial order, or regulation then You must: (a) comply with the terms of
|
||||
this License to the maximum extent possible; and (b) describe the limitations
|
||||
and the code they affect. Such description must be placed in a text file
|
||||
included with all distributions of the Covered Software under this License.
|
||||
Except to the extent prohibited by statute or regulation, such description
|
||||
must be sufficiently detailed for a recipient of ordinary skill
|
||||
to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means,
|
||||
this is the first time You have received notice of non-compliance with
|
||||
this License from such Contributor, and You become compliant prior to
|
||||
30 days after Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted
|
||||
to You by any and all Contributors for the Covered Software under
|
||||
Section 2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
6. Disclaimer of Warranty
|
||||
|
||||
Covered Software is provided under this License on an “as is” basis, without
|
||||
warranty of any kind, either expressed, implied, or statutory, including,
|
||||
without limitation, warranties that the Covered Software is free of defects,
|
||||
merchantable, fit for a particular purpose or non-infringing. The entire risk
|
||||
as to the quality and performance of the Covered Software is with You.
|
||||
Should any Covered Software prove defective in any respect, You
|
||||
(not any Contributor) assume the cost of any necessary servicing, repair,
|
||||
or correction. This disclaimer of warranty constitutes an essential part of
|
||||
this License. No use of any Covered Software is authorized under this
|
||||
License except under this disclaimer.
|
||||
|
||||
7. Limitation of Liability
|
||||
|
||||
Under no circumstances and under no legal theory, whether tort
|
||||
(including negligence), contract, or otherwise, shall any Contributor, or
|
||||
anyone who distributes Covered Software as permitted above, be liable to
|
||||
You for any direct, indirect, special, incidental, or consequential damages
|
||||
of any character including, without limitation, damages for lost profits,
|
||||
loss of goodwill, work stoppage, computer failure or malfunction, or any and
|
||||
all other commercial damages or losses, even if such party shall have been
|
||||
informed of the possibility of such damages. This limitation of liability
|
||||
shall not apply to liability for death or personal injury resulting from
|
||||
such party’s negligence to the extent applicable law prohibits such
|
||||
limitation. Some jurisdictions do not allow the exclusion or limitation of
|
||||
incidental or consequential damages, so this exclusion and limitation may
|
||||
not apply to You.
|
||||
|
||||
8. Litigation
|
||||
|
||||
Any litigation relating to this License may be brought only in the courts of
|
||||
a jurisdiction where the defendant maintains its principal place of business
|
||||
and such litigation shall be governed by laws of that jurisdiction, without
|
||||
reference to its conflict-of-law provisions. Nothing in this Section shall
|
||||
prevent a party’s ability to bring cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
|
||||
This License represents the complete agreement concerning the subject matter
|
||||
hereof. If any provision of this License is held to be unenforceable,
|
||||
such provision shall be reformed only to the extent necessary to make it
|
||||
enforceable. Any law or regulation which provides that the language of a
|
||||
contract shall be construed against the drafter shall not be used to construe
|
||||
this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
|
||||
10.1. New Versions
|
||||
Mozilla Foundation is the license steward. Except as provided in
|
||||
Section 10.3, no one other than the license steward has the right to
|
||||
modify or publish new versions of this License. Each version will be
|
||||
given a distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published
|
||||
by the license steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a modified
|
||||
version of this License if you rename the license and remove any
|
||||
references to the name of the license steward (except to note that such
|
||||
modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is
|
||||
Incompatible With Secondary Licenses
|
||||
If You choose to distribute Source Code Form that is
|
||||
Incompatible With Secondary Licenses under the terms of this version of
|
||||
the License, the notice described in Exhibit B of this
|
||||
License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
|
||||
This Source Code Form is subject to the terms of the
|
||||
Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed
|
||||
with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular file,
|
||||
then You may include the notice in a location (such as a LICENSE file in a
|
||||
relevant directory) where a recipient would be likely to
|
||||
look for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - “Incompatible With Secondary Licenses” Notice
|
||||
|
||||
This Source Code Form is “Incompatible With Secondary Licenses”,
|
||||
as defined by the Mozilla Public License, v. 2.0.
|
||||
520
README.md
520
README.md
|
|
@ -1,25 +1,197 @@
|
|||
# Stash
|
||||
<!-- markdownlint-disable MD033 -->
|
||||
|
||||
Wayland clipboard "manager" with fast persistent history and multi-media
|
||||
support. Stores and previews clipboard entries (text, images) on the command
|
||||
line.
|
||||
<h1 id="header" align="center">
|
||||
<pre>Stash</pre>
|
||||
</h1>
|
||||
|
||||
<div align="center">
|
||||
<a alt="CI Status" href="https://github.com/NotAShelf/stash/actions">
|
||||
<img
|
||||
src="https://github.com/NotAShelf/stash/actions/workflows/rust.yml/badge.svg"
|
||||
alt="Build Status"
|
||||
/>
|
||||
</a>
|
||||
<a alt="Dependencies" href="https://deps.rs/repo/github/notashelf/stash">
|
||||
<img
|
||||
src="https://deps.rs/repo/github/notashelf/stash/status.svg"
|
||||
alt="Dependency Status"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
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.
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<br/>
|
||||
<a href="#features">Features</a><br/>
|
||||
<a href="#installation">Installation</a> | <a href="#usage">Usage</a> | <a href="#usage">Motivation</a></br>
|
||||
<a href="#tips--tricks">Tips and Tricks</a>
|
||||
<br/>
|
||||
</div>
|
||||
|
||||
## Features
|
||||
|
||||
- Stores clipboard entries with automatic MIME detection
|
||||
Stash is a feature-rich, yet simple and lightweight clipboard management utility
|
||||
with many features such as but not necessarily limited to:
|
||||
|
||||
- Automatic MIME detection for stored entries
|
||||
- Fast persistent storage using SQLite
|
||||
- List, search, decode, delete, and wipe clipboard history
|
||||
- List, search, decode, delete, and wipe clipboard history with ease
|
||||
- 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`)
|
||||
- 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.
|
||||
|
||||
## 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
|
||||
{
|
||||
# Add Stash to your inputs like so
|
||||
inputs.stash.url = "github:NotAShelf/stash";
|
||||
|
||||
outputs = { /* ... */ };
|
||||
}
|
||||
```
|
||||
|
||||
Then you can get the package from your flake input, and add it to your packages
|
||||
to make `stash` available in your system.
|
||||
|
||||
```nix
|
||||
{inputs, pkgs, ...}: let
|
||||
stashPkg = inputs.stash.packages.${pkgs.stdenv.hostPlatform}.stash;
|
||||
in {
|
||||
environment.systemPackages = [stashPkg];
|
||||
|
||||
# Additionally feel free to add the Stash package in `systemd.packages` to
|
||||
# automatically run the Stash watch daemon, which will watch your primary
|
||||
# clipboard for changes and persist them.
|
||||
systemd.packages = [stashPkg];
|
||||
}
|
||||
```
|
||||
|
||||
If you want to give Stash a try before you switch to it, you may also run it one
|
||||
time with `nix run`.
|
||||
|
||||
```sh
|
||||
# Run directly from the git repository; will be garbage collected
|
||||
$ nix run github:NotAShelf/stash -- watch # start the watch daemon
|
||||
```
|
||||
|
||||
### Without Nix
|
||||
|
||||
[GitHub Releases]: https://github.com/notashelf/stash/releases
|
||||
|
||||
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:
|
||||
|
||||
- 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.
|
||||
- Build and install from source with Cargo:
|
||||
|
||||
```bash
|
||||
cargo install stash --locked
|
||||
```
|
||||
|
||||
Additionally, you may get Stash from source via `cargo install` using
|
||||
`cargo install --git https://github.com/notashelf/stash --locked` or you may
|
||||
check out to the repository, and use Cargo to build it. You'll need Rust 1.91.0
|
||||
or above. Most distributions should package this version already. You may, of
|
||||
course, prefer to package the built releases if you'd like.
|
||||
|
||||
## Usage
|
||||
|
||||
Command interface is only slightly different from Cliphist. In most cases, it
|
||||
will be as simple as replacing `cliphist` with `stash` in your commands, aliases
|
||||
or scripts.
|
||||
> [!IMPORTANT]
|
||||
> It is not a priority to provide 1:1 backwards compatibility with Cliphist.
|
||||
> While the interface is generally similar, Stash chooses to build upon
|
||||
> Cliphist's design and extend existing design choices. See
|
||||
> [Migrating from Cliphist](#migrating-from-cliphist) for more details. 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.
|
||||
|
||||
<!-- markdownlint-disable MD013 -->
|
||||
|
||||
```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 <MAX_ITEMS>
|
||||
Maximum number of clipboard entries to keep [default: 18446744073709551615]
|
||||
--max-dedupe-search <MAX_DEDUPE_SEARCH>
|
||||
Number of recent entries to check for duplicates when storing new clipboard data [default: 20]
|
||||
--preview-width <PREVIEW_WIDTH>
|
||||
Maximum width (in characters) for clipboard entry previews in list output [default: 100]
|
||||
--db-path <DB_PATH>
|
||||
Path to the `SQLite` clipboard database file [env: STASH_DB_PATH=]
|
||||
--excluded-apps <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
|
||||
```
|
||||
|
||||
<!-- markdownlint-enable MD013 -->
|
||||
|
||||
### Store an entry
|
||||
|
||||
|
|
@ -33,18 +205,39 @@ echo "some clipboard text" | stash store
|
|||
stash list
|
||||
```
|
||||
|
||||
Stash list will list all entries in an interactive TUI that allows navigation
|
||||
and copying/deleting entries. This behaviour is EXCLUSIVE TO TTYs and Stash will
|
||||
display entries in Cliphist-compatible TSV format in Bash scripts. You may also
|
||||
enforce the output format with `stash list --format <tsv | json>`.
|
||||
|
||||
You may also view your clipboard _with the addition of expired entries_, i.e.,
|
||||
entries that have reached their TTL and are marked as expired, using the
|
||||
`--expired` flag as `stash list --expired`. Expired entries are not cleaned up
|
||||
when using this flag, allowing you to inspect them before running cleanup.
|
||||
|
||||
### Decode an entry by ID
|
||||
|
||||
```bash
|
||||
stash decode --input "1234"
|
||||
stash decode <input ID>
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Decoding from dmenu-compatible tools:
|
||||
>
|
||||
> ```bash
|
||||
> stash list | tofi | stash decode
|
||||
> ```
|
||||
|
||||
### Delete entries matching a query
|
||||
|
||||
```bash
|
||||
stash delete --type query --arg "some text"
|
||||
stash delete --type [id | query] <text or ID>
|
||||
```
|
||||
|
||||
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
|
||||
|
|
@ -53,10 +246,33 @@ stash delete --type id < ids.txt
|
|||
|
||||
### Wipe all entries
|
||||
|
||||
> [!WARNING]
|
||||
> This command is deprecated, and will be removed in v0.4.0. Use `stash db wipe`
|
||||
> instead.
|
||||
|
||||
```bash
|
||||
stash wipe
|
||||
```
|
||||
|
||||
### Database management
|
||||
|
||||
Stash provides a `db` subcommand for database maintenance operations:
|
||||
|
||||
```bash
|
||||
stash db wipe [--expired] [--ask]
|
||||
stash db vacuum
|
||||
stash db stats
|
||||
```
|
||||
|
||||
- `stash db wipe`: Remove all entries from the database. Use `--expired` to only
|
||||
wipe expired entries instead of all entries. Requires `--ask` confirmation by
|
||||
default.
|
||||
- `stash db vacuum`: Optimize the database using SQLite's VACUUM command,
|
||||
reclaiming space and improving performance.
|
||||
- `stash db stats`: Display database statistics including total/active/expired
|
||||
entry counts, storage size, and page information. This is provided purely for
|
||||
convenience and the rule of the cool.
|
||||
|
||||
### Watch clipboard for changes and store automatically
|
||||
|
||||
```bash
|
||||
|
|
@ -64,7 +280,64 @@ stash watch
|
|||
```
|
||||
|
||||
This runs a daemon that monitors the clipboard and stores new entries
|
||||
automatically.
|
||||
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
|
||||
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
|
||||
|
||||
|
|
@ -77,11 +350,90 @@ commands `--help` text for more details. The following are generally standard:
|
|||
- `--preview-width <N>`: Text preview max width for `list`
|
||||
- `--version`: Print the current version and exit
|
||||
|
||||
### 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.
|
||||
|
||||
#### Clipboard Filtering by Entry Regex
|
||||
|
||||
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.
|
||||
|
||||
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 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.
|
||||
|
||||
#### 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.
|
||||
|
||||
## Tips & Tricks
|
||||
|
||||
### Migrating from Cliphist
|
||||
|
||||
Stash is designed to be a drop-in replacement for Cliphist, with only minor
|
||||
Stash was designed to be a drop-in replacement for Cliphist, with only minor
|
||||
improvements. If you are migrating from Cliphist, here are a few things you
|
||||
should know.
|
||||
|
||||
|
|
@ -100,7 +452,7 @@ should know.
|
|||
- Stash adds a `watch` command to automatically store clipboard changes. This is
|
||||
an alternative to `wl-paste --watch cliphist list`. You can avoid shelling out
|
||||
and depending on `wl-paste` as Stash implements it through `wl-clipboard-rs`
|
||||
crate.
|
||||
crate and provides its own `wl-copy` and `wl-paste` binaries.
|
||||
|
||||
### TSV Export and Import
|
||||
|
||||
|
|
@ -117,7 +469,7 @@ cliphist list --db ~/.cache/cliphist/db > cliphist.tsv
|
|||
**Import TSV into Stash:**
|
||||
|
||||
```bash
|
||||
stash --import < cliphist.tsv
|
||||
stash import < cliphist.tsv
|
||||
```
|
||||
|
||||
**Export TSV from Stash:**
|
||||
|
|
@ -134,7 +486,135 @@ cliphist --import < stash.tsv
|
|||
|
||||
### More Tricks
|
||||
|
||||
- Use `stash list` to export your clipboard history in TSV format. This displays
|
||||
your clipboard in the same format as `cliphist list`
|
||||
- Use `stash import --type tsv` to import TSV clipboard history from Cliphist or
|
||||
other tools.
|
||||
Here are some other tips for Stash that are worth documenting. If you have
|
||||
figured out something new, e.g. a neat shell trick, feel free to add it here!
|
||||
|
||||
1. You may use `stash list` to view your clipboard history in an interactive
|
||||
TUI. This is obvious if you have ever ran the command, but here are some
|
||||
things that you might not have known.
|
||||
- `stash list` displays the TUI _only_ if the user is in an interactive TTY.
|
||||
E.g. if it's a Bash script, `stash list` **will output TSV**.
|
||||
- You can change the format with `--format` to e.g. JSON but you can also
|
||||
force a TSV format inside an interactive session with `--format tsv`.
|
||||
- `stash list` displays the mime type for newly recorded entries, but it will
|
||||
not be able to display them for entries imported by Cliphist since Cliphist
|
||||
never made a record of this data.
|
||||
2. You can pipe `cliphist list --db ~/.cache/cliphist/db` to
|
||||
`stash import --type tsv` to mimic importing from STDIN.
|
||||
|
||||
```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/).
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ Requisite=graphical-session.target
|
|||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=wl-paste --watch stash store
|
||||
ExecStart=stash watch
|
||||
Restart=on-failure
|
||||
LoadCredential=clipboard_filter:/etc/stash/clipboard_filter
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
22
flake.lock
generated
22
flake.lock
generated
|
|
@ -1,12 +1,27 @@
|
|||
{
|
||||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1775839657,
|
||||
"narHash": "sha256-SPm9ck7jh3Un9nwPuMGbRU04UroFmOHjLP56T10MOeM=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "7cf72d978629469c4bd4206b95c402514c1f6000",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1754725699,
|
||||
"narHash": "sha256-iAcj9T/Y+3DBy2J0N+yF9XQQQ8IEb5swLFzs23CdP88=",
|
||||
"lastModified": 1775710090,
|
||||
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "85dbfc7aaf52ecb755f87e577ddbe6dbbdbc1054",
|
||||
"rev": "4c1018dae018162ec878d42fec712642d214fdfa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -18,6 +33,7 @@
|
|||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"crane": "crane",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
flake.nix
18
flake.nix
|
|
@ -1,16 +1,28 @@
|
|||
{
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs?ref=nixos-unstable";
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs?ref=nixos-unstable";
|
||||
crane.url = "github:ipetkov/crane";
|
||||
};
|
||||
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
crane,
|
||||
}: let
|
||||
systems = ["x86_64-linux" "aarch64-linux"];
|
||||
forEachSystem = nixpkgs.lib.genAttrs systems;
|
||||
pkgsForEach = nixpkgs.legacyPackages;
|
||||
in {
|
||||
packages = forEachSystem (system: {
|
||||
default = pkgsForEach.${system}.callPackage ./nix/package.nix {};
|
||||
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;
|
||||
});
|
||||
|
||||
devShells = forEachSystem (system: {
|
||||
|
|
|
|||
78
nix/modules/nixos.nix
Normal file
78
nix/modules/nixos.nix
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
self: {
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}: let
|
||||
inherit (lib.modules) mkIf;
|
||||
inherit (lib.options) mkOption mkEnableOption mkPackageOption literalMD;
|
||||
inherit (lib.types) listOf str;
|
||||
inherit (lib.strings) concatStringsSep;
|
||||
inherit (lib.meta) getExe;
|
||||
|
||||
cfg = config.services.stash-clipboard;
|
||||
in {
|
||||
options.services.stash-clipboard = {
|
||||
enable = mkEnableOption "stash, a Wayland clipboard manager";
|
||||
|
||||
package = mkPackageOption self.packages.${pkgs.system} ["stash"] {};
|
||||
|
||||
flags = mkOption {
|
||||
type = listOf str;
|
||||
default = [];
|
||||
example = ["--max-items 10"];
|
||||
description = "Flags to pass to stash watch.";
|
||||
};
|
||||
|
||||
filterFile = mkOption {
|
||||
type = str;
|
||||
default = "";
|
||||
example = "{file}`/etc/stash/clipboard_filter`";
|
||||
description = literalMD ''
|
||||
File containing a regular expression to catch sensitive patterns. The file
|
||||
passed to this option must contain your regex pattern with no quotes.
|
||||
|
||||
::: {.tip}
|
||||
Example regex to block common password patterns:
|
||||
|
||||
* `(password|secret|api[_-]?key|token)[=: ]+[^\s]+`
|
||||
:::
|
||||
'';
|
||||
};
|
||||
|
||||
excludedApps = mkOption {
|
||||
type = listOf str;
|
||||
default = [];
|
||||
example = ["Bitwarden"];
|
||||
description = ''
|
||||
Stash will avoid storing data if the active window class matches the
|
||||
entries passed to this option. This is useful for avoiding persistent
|
||||
passwords in the database, while still allowing one-time copies.
|
||||
|
||||
Entries from these apps are still copied to the clipboard, but it will
|
||||
never be put inside the database.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = mkIf cfg.enable {
|
||||
environment.systemPackages = [cfg.package];
|
||||
systemd = {
|
||||
packages = [cfg.package];
|
||||
user.services.stash-clipboard = {
|
||||
description = "Stash clipboard manager daemon";
|
||||
wantedBy = ["graphical-session.target"];
|
||||
after = ["graphical-session.target"];
|
||||
|
||||
serviceConfig = {
|
||||
ExecStart = "${getExe cfg.package} ${concatStringsSep " " cfg.flags} watch";
|
||||
LoadCredential = mkIf (cfg.filterFile != "") "clipboard_filter:${cfg.filterFile}";
|
||||
};
|
||||
|
||||
environment = mkIf (cfg.excludedApps != []) {
|
||||
STASH_EXCLUDED_APPS = concatStringsSep "," cfg.excludedApps;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
{
|
||||
lib,
|
||||
rustPlatform,
|
||||
}:
|
||||
rustPlatform.buildRustPackage (finalAttrs: {
|
||||
craneLib,
|
||||
stdenv,
|
||||
mold,
|
||||
versionCheckHook,
|
||||
useMold ? stdenv.isLinux,
|
||||
createSymlinks ? true,
|
||||
}: let
|
||||
pname = "stash";
|
||||
version = (builtins.fromTOML (builtins.readFile ../Cargo.toml)).package.version;
|
||||
|
||||
version = (lib.importTOML ../Cargo.toml).package.version;
|
||||
src = let
|
||||
fs = lib.fileset;
|
||||
s = ../.;
|
||||
|
|
@ -19,18 +22,50 @@ rustPlatform.buildRustPackage (finalAttrs: {
|
|||
];
|
||||
};
|
||||
|
||||
cargoLock.lockFile = "${finalAttrs.src}/Cargo.lock";
|
||||
enableParallelBuilding = true;
|
||||
|
||||
postInstall = ''
|
||||
mkdir -p $out
|
||||
install -Dm755 ${../vendor/stash.service} $out/share/stash.service
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Wayland clipboard manager with fast persistent history and multi-media support";
|
||||
maintainers = [lib.maintainers.NotAShelf];
|
||||
license = lib.licenses.mpl20;
|
||||
mainProgram = "stash";
|
||||
cargoArtifacts = craneLib.buildDepsOnly {
|
||||
name = "${pname}-deps";
|
||||
strictDeps = true;
|
||||
inherit src;
|
||||
};
|
||||
})
|
||||
in
|
||||
craneLib.buildPackage {
|
||||
inherit pname src version cargoArtifacts;
|
||||
|
||||
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 ''
|
||||
mkdir -p $out
|
||||
for bin in stash-copy stash-paste wl-copy wl-paste; do
|
||||
ln -sf $out/bin/stash $out/bin/$bin
|
||||
done
|
||||
'';
|
||||
|
||||
nativeInstallCheckInputs = [versionCheckHook];
|
||||
doInstallCheck = true;
|
||||
|
||||
# After the version check, let's see if all binaries are linked correctly.
|
||||
# We could probably add a check phase to 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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,29 @@
|
|||
{
|
||||
mkShell,
|
||||
rust-analyzer,
|
||||
rustfmt,
|
||||
rustc,
|
||||
clippy,
|
||||
cargo,
|
||||
rustfmt,
|
||||
clippy,
|
||||
taplo,
|
||||
rust-analyzer-unwrapped,
|
||||
cargo-nextest,
|
||||
rustPlatform,
|
||||
}:
|
||||
mkShell {
|
||||
name = "rust";
|
||||
|
||||
packages = [
|
||||
rust-analyzer
|
||||
rustfmt
|
||||
rustc
|
||||
cargo
|
||||
|
||||
(rustfmt.override {asNightly = true;})
|
||||
clippy
|
||||
cargo
|
||||
rustc
|
||||
taplo
|
||||
rust-analyzer-unwrapped
|
||||
|
||||
# Additional Cargo Tooling
|
||||
cargo-nextest
|
||||
];
|
||||
|
||||
RUST_SRC_PATH = "${rustPlatform.rustLibSrc}";
|
||||
|
|
|
|||
3
src/clipboard/mod.rs
Normal file
3
src/clipboard/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod persist;
|
||||
|
||||
pub use persist::{ClipboardData, get_serving_pid, persist_clipboard};
|
||||
262
src/clipboard/persist.rs
Normal file
262
src/clipboard/persist.rs
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
use std::{
|
||||
process::exit,
|
||||
sync::atomic::{AtomicI32, Ordering},
|
||||
};
|
||||
|
||||
use wl_clipboard_rs::copy::{
|
||||
ClipboardType,
|
||||
MimeType as CopyMimeType,
|
||||
Options,
|
||||
PreparedCopy,
|
||||
ServeRequests,
|
||||
Source,
|
||||
};
|
||||
|
||||
/// Maximum number of paste requests to serve before exiting. This (hopefully)
|
||||
/// prevents runaway processes while still providing persistence.
|
||||
const MAX_SERVE_REQUESTS: usize = 1000;
|
||||
|
||||
/// PID of the current clipboard persistence child process. Used to detect when
|
||||
/// clipboard content is from our own serve process.
|
||||
static SERVING_PID: AtomicI32 = AtomicI32::new(0);
|
||||
|
||||
/// Get the current serving PID if any. Used by the watch loop to avoid
|
||||
/// duplicate persistence processes.
|
||||
pub fn get_serving_pid() -> Option<i32> {
|
||||
let pid = SERVING_PID.load(Ordering::SeqCst);
|
||||
if pid != 0 { Some(pid) } else { None }
|
||||
}
|
||||
|
||||
/// Result type for persistence operations.
|
||||
pub type PersistenceResult<T> = Result<T, PersistenceError>;
|
||||
|
||||
/// 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<u8>,
|
||||
|
||||
/// All MIME types offered by the source. Preserves order.
|
||||
pub mime_types: Vec<String>,
|
||||
|
||||
/// The MIME type that was selected for storage.
|
||||
pub selected_mime: String,
|
||||
}
|
||||
|
||||
impl ClipboardData {
|
||||
/// Create new clipboard data.
|
||||
pub fn new(
|
||||
content: Vec<u8>,
|
||||
mime_types: Vec<String>,
|
||||
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<PreparedCopy> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +1,88 @@
|
|||
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use crate::db::StashError;
|
||||
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents};
|
||||
|
||||
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
|
||||
|
||||
pub trait DecodeCommand {
|
||||
fn decode(
|
||||
&self,
|
||||
in_: impl Read,
|
||||
out: impl Write,
|
||||
input: Option<String>,
|
||||
) -> Result<(), StashError>;
|
||||
fn decode(
|
||||
&self,
|
||||
in_: impl Read,
|
||||
out: impl Write,
|
||||
input: Option<String>,
|
||||
) -> Result<(), StashError>;
|
||||
}
|
||||
|
||||
impl DecodeCommand for SqliteClipboardDb {
|
||||
fn decode(
|
||||
&self,
|
||||
mut in_: impl Read,
|
||||
mut out: impl Write,
|
||||
input: Option<String>,
|
||||
) -> Result<(), StashError> {
|
||||
let input_str = if let Some(s) = input {
|
||||
s
|
||||
} else {
|
||||
let mut buf = String::new();
|
||||
if let Err(e) = in_.read_to_string(&mut buf) {
|
||||
log::error!("Failed to read stdin for decode: {e}");
|
||||
}
|
||||
buf
|
||||
};
|
||||
fn decode(
|
||||
&self,
|
||||
mut in_: impl Read,
|
||||
mut out: impl Write,
|
||||
input: Option<String>,
|
||||
) -> Result<(), StashError> {
|
||||
let input_str = if let Some(s) = input {
|
||||
s
|
||||
} else {
|
||||
let mut buf = String::new();
|
||||
in_
|
||||
.read_to_string(&mut buf)
|
||||
.map_err(|e| StashError::DecodeRead(e.to_string().into()))?;
|
||||
buf
|
||||
};
|
||||
|
||||
// If input is empty or whitespace, treat as error and trigger fallback
|
||||
if input_str.trim().is_empty() {
|
||||
log::info!("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();
|
||||
if let Err(err) = reader.read_to_end(&mut buf) {
|
||||
log::error!("Failed to read clipboard for relay: {err}");
|
||||
} else {
|
||||
let _ = out.write_all(&buf);
|
||||
}
|
||||
} else {
|
||||
log::error!("Failed to get clipboard contents for relay");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Try decode as usual
|
||||
match self.decode_entry(input_str.as_bytes(), &mut out, Some(input_str.clone())) {
|
||||
Ok(()) => {
|
||||
log::info!("Entry decoded");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to decode entry: {e}");
|
||||
if let Ok((mut reader, _mime)) =
|
||||
get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Any)
|
||||
{
|
||||
let mut buf = Vec::new();
|
||||
if let Err(err) = reader.read_to_end(&mut buf) {
|
||||
log::error!("Failed to read clipboard for relay: {err}");
|
||||
} else {
|
||||
let _ = out.write_all(&buf);
|
||||
}
|
||||
} else {
|
||||
log::error!("Failed to get clipboard contents for relay");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
// 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");
|
||||
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(),
|
||||
)
|
||||
})?;
|
||||
out.write_all(&buf).map_err(|e| {
|
||||
StashError::DecodeWrite(
|
||||
format!("Failed to write clipboard relay: {e}").into(),
|
||||
)
|
||||
})?;
|
||||
} else {
|
||||
return Err(StashError::DecodeGet(
|
||||
"Failed to get clipboard contents for relay".into(),
|
||||
));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Try decode as usual
|
||||
match self.decode_entry(
|
||||
input_str.as_bytes(),
|
||||
&mut out,
|
||||
Some(input_str.clone()),
|
||||
) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
// On decode failure, relay clipboard as fallback
|
||||
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(|err| {
|
||||
StashError::DecodeRead(
|
||||
format!("Failed to read clipboard for relay: {err}").into(),
|
||||
)
|
||||
})?;
|
||||
out.write_all(&buf).map_err(|err| {
|
||||
StashError::DecodeWrite(
|
||||
format!("Failed to write clipboard relay: {err}").into(),
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(e)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,15 @@
|
|||
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
|
||||
|
||||
pub trait DeleteCommand {
|
||||
fn delete(&self, input: impl Read) -> Result<usize, StashError>;
|
||||
fn delete(&self, input: impl Read) -> Result<usize, StashError>;
|
||||
}
|
||||
|
||||
impl DeleteCommand for SqliteClipboardDb {
|
||||
fn delete(&self, input: impl Read) -> Result<usize, StashError> {
|
||||
match self.delete_entries(input) {
|
||||
Ok(deleted) => {
|
||||
log::info!("Deleted {deleted} entries");
|
||||
Ok(deleted)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to delete entries: {e}");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn delete(&self, input: impl Read) -> Result<usize, StashError> {
|
||||
let deleted = self.delete_entries(input)?;
|
||||
log::info!("deleted {deleted} entries");
|
||||
Ok(deleted)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
66
src/commands/import.rs
Normal file
66
src/commands/import.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
use std::io::{self, BufRead};
|
||||
|
||||
use crate::db::{ClipboardDb, Entry, SqliteClipboardDb, StashError};
|
||||
|
||||
pub trait ImportCommand {
|
||||
/// Import clipboard entries from TSV format.
|
||||
fn import_tsv(
|
||||
&self,
|
||||
input: impl io::Read,
|
||||
max_items: u64,
|
||||
) -> Result<(), StashError>;
|
||||
}
|
||||
|
||||
impl ImportCommand for SqliteClipboardDb {
|
||||
fn import_tsv(
|
||||
&self,
|
||||
input: impl io::Read,
|
||||
max_items: u64,
|
||||
) -> Result<(), StashError> {
|
||||
let reader = io::BufReader::new(input);
|
||||
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())
|
||||
})?;
|
||||
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(),
|
||||
));
|
||||
};
|
||||
|
||||
let Ok(_id) = id_str.parse::<u64>() else {
|
||||
return Err(StashError::Store(
|
||||
format!("Failed to parse id from line {lineno}: {id_str}").into(),
|
||||
));
|
||||
};
|
||||
|
||||
let entry = Entry {
|
||||
contents: val.as_bytes().to_vec(),
|
||||
mime: crate::mime::detect_mime(val.as_bytes()),
|
||||
};
|
||||
|
||||
self
|
||||
.conn
|
||||
.execute(
|
||||
"INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)",
|
||||
rusqlite::params![entry.contents, entry.mime],
|
||||
)
|
||||
.map_err(|e| {
|
||||
StashError::Store(
|
||||
format!("Failed to insert entry at line {lineno}: {e}").into(),
|
||||
)
|
||||
})?;
|
||||
imported += 1;
|
||||
}
|
||||
|
||||
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}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,758 @@
|
|||
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
||||
use std::io::Write;
|
||||
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
|
||||
|
||||
pub trait ListCommand {
|
||||
fn list(&self, out: impl Write, preview_width: u32) -> Result<(), crate::db::StashError>;
|
||||
fn list(
|
||||
&self,
|
||||
out: impl Write,
|
||||
preview_width: u32,
|
||||
include_expired: bool,
|
||||
reverse: bool,
|
||||
) -> Result<(), StashError>;
|
||||
}
|
||||
|
||||
impl ListCommand for SqliteClipboardDb {
|
||||
fn list(&self, out: impl Write, preview_width: u32) -> Result<(), crate::db::StashError> {
|
||||
self.list_entries(out, preview_width)?;
|
||||
log::info!("Listed clipboard entries");
|
||||
Ok(())
|
||||
}
|
||||
fn list(
|
||||
&self,
|
||||
out: impl Write,
|
||||
preview_width: u32,
|
||||
include_expired: bool,
|
||||
reverse: bool,
|
||||
) -> Result<(), StashError> {
|
||||
self
|
||||
.list_entries(out, preview_width, include_expired, reverse)
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
/// All mutable state for the TUI list view.
|
||||
struct TuiState {
|
||||
/// Total number of entries matching the current filter in the DB.
|
||||
total: usize,
|
||||
|
||||
/// Global cursor position: index into the full ordered result set.
|
||||
cursor: usize,
|
||||
|
||||
/// DB offset of `window[0]`, i.e., the first row currently loaded.
|
||||
viewport_offset: usize,
|
||||
|
||||
/// The loaded slice of entries: `(id, preview, mime)`.
|
||||
window: Vec<(i64, String, String)>,
|
||||
|
||||
/// How many rows the window holds (== visible list height).
|
||||
window_size: usize,
|
||||
|
||||
/// Whether the window needs to be re-fetched from the DB.
|
||||
dirty: bool,
|
||||
|
||||
/// Current search query. Empty string means no filter.
|
||||
search_query: String,
|
||||
|
||||
/// Whether we're currently in search input mode.
|
||||
search_mode: bool,
|
||||
|
||||
/// Whether to show entries in reverse order (oldest first).
|
||||
reverse: bool,
|
||||
|
||||
/// ID of entry currently being copied.
|
||||
copying_entry: Option<i64>,
|
||||
}
|
||||
|
||||
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<Self, StashError> {
|
||||
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> {
|
||||
use std::io::stdout;
|
||||
|
||||
use crossterm::{
|
||||
event::{
|
||||
self,
|
||||
DisableMouseCapture,
|
||||
EnableMouseCapture,
|
||||
Event,
|
||||
KeyCode,
|
||||
KeyModifiers,
|
||||
},
|
||||
execute,
|
||||
terminal::{
|
||||
EnterAlternateScreen,
|
||||
LeaveAlternateScreen,
|
||||
disable_raw_mode,
|
||||
enable_raw_mode,
|
||||
},
|
||||
};
|
||||
use notify_rust::Notification;
|
||||
use ratatui::{
|
||||
Terminal,
|
||||
backend::CrosstermBackend,
|
||||
style::{Color, Modifier, Style},
|
||||
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)?;
|
||||
|
||||
enable_raw_mode()
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
let mut stdout = stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
|
||||
// Derive initial window size from current terminal height.
|
||||
let initial_height = terminal
|
||||
.size()
|
||||
.map(|r| r.height.saturating_sub(2) as usize)
|
||||
.unwrap_or(24);
|
||||
let initial_height = initial_height.max(1);
|
||||
|
||||
let mut tui = TuiState::new(
|
||||
self,
|
||||
include_expired,
|
||||
initial_height,
|
||||
preview_width,
|
||||
reverse,
|
||||
)?;
|
||||
|
||||
// ratatui ListState; only tracks selection within the *window* slice.
|
||||
let mut list_state = ListState::default();
|
||||
if tui.total > 0 {
|
||||
list_state.select(Some(0));
|
||||
}
|
||||
|
||||
/// 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<char>, // 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<EventActions, StashError> {
|
||||
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<CrosstermBackend<std::io::Stdout>>,
|
||||
tui: &mut TuiState,
|
||||
list_state: &mut ListState,
|
||||
max_id_width: usize,
|
||||
max_mime_width: usize|
|
||||
-> Result<(), StashError> {
|
||||
// Reserve 2 rows for search bar when in search mode
|
||||
let search_bar_height = if tui.search_mode { 2 } else { 0 };
|
||||
let term_height = terminal
|
||||
.size()
|
||||
.map(|r| r.height.saturating_sub(2 + search_bar_height) as usize)
|
||||
.unwrap_or(24)
|
||||
.max(1);
|
||||
tui.resize(term_height);
|
||||
tui.sync(self, include_expired, preview_width)?;
|
||||
|
||||
if tui.total == 0 {
|
||||
list_state.select(None);
|
||||
} else {
|
||||
list_state.select(Some(tui.local_cursor()));
|
||||
}
|
||||
|
||||
terminal
|
||||
.draw(|f| {
|
||||
let area = f.area();
|
||||
|
||||
// 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 border_width = 2;
|
||||
let highlight_symbol = ">";
|
||||
let highlight_width = 1;
|
||||
let content_width = area.width as usize - border_width;
|
||||
|
||||
let min_id_width = 2;
|
||||
let min_mime_width = 6;
|
||||
let min_preview_width = 4;
|
||||
let spaces = 3;
|
||||
|
||||
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
|
||||
.saturating_sub(highlight_width)
|
||||
.saturating_sub(id_col)
|
||||
.saturating_sub(mime_col)
|
||||
.saturating_sub(spaces);
|
||||
|
||||
if preview_col < min_preview_width {
|
||||
let needed = min_preview_width - preview_col;
|
||||
if mime_col > min_mime_width {
|
||||
let reduce = mime_col - min_mime_width;
|
||||
let take = reduce.min(needed);
|
||||
mime_col -= take;
|
||||
preview_col += take;
|
||||
}
|
||||
}
|
||||
if preview_col < min_preview_width {
|
||||
let needed = min_preview_width - preview_col;
|
||||
if id_col > min_id_width {
|
||||
let reduce = id_col - min_id_width;
|
||||
let take = reduce.min(needed);
|
||||
id_col -= take;
|
||||
preview_col += take;
|
||||
}
|
||||
}
|
||||
if preview_col < min_preview_width {
|
||||
preview_col = min_preview_width;
|
||||
}
|
||||
|
||||
let selected = list_state.selected();
|
||||
|
||||
let list_items: Vec<ListItem> = tui
|
||||
.window
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, entry)| {
|
||||
let mut preview = String::new();
|
||||
let mut width = 0;
|
||||
for g in entry.1.graphemes(true) {
|
||||
let g_width = UnicodeWidthStr::width(g);
|
||||
if width + g_width > preview_col {
|
||||
preview.push('…');
|
||||
break;
|
||||
}
|
||||
preview.push_str(g);
|
||||
width += g_width;
|
||||
}
|
||||
let mut mime = String::new();
|
||||
let mut mwidth = 0;
|
||||
for g in entry.2.graphemes(true) {
|
||||
let g_width = UnicodeWidthStr::width(g);
|
||||
if mwidth + g_width > mime_col {
|
||||
mime.push('…');
|
||||
break;
|
||||
}
|
||||
mime.push_str(g);
|
||||
mwidth += g_width;
|
||||
}
|
||||
|
||||
let mut spans = Vec::new();
|
||||
let (id, preview, mime) = entry;
|
||||
if Some(i) == selected {
|
||||
spans.push(Span::styled(
|
||||
highlight_symbol,
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
format!("{id:>id_col$}"),
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(
|
||||
format!("{preview:<preview_col$}"),
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(
|
||||
format!("{mime:>mime_col$}"),
|
||||
Style::default().fg(Color::Green),
|
||||
));
|
||||
} else {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::raw(format!("{id:>id_col$}")));
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::raw(format!("{preview:<preview_col$}")));
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::raw(format!("{mime:>mime_col$}")));
|
||||
}
|
||||
ListItem::new(Line::from(spans))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(list_items)
|
||||
.block(block)
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
.highlight_symbol("");
|
||||
|
||||
f.render_stateful_widget(list, area, list_state);
|
||||
})
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
// Initial draw.
|
||||
draw_frame(
|
||||
&mut terminal,
|
||||
&mut tui,
|
||||
&mut list_state,
|
||||
max_id_width,
|
||||
max_mime_width,
|
||||
)?;
|
||||
|
||||
let res = (|| -> Result<(), StashError> {
|
||||
loop {
|
||||
// Block waiting for events, then drain and process all queued input.
|
||||
if event::poll(std::time::Duration::from_millis(250))
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
|
||||
{
|
||||
let 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::<String>()
|
||||
})
|
||||
.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();
|
||||
},
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("failed to fetch entry {id}: {e}");
|
||||
let _ = Notification::new()
|
||||
.summary("Stash")
|
||||
.body(&format!("Failed to fetch entry: {e}"))
|
||||
.show();
|
||||
},
|
||||
}
|
||||
tui.copying_entry = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Redraw once after processing all accumulated input.
|
||||
draw_frame(
|
||||
&mut terminal,
|
||||
&mut tui,
|
||||
&mut list_state,
|
||||
max_id_width,
|
||||
max_mime_width,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
// Ignore errors during terminal restore, as we can't recover here.
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture
|
||||
);
|
||||
let _ = terminal.show_cursor();
|
||||
|
||||
res
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
pub mod decode;
|
||||
pub mod delete;
|
||||
pub mod import;
|
||||
pub mod list;
|
||||
pub mod query;
|
||||
pub mod store;
|
||||
pub mod watch;
|
||||
pub mod wipe;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
||||
|
||||
use crate::db::StashError;
|
||||
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
|
||||
|
||||
pub trait QueryCommand {
|
||||
fn query_delete(&self, query: &str) -> Result<usize, StashError>;
|
||||
fn query_delete(&self, query: &str) -> Result<usize, StashError>;
|
||||
}
|
||||
|
||||
impl QueryCommand for SqliteClipboardDb {
|
||||
fn query_delete(&self, query: &str) -> Result<usize, StashError> {
|
||||
<SqliteClipboardDb as ClipboardDb>::delete_query(self, query)
|
||||
}
|
||||
fn query_delete(&self, query: &str) -> Result<usize, StashError> {
|
||||
<Self as ClipboardDb>::delete_query(self, query)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,48 @@
|
|||
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub trait StoreCommand {
|
||||
fn store(
|
||||
&self,
|
||||
input: impl Read,
|
||||
max_dedupe_search: u64,
|
||||
max_items: u64,
|
||||
state: Option<String>,
|
||||
) -> Result<(), crate::db::StashError>;
|
||||
fn store(
|
||||
&self,
|
||||
input: impl Read,
|
||||
max_dedupe_search: u64,
|
||||
max_items: u64,
|
||||
state: Option<String>,
|
||||
excluded_apps: &[String],
|
||||
min_size: Option<usize>,
|
||||
max_size: usize,
|
||||
) -> Result<(), crate::db::StashError>;
|
||||
}
|
||||
|
||||
impl StoreCommand for SqliteClipboardDb {
|
||||
fn store(
|
||||
&self,
|
||||
input: impl Read,
|
||||
max_dedupe_search: u64,
|
||||
max_items: u64,
|
||||
state: Option<String>,
|
||||
) -> Result<(), crate::db::StashError> {
|
||||
if let Some("sensitive" | "clear") = state.as_deref() {
|
||||
self.delete_last()?;
|
||||
log::info!("Entry deleted");
|
||||
} else {
|
||||
self.store_entry(input, max_dedupe_search, max_items)?;
|
||||
log::info!("Entry stored");
|
||||
}
|
||||
Ok(())
|
||||
fn store(
|
||||
&self,
|
||||
input: impl Read,
|
||||
max_dedupe_search: u64,
|
||||
max_items: u64,
|
||||
state: Option<String>,
|
||||
excluded_apps: &[String],
|
||||
min_size: Option<usize>,
|
||||
max_size: usize,
|
||||
) -> Result<(), crate::db::StashError> {
|
||||
if let Some("sensitive" | "clear") = state.as_deref() {
|
||||
self.delete_last()?;
|
||||
log::info!("entry deleted");
|
||||
} else {
|
||||
self.store_entry(
|
||||
input,
|
||||
max_dedupe_search,
|
||||
max_items,
|
||||
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");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,79 +1,712 @@
|
|||
use crate::db::{ClipboardDb, Entry, SqliteClipboardDb};
|
||||
use smol::Timer;
|
||||
use std::io::Read;
|
||||
use std::time::Duration;
|
||||
use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents};
|
||||
use std::{collections::BinaryHeap, hash::Hasher, io::Read, time::Duration};
|
||||
|
||||
use smol::Timer;
|
||||
use wl_clipboard_rs::{
|
||||
copy::{MimeType as CopyMimeType, Options, Source},
|
||||
paste::{
|
||||
ClipboardType,
|
||||
MimeType as PasteMimeType,
|
||||
Seat,
|
||||
get_contents,
|
||||
get_mime_types_ordered,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
clipboard::{self, ClipboardData, get_serving_pid},
|
||||
db::{SqliteClipboardDb, nonblocking::AsyncClipboardDb},
|
||||
hash::Fnv1aHasher,
|
||||
};
|
||||
|
||||
/// Wrapper to provide [`Ord`] implementation for `f64` by negating values.
|
||||
/// This allows [`BinaryHeap`], which is a max-heap, to function as a min-heap.
|
||||
/// Also see:
|
||||
/// - <https://doc.rust-lang.org/std/cmp/struct.Reverse.html>
|
||||
/// - <https://doc.rust-lang.org/std/primitive.f64.html#method.total_cmp>
|
||||
/// - <https://docs.rs/ordered-float/latest/ordered_float/>
|
||||
#[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<std::cmp::Ordering> {
|
||||
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<f64> {
|
||||
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<i64> {
|
||||
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<dyn Read>, String, Vec<String>), 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<dyn Read>, 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 (<img src="...">), 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<dyn Read>, actual_mime, offered))
|
||||
},
|
||||
None => Err(wl_clipboard_rs::paste::Error::NoSeats),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub trait WatchCommand {
|
||||
fn watch(&self, max_dedupe_search: u64, max_items: u64);
|
||||
async fn watch(
|
||||
&self,
|
||||
max_dedupe_search: u64,
|
||||
max_items: u64,
|
||||
excluded_apps: &[String],
|
||||
expire_after: Option<Duration>,
|
||||
mime_type_preference: &str,
|
||||
min_size: Option<usize>,
|
||||
max_size: usize,
|
||||
persist: bool,
|
||||
);
|
||||
}
|
||||
|
||||
impl WatchCommand for SqliteClipboardDb {
|
||||
fn watch(&self, max_dedupe_search: u64, max_items: u64) {
|
||||
smol::block_on(async {
|
||||
log::info!("Starting clipboard watch daemon");
|
||||
async fn watch(
|
||||
&self,
|
||||
max_dedupe_search: u64,
|
||||
max_items: u64,
|
||||
excluded_apps: &[String],
|
||||
expire_after: Option<Duration>,
|
||||
mime_type_preference: &str,
|
||||
min_size: Option<usize>,
|
||||
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}"
|
||||
);
|
||||
|
||||
// Preallocate buffer for clipboard contents
|
||||
let mut last_contents: Option<Vec<u8>> = None;
|
||||
let mut buf = Vec::with_capacity(4096); // reasonable default, hopefully
|
||||
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
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}"),
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
if persist {
|
||||
log::info!("clipboard persistence enabled");
|
||||
}
|
||||
|
||||
// Build expiration queue from existing entries
|
||||
let mut exp_queue = ExpirationQueue::new();
|
||||
|
||||
// Load all expirations from database asynchronously
|
||||
match async_db.load_all_expirations().await {
|
||||
Ok(expirations) => {
|
||||
for (expires_at, id) in expirations {
|
||||
exp_queue.push(expires_at, id);
|
||||
}
|
||||
if !exp_queue.is_empty() {
|
||||
log::info!("loaded {} expirations from database", exp_queue.len());
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::warn!("failed to load expirations: {e}");
|
||||
},
|
||||
}
|
||||
|
||||
// We use hashes for comparison instead of storing full contents
|
||||
let mut last_hash: Option<u64> = None;
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
|
||||
// Helper to hash clipboard contents using FNV-1a (deterministic across
|
||||
// runs)
|
||||
let hash_contents = |data: &[u8]| -> u64 {
|
||||
let mut hasher = Fnv1aHasher::new();
|
||||
hasher.write(data);
|
||||
hasher.finish()
|
||||
};
|
||||
|
||||
// Initialize with current clipboard using smart MIME negotiation
|
||||
if let Ok((mut reader, ..)) = negotiate_mime_type(mime_type_preference) {
|
||||
buf.clear();
|
||||
if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
|
||||
last_hash = Some(hash_contents(&buf));
|
||||
}
|
||||
}
|
||||
|
||||
let poll_interval = Duration::from_millis(500);
|
||||
|
||||
loop {
|
||||
// Process any pending expirations that are due now
|
||||
if let Some(next_exp) = exp_queue.peek_next() {
|
||||
let now = SqliteClipboardDb::now();
|
||||
if next_exp <= now {
|
||||
// Expired entries to process
|
||||
let expired_ids = exp_queue.pop_expired(now);
|
||||
for id in expired_ids {
|
||||
// Verify entry still exists and get its content_hash
|
||||
let expired_hash: Option<i64> =
|
||||
match async_db.get_content_hash(id).await {
|
||||
Ok(hash) => hash,
|
||||
Err(e) => {
|
||||
log::warn!("failed to get content hash for entry {id}: {e}");
|
||||
None
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(stored_hash) = expired_hash {
|
||||
// Mark as expired
|
||||
if let Err(e) = async_db.mark_expired(id).await {
|
||||
log::warn!("failed to mark entry {id} as expired: {e}");
|
||||
} else {
|
||||
log::info!("entry {id} marked as expired");
|
||||
}
|
||||
|
||||
// Check if this expired entry is currently in the clipboard
|
||||
if let Ok((mut reader, ..)) =
|
||||
negotiate_mime_type(mime_type_preference)
|
||||
{
|
||||
let mut current_buf = Vec::new();
|
||||
if reader.read_to_end(&mut current_buf).is_ok()
|
||||
&& !current_buf.is_empty()
|
||||
{
|
||||
let current_hash = hash_contents(¤t_buf);
|
||||
// Convert stored i64 to u64 for comparison (preserves bit
|
||||
// pattern)
|
||||
if current_hash == stored_hash as u64 {
|
||||
// Clear the clipboard since expired content is still
|
||||
// there
|
||||
let mut opts = Options::new();
|
||||
opts
|
||||
.clipboard(wl_clipboard_rs::copy::ClipboardType::Regular);
|
||||
if opts
|
||||
.copy(
|
||||
Source::Bytes(Vec::new().into()),
|
||||
CopyMimeType::Autodetect,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
log::info!(
|
||||
"cleared clipboard containing expired entry {id}"
|
||||
);
|
||||
last_hash = None; // reset tracked hash
|
||||
} else {
|
||||
log::warn!(
|
||||
"failed to clear clipboard for expired entry {id}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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<String> = 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<String> = 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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
||||
|
||||
use crate::db::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(())
|
||||
}
|
||||
}
|
||||
2561
src/db/mod.rs
2561
src/db/mod.rs
File diff suppressed because it is too large
Load diff
375
src/db/nonblocking.rs
Normal file
375
src/db/nonblocking.rs
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use rusqlite::OptionalExtension;
|
||||
|
||||
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
|
||||
|
||||
/// Async wrapper for database operations that runs blocking operations
|
||||
/// on a thread pool to avoid blocking the async runtime. Since
|
||||
/// [`rusqlite::Connection`] is not Send, we store the database path and open a
|
||||
/// new connection for each operation.
|
||||
pub struct AsyncClipboardDb {
|
||||
db_path: PathBuf,
|
||||
}
|
||||
|
||||
impl AsyncClipboardDb {
|
||||
pub fn new(db_path: PathBuf) -> Self {
|
||||
Self { db_path }
|
||||
}
|
||||
|
||||
#[expect(clippy::too_many_arguments)]
|
||||
pub async fn store_entry(
|
||||
&self,
|
||||
data: Vec<u8>,
|
||||
max_dedupe_search: u64,
|
||||
max_items: u64,
|
||||
excluded_apps: Option<Vec<String>>,
|
||||
min_size: Option<usize>,
|
||||
max_size: usize,
|
||||
content_hash: Option<i64>,
|
||||
mime_types: Option<Vec<String>>,
|
||||
) -> Result<i64, StashError> {
|
||||
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<Vec<(f64, i64)>, 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<Option<i64>, StashError> {
|
||||
let path = self.db_path.clone();
|
||||
blocking::unblock(move || {
|
||||
let db = Self::open_db_internal(&path)?;
|
||||
let result: Option<i64> = 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<SqliteClipboardDb, StashError> {
|
||||
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<Vec<_>, _> = 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");
|
||||
});
|
||||
}
|
||||
}
|
||||
101
src/hash.rs
Normal file
101
src/hash.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/// FNV-1a hasher for deterministic hashing across process runs.
|
||||
///
|
||||
/// Unlike `std::collections::hash_map::DefaultHasher` (which uses SipHash
|
||||
/// with a random seed), this produces stable hashes suitable for persistent
|
||||
/// storage and cross-process comparison.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use std::hash::Hasher;
|
||||
///
|
||||
/// use stash::hash::Fnv1aHasher;
|
||||
///
|
||||
/// let mut hasher = Fnv1aHasher::new();
|
||||
/// hasher.write(b"hello");
|
||||
/// let hash = hasher.finish();
|
||||
/// ```
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Fnv1aHasher {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl Fnv1aHasher {
|
||||
const FNV_OFFSET: u64 = 0xCBF29CE484222325;
|
||||
const FNV_PRIME: u64 = 0x100000001B3;
|
||||
|
||||
/// Creates a new hasher initialized with the FNV-1a offset basis.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Self::FNV_OFFSET,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Fnv1aHasher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::hash::Hasher for Fnv1aHasher {
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
for byte in bytes {
|
||||
self.state ^= u64::from(*byte);
|
||||
self.state = self.state.wrapping_mul(Self::FNV_PRIME);
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&self) -> u64 {
|
||||
self.state
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::hash::Hasher;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_fnv1a_basic() {
|
||||
let mut hasher = Fnv1aHasher::new();
|
||||
hasher.write(b"hello");
|
||||
// FNV-1a hash for "hello" (little-endian u64)
|
||||
assert_eq!(hasher.finish(), 0xA430D84680AABD0B);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fnv1a_empty() {
|
||||
let hasher = Fnv1aHasher::new();
|
||||
// Empty input should return offset basis
|
||||
assert_eq!(hasher.finish(), Fnv1aHasher::FNV_OFFSET);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fnv1a_deterministic() {
|
||||
// Same input must produce same hash
|
||||
let mut h1 = Fnv1aHasher::new();
|
||||
let mut h2 = Fnv1aHasher::new();
|
||||
h1.write(b"test data");
|
||||
h2.write(b"test data");
|
||||
assert_eq!(h1.finish(), h2.finish());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_trait() {
|
||||
let h1 = Fnv1aHasher::new();
|
||||
let h2 = Fnv1aHasher::default();
|
||||
assert_eq!(h1.finish(), h2.finish());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_copy_trait() {
|
||||
let mut hasher = Fnv1aHasher::new();
|
||||
hasher.write(b"data");
|
||||
let copied = hasher;
|
||||
// Both should have same state after copy
|
||||
assert_eq!(hasher.finish(), copied.finish());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
use crate::db::{Entry, SqliteClipboardDb, detect_mime};
|
||||
use log::{error, info};
|
||||
use std::io::{self, BufRead};
|
||||
|
||||
pub trait ImportCommand {
|
||||
fn import_tsv(&self, input: impl io::Read);
|
||||
}
|
||||
|
||||
impl ImportCommand for SqliteClipboardDb {
|
||||
fn import_tsv(&self, input: impl io::Read) {
|
||||
let reader = io::BufReader::new(input);
|
||||
let mut imported = 0;
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
let mut parts = line.splitn(2, '\t');
|
||||
if let (Some(id_str), Some(val)) = (parts.next(), parts.next()) {
|
||||
if let Ok(_id) = id_str.parse::<u64>() {
|
||||
let entry = Entry {
|
||||
contents: val.as_bytes().to_vec(),
|
||||
mime: detect_mime(val.as_bytes()),
|
||||
};
|
||||
match self.conn.execute(
|
||||
"INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)",
|
||||
rusqlite::params![entry.contents, entry.mime],
|
||||
) {
|
||||
Ok(_) => {
|
||||
imported += 1;
|
||||
info!("Imported entry from TSV");
|
||||
}
|
||||
Err(e) => error!("Failed to insert entry: {e}"),
|
||||
}
|
||||
} else {
|
||||
error!("Failed to parse id from line: {id_str}");
|
||||
}
|
||||
} else {
|
||||
error!("Malformed TSV line: {line:?}");
|
||||
}
|
||||
}
|
||||
info!("Imported {imported} records from TSV into SQLite database.");
|
||||
}
|
||||
}
|
||||
634
src/main.rs
634
src/main.rs
|
|
@ -1,229 +1,487 @@
|
|||
mod clipboard;
|
||||
mod commands;
|
||||
mod db;
|
||||
mod hash;
|
||||
mod mime;
|
||||
mod multicall;
|
||||
|
||||
use std::{
|
||||
env,
|
||||
io::{self},
|
||||
path::PathBuf,
|
||||
process,
|
||||
env,
|
||||
io::{self, IsTerminal},
|
||||
path::PathBuf,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use clap::{CommandFactory, Parser, Subcommand};
|
||||
use color_eyre::eyre;
|
||||
use humantime::parse_duration;
|
||||
use inquire::Confirm;
|
||||
|
||||
mod commands;
|
||||
mod db;
|
||||
mod import;
|
||||
// While the module is named "wayland", the Wayland module is *strictly* for the
|
||||
// use-toplevel feature as it requires some low-level wayland crates that are
|
||||
// not required *by default*. The module is named that way because "toplevel"
|
||||
// sounded too silly. Stash is strictly a Wayland clipboard manager.
|
||||
#[cfg(feature = "use-toplevel")] mod wayland;
|
||||
|
||||
use crate::commands::decode::DecodeCommand;
|
||||
use crate::commands::delete::DeleteCommand;
|
||||
use crate::commands::list::ListCommand;
|
||||
use crate::commands::query::QueryCommand;
|
||||
use crate::commands::store::StoreCommand;
|
||||
use crate::commands::watch::WatchCommand;
|
||||
use crate::commands::wipe::WipeCommand;
|
||||
use crate::import::ImportCommand;
|
||||
use crate::{
|
||||
commands::{
|
||||
decode::DecodeCommand,
|
||||
delete::DeleteCommand,
|
||||
import::ImportCommand,
|
||||
list::ListCommand,
|
||||
query::QueryCommand,
|
||||
store::StoreCommand,
|
||||
watch::WatchCommand,
|
||||
},
|
||||
db::{ClipboardDb, DEFAULT_MAX_ENTRY_SIZE},
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "stash")]
|
||||
#[command(about = "Wayland clipboard manager", version)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
|
||||
#[arg(long, default_value_t = 750)]
|
||||
max_items: u64,
|
||||
/// Maximum number of clipboard entries to keep
|
||||
#[arg(long, default_value_t = u64::MAX)]
|
||||
max_items: u64,
|
||||
|
||||
#[arg(long, default_value_t = 100)]
|
||||
max_dedupe_search: u64,
|
||||
/// Number of recent entries to check for duplicates when storing new
|
||||
/// clipboard data.
|
||||
#[arg(long, default_value_t = 20)]
|
||||
max_dedupe_search: u64,
|
||||
|
||||
#[arg(long, default_value_t = 100)]
|
||||
preview_width: u32,
|
||||
/// Minimum size (in bytes) for clipboard entries. Entries smaller than this
|
||||
/// will not be stored.
|
||||
#[arg(long, env = "STASH_MIN_SIZE")]
|
||||
min_size: Option<usize>,
|
||||
|
||||
#[arg(long)]
|
||||
db_path: Option<PathBuf>,
|
||||
/// 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,
|
||||
|
||||
#[command(flatten)]
|
||||
verbosity: clap_verbosity_flag::Verbosity,
|
||||
/// 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")]
|
||||
db_path: Option<PathBuf>,
|
||||
|
||||
/// Application names to exclude from clipboard history
|
||||
#[cfg(feature = "use-toplevel")]
|
||||
#[arg(long, value_delimiter = ',', env = "STASH_EXCLUDED_APPS")]
|
||||
excluded_apps: Vec<String>,
|
||||
|
||||
/// Ask for confirmation before destructive operations
|
||||
#[arg(long)]
|
||||
ask: bool,
|
||||
|
||||
#[command(flatten)]
|
||||
verbosity: clap_verbosity_flag::Verbosity,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Store clipboard contents
|
||||
Store,
|
||||
/// Store clipboard contents
|
||||
Store,
|
||||
|
||||
/// List clipboard history
|
||||
List {
|
||||
/// Output format: "tsv" (default) or "json"
|
||||
#[arg(long, value_parser = ["tsv", "json"])]
|
||||
format: Option<String>,
|
||||
},
|
||||
/// List clipboard history
|
||||
List {
|
||||
/// Output format: "tsv" (default) or "json"
|
||||
#[arg(long, value_parser = ["tsv", "json"])]
|
||||
format: Option<String>,
|
||||
|
||||
/// Decode and output clipboard entry by id
|
||||
Decode { input: Option<String> },
|
||||
/// Show only expired entries (diagnostic, does not remove them)
|
||||
#[arg(long)]
|
||||
expired: bool,
|
||||
|
||||
/// 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.
|
||||
Delete {
|
||||
/// Id or query string
|
||||
arg: Option<String>,
|
||||
/// Reverse the order of entries (oldest first instead of newest first)
|
||||
#[arg(long)]
|
||||
reverse: bool,
|
||||
},
|
||||
|
||||
/// Explicitly specify type: "id" or "query"
|
||||
#[arg(long, value_parser = ["id", "query"])]
|
||||
r#type: Option<String>,
|
||||
},
|
||||
/// Decode and output clipboard entry by id
|
||||
Decode { input: Option<String> },
|
||||
|
||||
/// Wipe all clipboard history
|
||||
Wipe,
|
||||
/// 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.
|
||||
Delete {
|
||||
/// Id or query string
|
||||
arg: Option<String>,
|
||||
|
||||
/// Import clipboard data from stdin (default: TSV format)
|
||||
Import {
|
||||
/// Explicitly specify format: "tsv" (default)
|
||||
#[arg(long, value_parser = ["tsv"])]
|
||||
r#type: Option<String>,
|
||||
},
|
||||
/// Explicitly specify type: "id" or "query"
|
||||
#[arg(long, value_parser = ["id", "query"])]
|
||||
r#type: Option<String>,
|
||||
|
||||
/// Watch clipboard for changes and store automatically
|
||||
Watch,
|
||||
/// Ask for confirmation before deleting
|
||||
#[arg(long)]
|
||||
ask: bool,
|
||||
},
|
||||
|
||||
/// Database management operations
|
||||
Db {
|
||||
#[command(subcommand)]
|
||||
action: DbAction,
|
||||
},
|
||||
|
||||
/// Import clipboard data from stdin (default: TSV format)
|
||||
Import {
|
||||
/// Explicitly specify format: "tsv" (default)
|
||||
#[arg(long, value_parser = ["tsv"])]
|
||||
r#type: Option<String>,
|
||||
|
||||
/// Ask for confirmation before importing
|
||||
#[arg(long)]
|
||||
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<Duration>,
|
||||
|
||||
/// 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,
|
||||
},
|
||||
}
|
||||
|
||||
fn report_error<T>(result: Result<T, impl std::fmt::Display>, context: &str) -> Option<T> {
|
||||
match result {
|
||||
Ok(val) => Some(val),
|
||||
Err(e) => {
|
||||
log::error!("{context}: {e}");
|
||||
None
|
||||
}
|
||||
#[derive(Subcommand)]
|
||||
enum DbAction {
|
||||
/// Wipe database entries
|
||||
Wipe {
|
||||
/// Only wipe expired entries instead of all entries
|
||||
#[arg(long)]
|
||||
expired: bool,
|
||||
|
||||
/// Ask for confirmation before wiping
|
||||
#[arg(long)]
|
||||
ask: bool,
|
||||
},
|
||||
|
||||
/// Optimize database using VACUUM
|
||||
Vacuum,
|
||||
|
||||
/// Show database statistics
|
||||
Stats,
|
||||
}
|
||||
|
||||
fn report_error<T>(
|
||||
result: Result<T, impl std::fmt::Display>,
|
||||
context: &str,
|
||||
) -> Option<T> {
|
||||
match result {
|
||||
Ok(val) => Some(val),
|
||||
Err(e) => {
|
||||
log::error!("{context}: {e}");
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
smol::block_on(async {
|
||||
let cli = Cli::parse();
|
||||
env_logger::Builder::new()
|
||||
.filter_level(cli.verbosity.into())
|
||||
.init();
|
||||
// Normal CLI handling
|
||||
smol::block_on(async {
|
||||
let cli = Cli::parse();
|
||||
env_logger::Builder::new()
|
||||
.filter_level(cli.verbosity.into())
|
||||
.init();
|
||||
|
||||
let db_path = cli.db_path.unwrap_or_else(|| {
|
||||
dirs::cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join("stash")
|
||||
.join("db")
|
||||
});
|
||||
let db_path = match cli.db_path {
|
||||
Some(path) => path,
|
||||
None => {
|
||||
let cache_dir = dirs::cache_dir().ok_or_else(|| {
|
||||
eyre::eyre!(
|
||||
"Could not determine cache directory. Set --db-path or \
|
||||
$STASH_DB_PATH explicitly."
|
||||
)
|
||||
})?;
|
||||
cache_dir.join("stash").join("db")
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(parent) = db_path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
log::error!("Failed to create database directory: {e}");
|
||||
process::exit(1);
|
||||
if let Some(parent) = db_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let conn = rusqlite::Connection::open(&db_path)?;
|
||||
let db = db::SqliteClipboardDb::new(conn, db_path)?;
|
||||
|
||||
match cli.command {
|
||||
Some(Command::Store) => {
|
||||
let state = env::var("STASH_CLIPBOARD_STATE").ok();
|
||||
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",
|
||||
);
|
||||
},
|
||||
Some(Command::List {
|
||||
format,
|
||||
expired,
|
||||
reverse,
|
||||
}) => {
|
||||
match format.as_deref() {
|
||||
Some("tsv") => {
|
||||
report_error(
|
||||
db.list(io::stdout(), cli.preview_width, expired, reverse),
|
||||
"failed to list entries",
|
||||
);
|
||||
},
|
||||
Some("json") => {
|
||||
match db.list_json(expired, reverse) {
|
||||
Ok(json) => {
|
||||
println!("{json}");
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("failed to list entries as JSON: {e}");
|
||||
},
|
||||
}
|
||||
},
|
||||
Some(other) => {
|
||||
log::error!("unsupported format: {other}");
|
||||
},
|
||||
None => {
|
||||
if std::io::stdout().is_terminal() {
|
||||
report_error(
|
||||
db.list_tui(cli.preview_width, expired, reverse),
|
||||
"failed to list entries in TUI",
|
||||
);
|
||||
} else {
|
||||
report_error(
|
||||
db.list(io::stdout(), cli.preview_width, expired, reverse),
|
||||
"failed to list entries",
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
Some(Command::Decode { input }) => {
|
||||
report_error(
|
||||
db.decode(io::stdin(), io::stdout(), input),
|
||||
"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?");
|
||||
|
||||
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();
|
||||
if !should_proceed {
|
||||
log::info!("aborted by user.");
|
||||
}
|
||||
}
|
||||
if should_proceed {
|
||||
match (arg, r#type.as_deref()) {
|
||||
(Some(s), Some("id")) => {
|
||||
if let Ok(id) = s.parse::<u64>() {
|
||||
use std::io::Cursor;
|
||||
report_error(
|
||||
db.store(io::stdin(), cli.max_dedupe_search, cli.max_items, state),
|
||||
"Failed to store entry",
|
||||
db.delete(Cursor::new(format!("{id}\n"))),
|
||||
"Failed to delete entry by id",
|
||||
);
|
||||
}
|
||||
Some(Command::List { format }) => {
|
||||
let format = format.as_deref().unwrap_or("tsv");
|
||||
match format {
|
||||
"tsv" => {
|
||||
report_error(
|
||||
db.list(io::stdout(), cli.preview_width),
|
||||
"Failed to list entries",
|
||||
);
|
||||
}
|
||||
"json" => {
|
||||
// Implement JSON output
|
||||
match db.list_json() {
|
||||
Ok(json) => {
|
||||
println!("{json}");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to list entries as JSON: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
log::error!("Unsupported format: {format}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Command::Decode { input }) => {
|
||||
report_error(
|
||||
db.decode(io::stdin(), io::stdout(), input),
|
||||
"Failed to decode entry",
|
||||
);
|
||||
}
|
||||
Some(Command::Delete { arg, r#type }) => match (arg, r#type.as_deref()) {
|
||||
(Some(s), Some("id")) => {
|
||||
if let Ok(id) = s.parse::<u64>() {
|
||||
use std::io::Cursor;
|
||||
report_error(
|
||||
db.delete(Cursor::new(format!("{id}\n"))),
|
||||
"Failed to delete entry by id",
|
||||
);
|
||||
} else {
|
||||
log::error!("Argument is not a valid id");
|
||||
}
|
||||
}
|
||||
(Some(s), Some("query")) => {
|
||||
report_error(db.query_delete(&s), "Failed to delete entry by query");
|
||||
}
|
||||
(Some(s), None) => {
|
||||
if let Ok(id) = s.parse::<u64>() {
|
||||
use std::io::Cursor;
|
||||
report_error(
|
||||
db.delete(Cursor::new(format!("{id}\n"))),
|
||||
"Failed to delete entry by id",
|
||||
);
|
||||
} else {
|
||||
report_error(db.query_delete(&s), "Failed to delete entry by query");
|
||||
}
|
||||
}
|
||||
(None, _) => {
|
||||
report_error(db.delete(io::stdin()), "Failed to delete entry from stdin");
|
||||
}
|
||||
(_, Some(_)) => {
|
||||
log::error!("Unknown type for --type. Use \"id\" or \"query\".");
|
||||
}
|
||||
} else {
|
||||
log::error!("argument is not a valid id");
|
||||
}
|
||||
},
|
||||
Some(Command::Wipe) => {
|
||||
report_error(db.wipe(), "Failed to wipe database");
|
||||
}
|
||||
|
||||
Some(Command::Import { r#type }) => {
|
||||
// Default format is TSV (Cliphist compatible)
|
||||
let format = r#type.as_deref().unwrap_or("tsv");
|
||||
match format {
|
||||
"tsv" => {
|
||||
db.import_tsv(io::stdin());
|
||||
}
|
||||
_ => {
|
||||
log::error!("Unsupported import format: {format}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Command::Watch) => {
|
||||
db.watch(cli.max_dedupe_search, cli.max_items);
|
||||
}
|
||||
None => {
|
||||
if let Err(e) = Cli::command().print_help() {
|
||||
eprintln!("Failed to print help: {e}");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
(Some(s), Some("query")) => {
|
||||
report_error(
|
||||
db.query_delete(&s),
|
||||
"failed to delete entry by query",
|
||||
);
|
||||
},
|
||||
(Some(s), None) => {
|
||||
if let Ok(id) = s.parse::<u64>() {
|
||||
use std::io::Cursor;
|
||||
report_error(
|
||||
db.delete(Cursor::new(format!("{id}\n"))),
|
||||
"failed to delete entry by id",
|
||||
);
|
||||
} else {
|
||||
report_error(
|
||||
db.query_delete(&s),
|
||||
"failed to delete entry by query",
|
||||
);
|
||||
}
|
||||
},
|
||||
(None, _) => {
|
||||
report_error(
|
||||
db.delete(io::stdin()),
|
||||
"failed to delete entry from stdin",
|
||||
);
|
||||
},
|
||||
(_, Some(_)) => {
|
||||
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::Import { r#type, ask }) => {
|
||||
let mut should_proceed = true;
|
||||
if ask {
|
||||
should_proceed = confirm(
|
||||
"Are you sure you want to import clipboard data? This may \
|
||||
overwrite existing entries.",
|
||||
);
|
||||
if !should_proceed {
|
||||
log::info!("import command aborted by user.");
|
||||
}
|
||||
}
|
||||
if should_proceed {
|
||||
let format = r#type.as_deref().unwrap_or("tsv");
|
||||
match format {
|
||||
"tsv" => {
|
||||
if let Err(e) =
|
||||
ImportCommand::import_tsv(&db, io::stdin(), cli.max_items)
|
||||
{
|
||||
log::error!("failed to import TSV: {e}");
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
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;
|
||||
},
|
||||
|
||||
None => {
|
||||
Cli::command().print_help()?;
|
||||
println!();
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
|
|
|||
273
src/mime.rs
Normal file
273
src/mime.rs
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
use imagesize::ImageType;
|
||||
|
||||
/// Detect MIME type of clipboard data. We try binary detection first using
|
||||
/// [`imagesize`] followed by a check for text/uri-list for file manager copies
|
||||
/// and finally fall back to text/plain for UTF-8 or [`None`] for binary.
|
||||
pub fn detect_mime(data: &[u8]) -> Option<String> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
6
src/multicall/mod.rs
Normal file
6
src/multicall/mod.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Reference documentation:
|
||||
// https://wayland.freedesktop.org/docs/html/apa.html#protocol-spec-wl_data_device
|
||||
// https://docs.rs/wl-clipboard-rs/latest/wl_clipboard_rs
|
||||
// https://github.com/YaLTeR/wl-clipboard-rs/blob/master/wl-clipboard-rs-tools/src/bin/wl_copy.rs
|
||||
pub mod wl_copy;
|
||||
pub mod wl_paste;
|
||||
296
src/multicall/wl_copy.rs
Normal file
296
src/multicall/wl_copy.rs
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
use std::io::{self, Read};
|
||||
|
||||
use clap::{ArgAction, Parser};
|
||||
use color_eyre::eyre::{Context, Result, bail};
|
||||
use wl_clipboard_rs::{
|
||||
copy::{
|
||||
ClipboardType as CopyClipboardType,
|
||||
MimeType as CopyMimeType,
|
||||
Options,
|
||||
Seat as CopySeat,
|
||||
ServeRequests,
|
||||
Source,
|
||||
},
|
||||
utils::{PrimarySelectionCheckError, is_primary_selection_supported},
|
||||
};
|
||||
|
||||
// Maximum clipboard content size to prevent memory exhaustion (100MB)
|
||||
const MAX_CLIPBOARD_SIZE: usize = 100 * 1024 * 1024;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "wl-copy",
|
||||
about = "Copy clipboard contents on Wayland.",
|
||||
version
|
||||
)]
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
struct WlCopyArgs {
|
||||
/// Serve only a single paste request and then exit
|
||||
#[arg(short = 'o', long = "paste-once", action = ArgAction::SetTrue)]
|
||||
paste_once: bool,
|
||||
|
||||
/// Stay in the foreground instead of forking
|
||||
#[arg(short = 'f', long = "foreground", action = ArgAction::SetTrue)]
|
||||
foreground: bool,
|
||||
|
||||
/// Clear the clipboard instead of copying
|
||||
#[arg(short = 'c', long = "clear", action = ArgAction::SetTrue)]
|
||||
clear: bool,
|
||||
|
||||
/// Use the "primary" clipboard
|
||||
#[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)]
|
||||
primary: bool,
|
||||
|
||||
/// Use the regular clipboard
|
||||
#[arg(short = 'r', long = "regular", action = ArgAction::SetTrue)]
|
||||
regular: bool,
|
||||
|
||||
/// Trim the trailing newline character before copying
|
||||
#[arg(short = 'n', long = "trim-newline", action = ArgAction::SetTrue)]
|
||||
trim_newline: bool,
|
||||
|
||||
/// Pick the seat to work with
|
||||
#[arg(short = 's', long = "seat")]
|
||||
seat: Option<String>,
|
||||
|
||||
/// Override the inferred MIME type for the content
|
||||
#[arg(short = 't', long = "type")]
|
||||
mime_type: Option<String>,
|
||||
|
||||
/// 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<usize>,
|
||||
|
||||
/// Text to copy (if not given, read from stdin)
|
||||
#[arg(value_name = "TEXT TO COPY", action = ArgAction::Append)]
|
||||
text: Vec<String>,
|
||||
}
|
||||
|
||||
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<Vec<u8>> {
|
||||
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(())
|
||||
}
|
||||
531
src/multicall/wl_paste.rs
Normal file
531
src/multicall/wl_paste.rs
Normal file
|
|
@ -0,0 +1,531 @@
|
|||
// https://wayland.freedesktop.org/docs/html/apa.html#protocol-spec-wl_data_device
|
||||
// https://docs.rs/wl-clipboard-rs/latest/wl_clipboard_rs
|
||||
// https://github.com/YaLTeR/wl-clipboard-rs/blob/master/wl-clipboard-rs-tools/src/bin/wl_paste.rs
|
||||
use std::{
|
||||
collections::hash_map::DefaultHasher,
|
||||
hash::{Hash, Hasher},
|
||||
io::{self, Read, Write},
|
||||
process::{Command, Stdio},
|
||||
sync::{Arc, Mutex},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use clap::{ArgAction, Parser};
|
||||
use color_eyre::eyre::{Context, Result, bail};
|
||||
use wl_clipboard_rs::paste::{
|
||||
ClipboardType as PasteClipboardType,
|
||||
Error as PasteError,
|
||||
MimeType as PasteMimeType,
|
||||
Seat as PasteSeat,
|
||||
get_contents,
|
||||
get_mime_types,
|
||||
};
|
||||
|
||||
// Watch mode timing constants
|
||||
const WATCH_POLL_INTERVAL_MS: u64 = 500;
|
||||
const WATCH_DEBOUNCE_INTERVAL_MS: u64 = 1000;
|
||||
|
||||
// Maximum clipboard content size to prevent memory exhaustion (100MB)
|
||||
const MAX_CLIPBOARD_SIZE: usize = 100 * 1024 * 1024;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "wl-paste",
|
||||
about = "Paste clipboard contents on Wayland.",
|
||||
version,
|
||||
disable_help_subcommand = true
|
||||
)]
|
||||
struct WlPasteArgs {
|
||||
/// List the offered MIME types instead of pasting
|
||||
#[arg(short = 'l', long = "list-types", action = ArgAction::SetTrue)]
|
||||
list_types: bool,
|
||||
|
||||
/// Use the "primary" clipboard
|
||||
#[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)]
|
||||
primary: bool,
|
||||
|
||||
/// Do not append a newline character
|
||||
#[arg(short = 'n', long = "no-newline", action = ArgAction::SetTrue)]
|
||||
no_newline: bool,
|
||||
|
||||
/// Pick the seat to work with
|
||||
#[arg(short = 's', long = "seat")]
|
||||
seat: Option<String>,
|
||||
|
||||
/// Request the given MIME type instead of inferring the MIME type
|
||||
#[arg(short = 't', long = "type")]
|
||||
mime_type: Option<String>,
|
||||
|
||||
/// 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<Vec<String>>,
|
||||
}
|
||||
|
||||
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::<u64>));
|
||||
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<u64> {
|
||||
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<String>,
|
||||
) -> Option<String> {
|
||||
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(())
|
||||
}
|
||||
179
src/wayland/mod.rs
Normal file
179
src/wayland/mod.rs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
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<String> = ArcSwapOption::const_empty();
|
||||
static TOPLEVEL_APPS: LazyLock<Mutex<HashMap<ObjectId, String>>> =
|
||||
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<String> {
|
||||
// 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<dyn std::error::Error>> {
|
||||
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<wl_registry::WlRegistry, ()> for AppState {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
registry: &wl_registry::WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_data: &(),
|
||||
_conn: &WaylandConnection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) -> std::sync::Arc<dyn wayland_client::backend::ObjectData> {
|
||||
qhandle.make_data::<ZwlrForeignToplevelManagerV1, ()>(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ZwlrForeignToplevelManagerV1, ()> for AppState {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_manager: &ZwlrForeignToplevelManagerV1,
|
||||
event: zwlr_foreign_toplevel_manager_v1::Event,
|
||||
_data: &(),
|
||||
_conn: &WaylandConnection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) -> std::sync::Arc<dyn wayland_client::backend::ObjectData> {
|
||||
qhandle.make_data::<ZwlrForeignToplevelHandleV1, ()>(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ZwlrForeignToplevelHandleV1, ()> for AppState {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
handle: &ZwlrForeignToplevelHandleV1,
|
||||
event: zwlr_foreign_toplevel_handle_v1::Event,
|
||||
_data: &(),
|
||||
_conn: &WaylandConnection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
) {
|
||||
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<u8> = 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<Self>,
|
||||
) -> std::sync::Arc<dyn wayland_client::backend::ObjectData> {
|
||||
qhandle.make_data::<ZwlrForeignToplevelHandleV1, ()>(())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue