mirror of
https://github.com/NotAShelf/stash.git
synced 2026-04-18 08:29:53 +00:00
Compare commits
71 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 |
28 changed files with 4696 additions and 954 deletions
22
.github/dependabot.yaml
vendored
22
.github/dependabot.yaml
vendored
|
|
@ -1,13 +1,23 @@
|
||||||
version: 2
|
version: 2
|
||||||
updates:
|
updates:
|
||||||
# Update Cargo deps
|
|
||||||
- package-ecosystem: cargo
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
|
|
||||||
# Update used workflows
|
# Update used workflows
|
||||||
- package-ecosystem: github-actions
|
- package-ecosystem: github-actions
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: daily
|
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
|
||||||
|
|
|
||||||
2
.github/workflows/nix-cache.yaml
vendored
2
.github/workflows/nix-cache.yaml
vendored
|
|
@ -20,7 +20,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
nix_path: nixpkgs=channel:nixos-unstable
|
nix_path: nixpkgs=channel:nixos-unstable
|
||||||
|
|
||||||
- uses: cachix/cachix-action@v16
|
- uses: cachix/cachix-action@v17
|
||||||
with:
|
with:
|
||||||
name: nyx
|
name: nyx
|
||||||
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
|
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
|
||||||
|
|
|
||||||
6
.github/workflows/release.yaml
vendored
6
.github/workflows/release.yaml
vendored
|
|
@ -40,7 +40,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
id: create_release
|
id: create_release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|
@ -98,7 +98,7 @@ jobs:
|
||||||
cp target/${{ matrix.target }}/release/stash ${{ matrix.name }}
|
cp target/${{ matrix.target }}/release/stash ${{ matrix.name }}
|
||||||
|
|
||||||
- name: Upload Release Asset
|
- name: Upload Release Asset
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
files: ${{ matrix.name }}
|
files: ${{ matrix.name }}
|
||||||
|
|
||||||
|
|
@ -120,7 +120,7 @@ jobs:
|
||||||
sha256sum stash-* > SHA256SUMS
|
sha256sum stash-* > SHA256SUMS
|
||||||
|
|
||||||
- name: Upload Checksums
|
- name: Upload Checksums
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
files: SHA256SUMS
|
files: SHA256SUMS
|
||||||
|
|
|
||||||
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
|
||||||
1084
Cargo.lock
generated
1084
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
47
Cargo.toml
47
Cargo.toml
|
|
@ -1,56 +1,59 @@
|
||||||
[package]
|
[package]
|
||||||
name = "stash-clipboard"
|
name = "stash-clipboard"
|
||||||
description = "Wayland clipboard manager with fast persistent history and multi-media support"
|
description = "Wayland clipboard manager with fast persistent history and multi-media support"
|
||||||
version = "0.3.4"
|
version = "0.3.6"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
authors = ["NotAShelf <raf@notashelf.dev>"]
|
authors = [ "NotAShelf <raf@notashelf.dev>" ]
|
||||||
license = "MPL-2.0"
|
license = "MPL-2.0"
|
||||||
readme = true
|
readme = true
|
||||||
repository = "https://github.com/notashelf/stash"
|
repository = "https://github.com/notashelf/stash"
|
||||||
rust-version = "1.90"
|
rust-version = "1.91.0"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "stash" # actual binary name for Nix, Cargo, etc.
|
name = "stash" # actual binary name for Nix, Cargo, etc.
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
arc-swap = { version = "1.9.1", optional = true }
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
clap = { version = "4.5.54", features = ["derive", "env"] }
|
blocking = "1.6.2"
|
||||||
|
clap = { version = "4.6.0", features = [ "derive", "env" ] }
|
||||||
clap-verbosity-flag = "3.0.4"
|
clap-verbosity-flag = "3.0.4"
|
||||||
color-eyre = "0.6.5"
|
color-eyre = "0.6.5"
|
||||||
crossterm = "0.29.0"
|
crossterm = "0.29.0"
|
||||||
ctrlc = "3.5.1"
|
ctrlc = "3.5.2"
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
env_logger = "0.11.8"
|
env_logger = "0.11.10"
|
||||||
|
humantime = "2.3.0"
|
||||||
imagesize = "0.14.0"
|
imagesize = "0.14.0"
|
||||||
inquire = { version = "0.9.2", default-features = false, features = [
|
inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] }
|
||||||
"crossterm",
|
libc = "0.2.184"
|
||||||
] }
|
|
||||||
libc = "0.2.180"
|
|
||||||
log = "0.4.29"
|
log = "0.4.29"
|
||||||
notify-rust = { version = "4.11.7", optional = true }
|
mime-sniffer = "0.1.3"
|
||||||
|
notify-rust = { version = "4.14.0", optional = true }
|
||||||
ratatui = "0.30.0"
|
ratatui = "0.30.0"
|
||||||
regex = "1.12.2"
|
regex = "1.12.3"
|
||||||
rusqlite = { version = "0.38.0", features = ["bundled"] }
|
rusqlite = { version = "0.39.0", features = [ "bundled" ] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = [ "derive" ] }
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
smol = "2.0.2"
|
smol = "2.0.2"
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
unicode-segmentation = "1.12.0"
|
unicode-segmentation = "1.13.2"
|
||||||
unicode-width = "0.2.2"
|
unicode-width = "0.2.2"
|
||||||
wayland-client = { version = "0.31.12", features = ["log"], optional = true }
|
wayland-client = { version = "0.31.14", features = [ "log" ], optional = true }
|
||||||
wayland-protocols-wlr = { version = "0.3.10", default-features = false, optional = true }
|
wayland-protocols-wlr = { version = "0.3.12", default-features = false, optional = true }
|
||||||
wl-clipboard-rs = "0.9.3"
|
wl-clipboard-rs = "0.9.3"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3.18.0"
|
futures = "0.3.32"
|
||||||
|
tempfile = "3.27.0"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["notifications", "use-toplevel"]
|
default = [ "notifications", "use-toplevel" ]
|
||||||
notifications = ["dep:notify-rust"]
|
notifications = [ "dep:notify-rust" ]
|
||||||
use-toplevel = ["dep:wayland-client", "dep:wayland-protocols-wlr"]
|
use-toplevel = [ "dep:arc-swap", "dep:wayland-client", "dep:wayland-protocols-wlr" ]
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
lto = true
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
strip = true
|
strip = true
|
||||||
lto = true
|
|
||||||
|
|
|
||||||
245
README.md
245
README.md
|
|
@ -20,7 +20,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
Lightweight Wayland clipboard "manager" with fast persistent history and
|
Lightweight & feature-rich Wayland clipboard "manager" with fast persistent history and
|
||||||
robust multi-media support. Stores and previews clipboard entries (text, images)
|
robust multi-media support. Stores and previews clipboard entries (text, images)
|
||||||
on the clipboard with a neat TUI and advanced scripting capabilities.
|
on the clipboard with a neat TUI and advanced scripting capabilities.
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<br/>
|
<br/>
|
||||||
<a href="#features">Features</a><br/>
|
<a href="#features">Features</a><br/>
|
||||||
<a href="#installation">Installation</a> | <a href="#usage">Usage</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>
|
<a href="#tips--tricks">Tips and Tricks</a>
|
||||||
<br/>
|
<br/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -45,21 +45,35 @@ with many features such as but not necessarily limited to:
|
||||||
- Import clipboard history from TSV (e.g., from `cliphist list`)
|
- Import clipboard history from TSV (e.g., from `cliphist list`)
|
||||||
- Image preview (shows dimensions and format)
|
- Image preview (shows dimensions and format)
|
||||||
- Text previews with customizable width
|
- Text previews with customizable width
|
||||||
- Deduplication and entry limit control
|
- De-duplication, whitespace prevention and entry limit control
|
||||||
- Automatic clipboard monitoring with `stash watch`
|
- 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`)
|
- Drop-in replacement for `wl-clipboard` tools (`wl-copy` and `wl-paste`)
|
||||||
- Sensitive clipboard filtering via regex (see below)
|
- Sensitive clipboard filtering via regex (see below)
|
||||||
- Sensitive clipboard filtering by application (see below)
|
- Sensitive clipboard filtering by application (see below)
|
||||||
|
|
||||||
See [usage section](#usage) for more details.
|
on top of the existing features of Cliphist, which are as follows:
|
||||||
|
|
||||||
|
- Write clipboard changes to a history file.
|
||||||
|
- Recall history with dmenu, rofi, wofi (or whatever other picker you like).
|
||||||
|
- Both text and images are supported.
|
||||||
|
- Clipboard is preserved byte-for-byte.
|
||||||
|
- Leading/trailing whitespace, no whitespace, or newlines are preserved.
|
||||||
|
- Won’t break fancy editor selections like Vim wordwise, linewise, or block
|
||||||
|
mode.
|
||||||
|
|
||||||
|
Most of Stash's usage is documented in the [usage section](#usage) for more
|
||||||
|
details. Refer to the [Tips & Tricks section](#tips--tricks) for more "advanced"
|
||||||
|
features, or conveniences provided by Stash.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### With Nix
|
### With Nix
|
||||||
|
|
||||||
Nix is the recommended way of downloading Stash. You can install it using Nix
|
Nix is the recommended way of downloading (and developing!) Stash. You can
|
||||||
flakes using `nix profile add` if on non-nixos or add Stash as a flake input if
|
install it using Nix flakes using `nix profile add` if on non-nixos or add Stash
|
||||||
you are on NixOS.
|
as a flake input if you are on NixOS.
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
{
|
{
|
||||||
|
|
@ -90,7 +104,8 @@ If you want to give Stash a try before you switch to it, you may also run it one
|
||||||
time with `nix run`.
|
time with `nix run`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nix run github:NotAShelf/stash -- watch # start the watch daemon
|
# Run directly from the git repository; will be garbage collected
|
||||||
|
$ nix run github:NotAShelf/stash -- watch # start the watch daemon
|
||||||
```
|
```
|
||||||
|
|
||||||
### Without Nix
|
### Without Nix
|
||||||
|
|
@ -109,16 +124,23 @@ releases are made when a version gets tagged, and are available under
|
||||||
- Build and install from source with Cargo:
|
- Build and install from source with Cargo:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo install --git https://github.com/notashelf/stash
|
cargo install stash --locked
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Additionally, you may get Stash from source via `cargo install` using
|
||||||
|
`cargo install --git https://github.com/notashelf/stash --locked` or you may
|
||||||
|
check out to the repository, and use Cargo to build it. You'll need Rust 1.91.0
|
||||||
|
or above. Most distributions should package this version already. You may, of
|
||||||
|
course, prefer to package the built releases if you'd like.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
> [!NOTE]
|
> [!IMPORTANT]
|
||||||
> It is not a priority to provide 1:1 backwards compatibility with Cliphist.
|
> It is not a priority to provide 1:1 backwards compatibility with Cliphist.
|
||||||
> While the interface is _almost_ identical, Stash chooses to build upon
|
> While the interface is generally similar, Stash chooses to build upon
|
||||||
> Cliphist's design and extend existing design choices. See
|
> Cliphist's design and extend existing design choices. See
|
||||||
> [Migrating from Cliphist](#migrating-from-cliphist) for more details.
|
> [Migrating from Cliphist](#migrating-from-cliphist) for more details. Refer to
|
||||||
|
> help text if confused.
|
||||||
|
|
||||||
The command interface of Stash is _only slightly_ different from Cliphist. In
|
The command interface of Stash is _only slightly_ different from Cliphist. In
|
||||||
most cases, you may simply replace `cliphist` with `stash` and your commands,
|
most cases, you may simply replace `cliphist` with `stash` and your commands,
|
||||||
|
|
@ -141,7 +163,7 @@ Commands:
|
||||||
list List clipboard history
|
list List clipboard history
|
||||||
decode Decode and output clipboard entry by id
|
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
|
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
|
||||||
wipe Wipe all clipboard history
|
db Database management operations
|
||||||
import Import clipboard data from stdin (default: TSV format)
|
import Import clipboard data from stdin (default: TSV format)
|
||||||
watch Start a process to watch clipboard for changes and store automatically
|
watch Start a process to watch clipboard for changes and store automatically
|
||||||
help Print this message or the help of the given subcommand(s)
|
help Print this message or the help of the given subcommand(s)
|
||||||
|
|
@ -154,7 +176,7 @@ Options:
|
||||||
--preview-width <PREVIEW_WIDTH>
|
--preview-width <PREVIEW_WIDTH>
|
||||||
Maximum width (in characters) for clipboard entry previews in list output [default: 100]
|
Maximum width (in characters) for clipboard entry previews in list output [default: 100]
|
||||||
--db-path <DB_PATH>
|
--db-path <DB_PATH>
|
||||||
Path to the `SQLite` clipboard database file
|
Path to the `SQLite` clipboard database file [env: STASH_DB_PATH=]
|
||||||
--excluded-apps <EXCLUDED_APPS>
|
--excluded-apps <EXCLUDED_APPS>
|
||||||
Application names to exclude from clipboard history [env: STASH_EXCLUDED_APPS=]
|
Application names to exclude from clipboard history [env: STASH_EXCLUDED_APPS=]
|
||||||
--ask
|
--ask
|
||||||
|
|
@ -188,6 +210,11 @@ 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
|
display entries in Cliphist-compatible TSV format in Bash scripts. You may also
|
||||||
enforce the output format with `stash list --format <tsv | json>`.
|
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
|
### Decode an entry by ID
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -219,10 +246,33 @@ stash delete --type id < ids.txt
|
||||||
|
|
||||||
### Wipe all entries
|
### Wipe all entries
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> This command is deprecated, and will be removed in v0.4.0. Use `stash db wipe`
|
||||||
|
> instead.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
stash wipe
|
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
|
### Watch clipboard for changes and store automatically
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -235,13 +285,59 @@ automatically. This is designed as an alternative to shelling out to
|
||||||
premade Systemd service in `contrib/`. Packagers are encouraged to vendor the
|
premade Systemd service in `contrib/`. Packagers are encouraged to vendor the
|
||||||
service unless adding their own.
|
service unless adding their own.
|
||||||
|
|
||||||
> [!TIP]
|
#### Automatic Clipboard Clearing on Expiration
|
||||||
> Stash provides `wl-copy` and `wl-paste` binaries for backwards compatibility
|
|
||||||
> with the `wl-clipboard` tools. If _must_ depend on those binaries by name, you
|
When `stash watch` is running and a clipboard entry expires, Stash will detect
|
||||||
> may simply use the `wl-copy` and `wl-paste` provided as `wl-clipboard-rs`
|
if the current clipboard still contains that expired content and automatically
|
||||||
> wrappers on your system. In other words, you can use
|
clear it. This prevents stale data from remaining in your clipboard after an
|
||||||
> `wl-paste --watch stash store` as an alternative to `stash watch` if
|
entry has expired from history.
|
||||||
> preferred.
|
|
||||||
|
> [!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
|
### Options
|
||||||
|
|
||||||
|
|
@ -319,6 +415,20 @@ be only copied to the clipboard.
|
||||||
>
|
>
|
||||||
> `stash --excluded-apps Bitwarden watch`
|
> `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
|
## Tips & Tricks
|
||||||
|
|
||||||
### Migrating from Cliphist
|
### Migrating from Cliphist
|
||||||
|
|
@ -406,6 +516,87 @@ figured out something new, e.g. a neat shell trick, feel free to add it here!
|
||||||
the packagers. While building from source, you may link
|
the packagers. While building from source, you may link
|
||||||
`target/release/stash` manually.
|
`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
|
## Attributions
|
||||||
|
|
||||||
My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the
|
My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the
|
||||||
|
|
@ -413,8 +604,14 @@ My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the
|
||||||
powered by [several crates](./Cargo.toml), but none of them were as detrimental
|
powered by [several crates](./Cargo.toml), but none of them were as detrimental
|
||||||
in Stash's design process.
|
in Stash's design process.
|
||||||
|
|
||||||
Additional thanks to my testers, who have tested earlier versions of Stash and
|
Secondly, but by no means less importantly, I would like to thank
|
||||||
provided feedback. Thank you :)
|
[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
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
46
build.rs
46
build.rs
|
|
@ -1,46 +0,0 @@
|
||||||
use std::{env, fs, path::Path};
|
|
||||||
|
|
||||||
/// List of multicall symlinks to create (name, target)
|
|
||||||
const MULTICALL_LINKS: &[&str] =
|
|
||||||
&["stash-copy", "stash-paste", "wl-copy", "wl-paste"];
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
// OUT_DIR is something like .../target/debug/build/<pkg>/out
|
|
||||||
// We want .../target/debug or .../target/release
|
|
||||||
let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set");
|
|
||||||
let bin_dir = Path::new(&out_dir)
|
|
||||||
.ancestors()
|
|
||||||
.nth(3)
|
|
||||||
.expect("Failed to find binary dir");
|
|
||||||
|
|
||||||
// Path to the main stash binary
|
|
||||||
let stash_bin = bin_dir.join("stash");
|
|
||||||
|
|
||||||
// Create symlinks for each multicall binary
|
|
||||||
for link in MULTICALL_LINKS {
|
|
||||||
let link_path = bin_dir.join(link);
|
|
||||||
// Remove existing symlink or file if present
|
|
||||||
let _ = fs::remove_file(&link_path);
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::symlink;
|
|
||||||
match symlink(&stash_bin, &link_path) {
|
|
||||||
Ok(()) => {
|
|
||||||
println!(
|
|
||||||
"cargo:warning=Created symlink: {} -> {}",
|
|
||||||
link_path.display(),
|
|
||||||
stash_bin.display()
|
|
||||||
);
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
println!(
|
|
||||||
"cargo:warning=Failed to create symlink {} -> {}: {}",
|
|
||||||
link_path.display(),
|
|
||||||
stash_bin.display(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
12
flake.lock
generated
12
flake.lock
generated
|
|
@ -2,11 +2,11 @@
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"crane": {
|
"crane": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1766194365,
|
"lastModified": 1775839657,
|
||||||
"narHash": "sha256-4AFsUZ0kl6MXSm4BaQgItD0VGlEKR3iq7gIaL7TjBvc=",
|
"narHash": "sha256-SPm9ck7jh3Un9nwPuMGbRU04UroFmOHjLP56T10MOeM=",
|
||||||
"owner": "ipetkov",
|
"owner": "ipetkov",
|
||||||
"repo": "crane",
|
"repo": "crane",
|
||||||
"rev": "7d8ec2c71771937ab99790b45e6d9b93d15d9379",
|
"rev": "7cf72d978629469c4bd4206b95c402514c1f6000",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -17,11 +17,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1766309749,
|
"lastModified": 1775710090,
|
||||||
"narHash": "sha256-3xY8CZ4rSnQ0NqGhMKAy5vgC+2IVK0NoVEzDoOh4DA4=",
|
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "a6531044f6d0bef691ea18d4d4ce44d0daa6e816",
|
"rev": "4c1018dae018162ec878d42fec712642d214fdfa",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@
|
||||||
stdenv,
|
stdenv,
|
||||||
mold,
|
mold,
|
||||||
versionCheckHook,
|
versionCheckHook,
|
||||||
|
useMold ? stdenv.isLinux,
|
||||||
|
createSymlinks ? true,
|
||||||
}: let
|
}: let
|
||||||
pname = "stash";
|
pname = "stash";
|
||||||
version = (builtins.fromTOML (builtins.readFile ../Cargo.toml)).package.version;
|
version = (lib.importTOML ../Cargo.toml).package.version;
|
||||||
src = let
|
src = let
|
||||||
fs = lib.fileset;
|
fs = lib.fileset;
|
||||||
s = ../.;
|
s = ../.;
|
||||||
|
|
@ -17,7 +19,6 @@
|
||||||
(fs.fileFilter (file: builtins.any file.hasExt ["rs"]) (s + /src))
|
(fs.fileFilter (file: builtins.any file.hasExt ["rs"]) (s + /src))
|
||||||
(s + /Cargo.lock)
|
(s + /Cargo.lock)
|
||||||
(s + /Cargo.toml)
|
(s + /Cargo.toml)
|
||||||
(s + /build.rs)
|
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -36,7 +37,7 @@ in
|
||||||
# generated by the build wrapper are correctly linked, we should link
|
# generated by the build wrapper are correctly linked, we should link
|
||||||
# them *manually*. The postInstallCheck phase that follows will check
|
# them *manually*. The postInstallCheck phase that follows will check
|
||||||
# to verify if all of those links are in place.
|
# to verify if all of those links are in place.
|
||||||
postInstall = ''
|
postInstall = lib.optionalString createSymlinks ''
|
||||||
mkdir -p $out
|
mkdir -p $out
|
||||||
for bin in stash-copy stash-paste wl-copy wl-paste; do
|
for bin in stash-copy stash-paste wl-copy wl-paste; do
|
||||||
ln -sf $out/bin/stash $out/bin/$bin
|
ln -sf $out/bin/stash $out/bin/$bin
|
||||||
|
|
@ -48,13 +49,13 @@ in
|
||||||
|
|
||||||
# After the version check, let's see if all binaries are linked correctly.
|
# 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.
|
# We could probably add a check phase to get the versions of each.
|
||||||
postInstallCheck = ''
|
postInstallCheck = lib.optionalString createSymlinks ''
|
||||||
for bin in stash stash-copy stash-paste wl-copy wl-paste; do
|
for bin in stash stash-copy stash-paste wl-copy wl-paste; do
|
||||||
[ -x "$out/bin/$bin" ] || { echo "$bin missing"; exit 1; }
|
[ -x "$out/bin/$bin" ] || { echo "$bin missing"; exit 1; }
|
||||||
done
|
done
|
||||||
'';
|
'';
|
||||||
|
|
||||||
env = lib.optionalAttrs (stdenv.isLinux && !stdenv.hostPlatform.isAarch) {
|
env = lib.optionalAttrs useMold {
|
||||||
CARGO_LINKER = "clang";
|
CARGO_LINKER = "clang";
|
||||||
CARGO_RUSTFLAGS = "-Clink-arg=-fuse-ld=${mold}/bin/mold";
|
CARGO_RUSTFLAGS = "-Clink-arg=-fuse-ld=${mold}/bin/mold";
|
||||||
};
|
};
|
||||||
|
|
@ -65,5 +66,6 @@ in
|
||||||
license = lib.licenses.mpl20;
|
license = lib.licenses.mpl20;
|
||||||
maintainers = [lib.maintainers.NotAShelf];
|
maintainers = [lib.maintainers.NotAShelf];
|
||||||
mainProgram = "stash";
|
mainProgram = "stash";
|
||||||
|
platforms = lib.platforms.linux;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,7 +32,7 @@ impl DecodeCommand for SqliteClipboardDb {
|
||||||
|
|
||||||
// If input is empty or whitespace, treat as error and trigger fallback
|
// If input is empty or whitespace, treat as error and trigger fallback
|
||||||
if input_str.trim().is_empty() {
|
if input_str.trim().is_empty() {
|
||||||
log::debug!("No input provided to decode; relaying clipboard to stdout");
|
log::debug!("no input provided to decode; relaying clipboard to stdout");
|
||||||
if let Ok((mut reader, _mime)) =
|
if let Ok((mut reader, _mime)) =
|
||||||
get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Any)
|
get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Any)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ pub trait DeleteCommand {
|
||||||
impl DeleteCommand for SqliteClipboardDb {
|
impl DeleteCommand for SqliteClipboardDb {
|
||||||
fn delete(&self, input: impl Read) -> Result<usize, StashError> {
|
fn delete(&self, input: impl Read) -> Result<usize, StashError> {
|
||||||
let deleted = self.delete_entries(input)?;
|
let deleted = self.delete_entries(input)?;
|
||||||
log::info!("Deleted {deleted} entries");
|
log::info!("deleted {deleted} entries");
|
||||||
Ok(deleted)
|
Ok(deleted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
use std::io::{self, BufRead};
|
use std::io::{self, BufRead};
|
||||||
|
|
||||||
use crate::db::{
|
use crate::db::{ClipboardDb, Entry, SqliteClipboardDb, StashError};
|
||||||
ClipboardDb,
|
|
||||||
Entry,
|
|
||||||
SqliteClipboardDb,
|
|
||||||
StashError,
|
|
||||||
detect_mime,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub trait ImportCommand {
|
pub trait ImportCommand {
|
||||||
/// Import clipboard entries from TSV format.
|
/// Import clipboard entries from TSV format.
|
||||||
|
|
@ -44,7 +38,7 @@ impl ImportCommand for SqliteClipboardDb {
|
||||||
|
|
||||||
let entry = Entry {
|
let entry = Entry {
|
||||||
contents: val.as_bytes().to_vec(),
|
contents: val.as_bytes().to_vec(),
|
||||||
mime: detect_mime(val.as_bytes()),
|
mime: crate::mime::detect_mime(val.as_bytes()),
|
||||||
};
|
};
|
||||||
|
|
||||||
self
|
self
|
||||||
|
|
@ -61,11 +55,11 @@ impl ImportCommand for SqliteClipboardDb {
|
||||||
imported += 1;
|
imported += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("Imported {imported} records from TSV into SQLite database.");
|
log::info!("imported {imported} records from TSV into SQLite database.");
|
||||||
|
|
||||||
// Trim database to max_items after import
|
// Trim database to max_items after import
|
||||||
self.trim_db(max_items)?;
|
self.trim_db(max_items)?;
|
||||||
log::info!("Trimmed clipboard database to max_items = {max_items}");
|
log::info!("trimmed clipboard database to max_items = {max_items}");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,13 @@ use unicode_width::UnicodeWidthStr;
|
||||||
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
|
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
|
||||||
|
|
||||||
pub trait ListCommand {
|
pub trait ListCommand {
|
||||||
fn list(&self, out: impl Write, preview_width: u32)
|
fn list(
|
||||||
-> Result<(), StashError>;
|
&self,
|
||||||
|
out: impl Write,
|
||||||
|
preview_width: u32,
|
||||||
|
include_expired: bool,
|
||||||
|
reverse: bool,
|
||||||
|
) -> Result<(), StashError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ListCommand for SqliteClipboardDb {
|
impl ListCommand for SqliteClipboardDb {
|
||||||
|
|
@ -15,14 +20,267 @@ impl ListCommand for SqliteClipboardDb {
|
||||||
&self,
|
&self,
|
||||||
out: impl Write,
|
out: impl Write,
|
||||||
preview_width: u32,
|
preview_width: u32,
|
||||||
|
include_expired: bool,
|
||||||
|
reverse: bool,
|
||||||
) -> Result<(), StashError> {
|
) -> Result<(), StashError> {
|
||||||
self.list_entries(out, preview_width).map(|_| ())
|
self
|
||||||
|
.list_entries(out, preview_width, include_expired, reverse)
|
||||||
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All mutable state for the TUI list view.
|
||||||
|
struct TuiState {
|
||||||
|
/// Total number of entries matching the current filter in the DB.
|
||||||
|
total: usize,
|
||||||
|
|
||||||
|
/// Global cursor position: index into the full ordered result set.
|
||||||
|
cursor: usize,
|
||||||
|
|
||||||
|
/// DB offset of `window[0]`, i.e., the first row currently loaded.
|
||||||
|
viewport_offset: usize,
|
||||||
|
|
||||||
|
/// The loaded slice of entries: `(id, preview, mime)`.
|
||||||
|
window: Vec<(i64, String, String)>,
|
||||||
|
|
||||||
|
/// How many rows the window holds (== visible list height).
|
||||||
|
window_size: usize,
|
||||||
|
|
||||||
|
/// Whether the window needs to be re-fetched from the DB.
|
||||||
|
dirty: bool,
|
||||||
|
|
||||||
|
/// Current search query. Empty string means no filter.
|
||||||
|
search_query: String,
|
||||||
|
|
||||||
|
/// Whether we're currently in search input mode.
|
||||||
|
search_mode: bool,
|
||||||
|
|
||||||
|
/// Whether to show entries in reverse order (oldest first).
|
||||||
|
reverse: bool,
|
||||||
|
|
||||||
|
/// ID of entry currently being copied.
|
||||||
|
copying_entry: Option<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 {
|
impl SqliteClipboardDb {
|
||||||
#[allow(clippy::too_many_lines)]
|
#[allow(clippy::too_many_lines)]
|
||||||
pub fn list_tui(&self, preview_width: u32) -> Result<(), StashError> {
|
pub fn list_tui(
|
||||||
|
&self,
|
||||||
|
preview_width: u32,
|
||||||
|
include_expired: bool,
|
||||||
|
reverse: bool,
|
||||||
|
) -> Result<(), StashError> {
|
||||||
use std::io::stdout;
|
use std::io::stdout;
|
||||||
|
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
|
|
@ -52,42 +310,9 @@ impl SqliteClipboardDb {
|
||||||
};
|
};
|
||||||
use wl_clipboard_rs::copy::{MimeType, Options, Source};
|
use wl_clipboard_rs::copy::{MimeType, Options, Source};
|
||||||
|
|
||||||
// Query entries from DB
|
// One-time column-width metadata (no blob reads).
|
||||||
let mut stmt = self
|
let (max_id_width, max_mime_width) =
|
||||||
.conn
|
global_column_widths(self, include_expired)?;
|
||||||
.prepare(
|
|
||||||
"SELECT id, contents, mime FROM clipboard ORDER BY last_accessed \
|
|
||||||
DESC, id DESC",
|
|
||||||
)
|
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
|
||||||
let mut rows = stmt
|
|
||||||
.query([])
|
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
|
||||||
|
|
||||||
let mut entries: Vec<(i64, String, String)> = Vec::new();
|
|
||||||
let mut max_id_width = 2;
|
|
||||||
let mut max_mime_width = 8;
|
|
||||||
while let Some(row) = rows
|
|
||||||
.next()
|
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
|
|
||||||
{
|
|
||||||
let id: i64 = row
|
|
||||||
.get(0)
|
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
|
||||||
let contents: Vec<u8> = row
|
|
||||||
.get(1)
|
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
|
||||||
let mime: Option<String> = row
|
|
||||||
.get(2)
|
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
|
||||||
let preview =
|
|
||||||
crate::db::preview_entry(&contents, mime.as_deref(), preview_width);
|
|
||||||
let mime_str = mime.as_deref().unwrap_or("").to_string();
|
|
||||||
let id_str = id.to_string();
|
|
||||||
max_id_width = max_id_width.max(id_str.width());
|
|
||||||
max_mime_width = max_mime_width.max(mime_str.width());
|
|
||||||
entries.push((id, preview, mime_str));
|
|
||||||
}
|
|
||||||
|
|
||||||
enable_raw_mode()
|
enable_raw_mode()
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||||
|
|
@ -98,35 +323,160 @@ impl SqliteClipboardDb {
|
||||||
let mut terminal = Terminal::new(backend)
|
let mut terminal = Terminal::new(backend)
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||||
|
|
||||||
let mut state = ListState::default();
|
// Derive initial window size from current terminal height.
|
||||||
if !entries.is_empty() {
|
let initial_height = terminal
|
||||||
state.select(Some(0));
|
.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()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let res = (|| -> Result<(), StashError> {
|
|
||||||
loop {
|
|
||||||
terminal
|
terminal
|
||||||
.draw(|f| {
|
.draw(|f| {
|
||||||
let area = f.area();
|
let area = f.area();
|
||||||
let block = Block::default()
|
|
||||||
.title(
|
// Build title based on search state
|
||||||
"Clipboard Entries (j/k/↑/↓ to move, Enter to copy, Shift+D \
|
let title = if tui.search_mode {
|
||||||
to delete, q/ESC to quit)",
|
format!("Search: {}", tui.search_query)
|
||||||
|
} else if tui.search_query.is_empty() {
|
||||||
|
"Clipboard Entries (j/k/↑/↓ to move, / to search, Enter to copy, \
|
||||||
|
Shift+D to delete, q/ESC to quit)"
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Clipboard Entries (filtered: '{}' - {} results, / to search, \
|
||||||
|
ESC to clear, q to quit)",
|
||||||
|
tui.search_query, tui.total
|
||||||
)
|
)
|
||||||
.borders(Borders::ALL);
|
};
|
||||||
|
|
||||||
|
let block = Block::default().title(title).borders(Borders::ALL);
|
||||||
|
|
||||||
let border_width = 2;
|
let border_width = 2;
|
||||||
let highlight_symbol = ">";
|
let highlight_symbol = ">";
|
||||||
let highlight_width = 1;
|
let highlight_width = 1;
|
||||||
let content_width = area.width as usize - border_width;
|
let content_width = area.width as usize - border_width;
|
||||||
|
|
||||||
// Minimum widths for columns
|
|
||||||
let min_id_width = 2;
|
let min_id_width = 2;
|
||||||
let min_mime_width = 6;
|
let min_mime_width = 6;
|
||||||
let min_preview_width = 4;
|
let min_preview_width = 4;
|
||||||
let spaces = 3; // [id][ ][preview][ ][mime]
|
let spaces = 3;
|
||||||
|
|
||||||
// Dynamically allocate widths
|
|
||||||
let mut id_col = max_id_width.max(min_id_width);
|
let mut id_col = max_id_width.max(min_id_width);
|
||||||
let mut mime_col = max_mime_width.max(min_mime_width);
|
let mut mime_col = max_mime_width.max(min_mime_width);
|
||||||
let mut preview_col = content_width
|
let mut preview_col = content_width
|
||||||
|
|
@ -135,7 +485,6 @@ impl SqliteClipboardDb {
|
||||||
.saturating_sub(mime_col)
|
.saturating_sub(mime_col)
|
||||||
.saturating_sub(spaces);
|
.saturating_sub(spaces);
|
||||||
|
|
||||||
// If not enough space, shrink columns
|
|
||||||
if preview_col < min_preview_width {
|
if preview_col < min_preview_width {
|
||||||
let needed = min_preview_width - preview_col;
|
let needed = min_preview_width - preview_col;
|
||||||
if mime_col > min_mime_width {
|
if mime_col > min_mime_width {
|
||||||
|
|
@ -158,13 +507,13 @@ impl SqliteClipboardDb {
|
||||||
preview_col = min_preview_width;
|
preview_col = min_preview_width;
|
||||||
}
|
}
|
||||||
|
|
||||||
let selected = state.selected();
|
let selected = list_state.selected();
|
||||||
|
|
||||||
let list_items: Vec<ListItem> = entries
|
let list_items: Vec<ListItem> = tui
|
||||||
|
.window
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, entry)| {
|
.map(|(i, entry)| {
|
||||||
// Truncate preview by grapheme clusters and display width
|
|
||||||
let mut preview = String::new();
|
let mut preview = String::new();
|
||||||
let mut width = 0;
|
let mut width = 0;
|
||||||
for g in entry.1.graphemes(true) {
|
for g in entry.1.graphemes(true) {
|
||||||
|
|
@ -176,7 +525,6 @@ impl SqliteClipboardDb {
|
||||||
preview.push_str(g);
|
preview.push_str(g);
|
||||||
width += g_width;
|
width += g_width;
|
||||||
}
|
}
|
||||||
// Truncate and pad mimetype
|
|
||||||
let mut mime = String::new();
|
let mut mime = String::new();
|
||||||
let mut mwidth = 0;
|
let mut mwidth = 0;
|
||||||
for g in entry.2.graphemes(true) {
|
for g in entry.2.graphemes(true) {
|
||||||
|
|
@ -189,8 +537,6 @@ impl SqliteClipboardDb {
|
||||||
mwidth += g_width;
|
mwidth += g_width;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compose the row as highlight + id + space + preview + space +
|
|
||||||
// mimetype
|
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
let (id, preview, mime) = entry;
|
let (id, preview, mime) = entry;
|
||||||
if Some(i) == selected {
|
if Some(i) == selected {
|
||||||
|
|
@ -237,70 +583,121 @@ impl SqliteClipboardDb {
|
||||||
.fg(Color::Yellow)
|
.fg(Color::Yellow)
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
)
|
)
|
||||||
.highlight_symbol(""); // handled manually
|
.highlight_symbol("");
|
||||||
|
|
||||||
f.render_stateful_widget(list, area, &mut state);
|
f.render_stateful_widget(list, area, list_state);
|
||||||
})
|
})
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
.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))
|
if event::poll(std::time::Duration::from_millis(250))
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
|
||||||
&& let Event::Key(key) = event::read()
|
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
|
|
||||||
{
|
{
|
||||||
match (key.code, key.modifiers) {
|
let actions = drain_events(&tui)?;
|
||||||
(KeyCode::Char('q') | KeyCode::Esc, _) => break,
|
|
||||||
(KeyCode::Down | KeyCode::Char('j'), _) => {
|
if actions.quit {
|
||||||
if entries.is_empty() {
|
break;
|
||||||
state.select(None);
|
|
||||||
} else {
|
|
||||||
let i = match state.selected() {
|
|
||||||
Some(i) => {
|
|
||||||
if i >= entries.len() - 1 {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
i + 1
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
None => 0,
|
// Handle search mode actions
|
||||||
};
|
if actions.toggle_search {
|
||||||
state.select(Some(i));
|
tui.toggle_search_mode();
|
||||||
}
|
}
|
||||||
},
|
|
||||||
(KeyCode::Up | KeyCode::Char('k'), _) => {
|
if actions.clear_search && tui.clear_search() {
|
||||||
if entries.is_empty() {
|
// Search was cleared, refresh count
|
||||||
state.select(None);
|
tui.total =
|
||||||
} else {
|
self.count_entries(include_expired, tui.search_filter())?;
|
||||||
let i = match state.selected() {
|
|
||||||
Some(i) => {
|
|
||||||
if i == 0 {
|
|
||||||
entries.len() - 1
|
|
||||||
} else {
|
|
||||||
i - 1
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
None => 0,
|
if let Some(c) = actions.search_input {
|
||||||
};
|
let new_query = format!("{}{}", tui.search_query, c);
|
||||||
state.select(Some(i));
|
if tui.set_search(new_query) {
|
||||||
|
// Search changed, refresh count and reset
|
||||||
|
tui.total =
|
||||||
|
self.count_entries(include_expired, tui.search_filter())?;
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
(KeyCode::Enter, _) => {
|
|
||||||
if let Some(idx) = state.selected()
|
if actions.search_backspace {
|
||||||
&& let Some((id, ..)) = entries.get(idx)
|
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()
|
||||||
{
|
{
|
||||||
match self.copy_entry(*id) {
|
self
|
||||||
Ok((new_id, contents, mime)) => {
|
.conn
|
||||||
if new_id != *id {
|
.execute(
|
||||||
entries[idx] = (
|
"DELETE FROM clipboard WHERE id = ?1",
|
||||||
new_id,
|
rusqlite::params![id],
|
||||||
entries[idx].1.clone(),
|
)
|
||||||
entries[idx].2.clone(),
|
.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 opts = Options::new();
|
||||||
let mime_type = match mime {
|
let mime_type = match mime {
|
||||||
Some(ref m) if m == "text/plain" => MimeType::Text,
|
Some(ref m) if m == "text/plain" => MimeType::Text,
|
||||||
Some(ref m) => MimeType::Specific(m.clone().to_owned()),
|
Some(ref m) => MimeType::Specific(m.clone().clone()),
|
||||||
None => MimeType::Text,
|
None => MimeType::Text,
|
||||||
};
|
};
|
||||||
let copy_result = opts
|
let copy_result = opts
|
||||||
|
|
@ -313,7 +710,7 @@ impl SqliteClipboardDb {
|
||||||
.show();
|
.show();
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to copy entry to clipboard: {e}");
|
log::error!("failed to copy entry to clipboard: {e}");
|
||||||
let _ = Notification::new()
|
let _ = Notification::new()
|
||||||
.summary("Stash")
|
.summary("Stash")
|
||||||
.body(&format!("Failed to copy to clipboard: {e}"))
|
.body(&format!("Failed to copy to clipboard: {e}"))
|
||||||
|
|
@ -322,48 +719,26 @@ impl SqliteClipboardDb {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to fetch entry {id}: {e}");
|
log::error!("failed to fetch entry {id}: {e}");
|
||||||
let _ = Notification::new()
|
let _ = Notification::new()
|
||||||
.summary("Stash")
|
.summary("Stash")
|
||||||
.body(&format!("Failed to fetch entry: {e}"))
|
.body(&format!("Failed to fetch entry: {e}"))
|
||||||
.show();
|
.show();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
tui.copying_entry = None;
|
||||||
}
|
}
|
||||||
},
|
|
||||||
(KeyCode::Char('D'), KeyModifiers::SHIFT) => {
|
|
||||||
if let Some(idx) = state.selected()
|
|
||||||
&& let Some((id, ..)) = entries.get(idx)
|
|
||||||
{
|
|
||||||
// Delete entry from DB
|
|
||||||
self
|
|
||||||
.conn
|
|
||||||
.execute(
|
|
||||||
"DELETE FROM clipboard WHERE id = ?1",
|
|
||||||
rusqlite::params![id],
|
|
||||||
)
|
|
||||||
.map_err(|e| {
|
|
||||||
StashError::DeleteEntry(*id, e.to_string().into())
|
|
||||||
})?;
|
|
||||||
// Remove from entries and update selection
|
|
||||||
entries.remove(idx);
|
|
||||||
let new_len = entries.len();
|
|
||||||
if new_len == 0 {
|
|
||||||
state.select(None);
|
|
||||||
} else if idx >= new_len {
|
|
||||||
state.select(Some(new_len - 1));
|
|
||||||
} else {
|
|
||||||
state.select(Some(idx));
|
|
||||||
}
|
}
|
||||||
// Show notification
|
|
||||||
let _ = Notification::new()
|
|
||||||
.summary("Stash")
|
|
||||||
.body("Deleted entry")
|
|
||||||
.show();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_ => {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redraw once after processing all accumulated input.
|
||||||
|
draw_frame(
|
||||||
|
&mut terminal,
|
||||||
|
&mut tui,
|
||||||
|
&mut list_state,
|
||||||
|
max_id_width,
|
||||||
|
max_mime_width,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,3 @@ pub mod list;
|
||||||
pub mod query;
|
pub mod query;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod watch;
|
pub mod watch;
|
||||||
pub mod wipe;
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ use std::io::Read;
|
||||||
|
|
||||||
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub trait StoreCommand {
|
pub trait StoreCommand {
|
||||||
fn store(
|
fn store(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -10,6 +11,8 @@ pub trait StoreCommand {
|
||||||
max_items: u64,
|
max_items: u64,
|
||||||
state: Option<String>,
|
state: Option<String>,
|
||||||
excluded_apps: &[String],
|
excluded_apps: &[String],
|
||||||
|
min_size: Option<usize>,
|
||||||
|
max_size: usize,
|
||||||
) -> Result<(), crate::db::StashError>;
|
) -> Result<(), crate::db::StashError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -21,18 +24,24 @@ impl StoreCommand for SqliteClipboardDb {
|
||||||
max_items: u64,
|
max_items: u64,
|
||||||
state: Option<String>,
|
state: Option<String>,
|
||||||
excluded_apps: &[String],
|
excluded_apps: &[String],
|
||||||
|
min_size: Option<usize>,
|
||||||
|
max_size: usize,
|
||||||
) -> Result<(), crate::db::StashError> {
|
) -> Result<(), crate::db::StashError> {
|
||||||
if let Some("sensitive" | "clear") = state.as_deref() {
|
if let Some("sensitive" | "clear") = state.as_deref() {
|
||||||
self.delete_last()?;
|
self.delete_last()?;
|
||||||
log::info!("Entry deleted");
|
log::info!("entry deleted");
|
||||||
} else {
|
} else {
|
||||||
self.store_entry(
|
self.store_entry(
|
||||||
input,
|
input,
|
||||||
max_dedupe_search,
|
max_dedupe_search,
|
||||||
max_items,
|
max_items,
|
||||||
Some(excluded_apps),
|
Some(excluded_apps),
|
||||||
|
min_size,
|
||||||
|
max_size,
|
||||||
|
None, // no pre-computed hash for CLI store
|
||||||
|
None, // no mime types for CLI store
|
||||||
)?;
|
)?;
|
||||||
log::info!("Entry stored");
|
log::info!("entry stored");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,349 @@
|
||||||
use std::{
|
use std::{collections::BinaryHeap, hash::Hasher, io::Read, time::Duration};
|
||||||
collections::hash_map::DefaultHasher,
|
|
||||||
hash::{Hash, Hasher},
|
|
||||||
io::Read,
|
|
||||||
time::Duration,
|
|
||||||
};
|
|
||||||
|
|
||||||
use smol::Timer;
|
use smol::Timer;
|
||||||
use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents};
|
use wl_clipboard_rs::{
|
||||||
|
copy::{MimeType as CopyMimeType, Options, Source},
|
||||||
|
paste::{
|
||||||
|
ClipboardType,
|
||||||
|
MimeType as PasteMimeType,
|
||||||
|
Seat,
|
||||||
|
get_contents,
|
||||||
|
get_mime_types_ordered,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
use crate::{
|
||||||
|
clipboard::{self, ClipboardData, get_serving_pid},
|
||||||
|
db::{SqliteClipboardDb, nonblocking::AsyncClipboardDb},
|
||||||
|
hash::Fnv1aHasher,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Wrapper to provide [`Ord`] implementation for `f64` by negating values.
|
||||||
|
/// This allows [`BinaryHeap`], which is a max-heap, to function as a min-heap.
|
||||||
|
/// Also see:
|
||||||
|
/// - <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 {
|
pub trait WatchCommand {
|
||||||
fn watch(
|
async fn watch(
|
||||||
&self,
|
&self,
|
||||||
max_dedupe_search: u64,
|
max_dedupe_search: u64,
|
||||||
max_items: u64,
|
max_items: u64,
|
||||||
excluded_apps: &[String],
|
excluded_apps: &[String],
|
||||||
|
expire_after: Option<Duration>,
|
||||||
|
mime_type_preference: &str,
|
||||||
|
min_size: Option<usize>,
|
||||||
|
max_size: usize,
|
||||||
|
persist: bool,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WatchCommand for SqliteClipboardDb {
|
impl WatchCommand for SqliteClipboardDb {
|
||||||
fn watch(
|
async fn watch(
|
||||||
&self,
|
&self,
|
||||||
max_dedupe_search: u64,
|
max_dedupe_search: u64,
|
||||||
max_items: u64,
|
max_items: u64,
|
||||||
excluded_apps: &[String],
|
excluded_apps: &[String],
|
||||||
|
expire_after: Option<Duration>,
|
||||||
|
mime_type_preference: &str,
|
||||||
|
min_size: Option<usize>,
|
||||||
|
max_size: usize,
|
||||||
|
persist: bool,
|
||||||
) {
|
) {
|
||||||
smol::block_on(async {
|
let async_db = AsyncClipboardDb::new(self.db_path.clone());
|
||||||
log::info!("Starting clipboard watch daemon");
|
log::info!(
|
||||||
|
"Starting clipboard watch daemon with MIME type preference: \
|
||||||
|
{mime_type_preference}"
|
||||||
|
);
|
||||||
|
|
||||||
|
if persist {
|
||||||
|
log::info!("clipboard persistence enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build expiration queue from existing entries
|
||||||
|
let mut exp_queue = ExpirationQueue::new();
|
||||||
|
|
||||||
|
// Load all expirations from database asynchronously
|
||||||
|
match async_db.load_all_expirations().await {
|
||||||
|
Ok(expirations) => {
|
||||||
|
for (expires_at, id) in expirations {
|
||||||
|
exp_queue.push(expires_at, id);
|
||||||
|
}
|
||||||
|
if !exp_queue.is_empty() {
|
||||||
|
log::info!("loaded {} expirations from database", exp_queue.len());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("failed to load expirations: {e}");
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// We use hashes for comparison instead of storing full contents
|
// We use hashes for comparison instead of storing full contents
|
||||||
let mut last_hash: Option<u64> = None;
|
let mut last_hash: Option<u64> = None;
|
||||||
let mut buf = Vec::with_capacity(4096);
|
let mut buf = Vec::with_capacity(4096);
|
||||||
|
|
||||||
// Helper to hash clipboard contents
|
// Helper to hash clipboard contents using FNV-1a (deterministic across
|
||||||
|
// runs)
|
||||||
let hash_contents = |data: &[u8]| -> u64 {
|
let hash_contents = |data: &[u8]| -> u64 {
|
||||||
let mut hasher = DefaultHasher::new();
|
let mut hasher = Fnv1aHasher::new();
|
||||||
data.hash(&mut hasher);
|
hasher.write(data);
|
||||||
hasher.finish()
|
hasher.finish()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize with current clipboard
|
// Initialize with current clipboard using smart MIME negotiation
|
||||||
if let Ok((mut reader, _)) = get_contents(
|
if let Ok((mut reader, ..)) = negotiate_mime_type(mime_type_preference) {
|
||||||
ClipboardType::Regular,
|
|
||||||
Seat::Unspecified,
|
|
||||||
wl_clipboard_rs::paste::MimeType::Any,
|
|
||||||
) {
|
|
||||||
buf.clear();
|
buf.clear();
|
||||||
if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
|
if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
|
||||||
last_hash = Some(hash_contents(&buf));
|
last_hash = Some(hash_contents(&buf));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let poll_interval = Duration::from_millis(500);
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match get_contents(
|
// Process any pending expirations that are due now
|
||||||
ClipboardType::Regular,
|
if let Some(next_exp) = exp_queue.peek_next() {
|
||||||
Seat::Unspecified,
|
let now = SqliteClipboardDb::now();
|
||||||
wl_clipboard_rs::paste::MimeType::Any,
|
if next_exp <= now {
|
||||||
) {
|
// Expired entries to process
|
||||||
Ok((mut reader, _mime_type)) => {
|
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();
|
buf.clear();
|
||||||
if let Err(e) = reader.read_to_end(&mut buf) {
|
if let Err(e) = reader.read_to_end(&mut buf) {
|
||||||
log::error!("Failed to read clipboard contents: {e}");
|
log::error!("failed to read clipboard contents: {e}");
|
||||||
Timer::after(Duration::from_millis(500)).await;
|
Timer::after(Duration::from_millis(500)).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -70,29 +352,91 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
if !buf.is_empty() {
|
if !buf.is_empty() {
|
||||||
let current_hash = hash_contents(&buf);
|
let current_hash = hash_contents(&buf);
|
||||||
if last_hash != Some(current_hash) {
|
if last_hash != Some(current_hash) {
|
||||||
let id = self.next_sequence();
|
// Clone buf for the async operation since it needs 'static
|
||||||
match self.store_entry(
|
let buf_clone = buf.clone();
|
||||||
&buf[..],
|
#[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_dedupe_search,
|
||||||
max_items,
|
max_items,
|
||||||
Some(excluded_apps),
|
Some(excluded_apps.to_vec()),
|
||||||
) {
|
min_size,
|
||||||
Ok(_) => {
|
max_size,
|
||||||
log::info!("Stored new clipboard entry (id: {id})");
|
content_hash,
|
||||||
|
Some(mime_types_for_persist.clone()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(id) => {
|
||||||
|
log::info!("stored new clipboard entry (id: {id})");
|
||||||
last_hash = Some(current_hash);
|
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(_)) => {
|
Err(crate::db::StashError::ExcludedByApp(_)) => {
|
||||||
log::info!("Clipboard entry excluded by app filter");
|
log::info!("clipboard entry excluded by app filter");
|
||||||
last_hash = Some(current_hash);
|
last_hash = Some(current_hash);
|
||||||
},
|
},
|
||||||
Err(crate::db::StashError::Store(ref msg))
|
Err(crate::db::StashError::Store(ref msg))
|
||||||
if msg.contains("Excluded by app filter") =>
|
if msg.contains("Excluded by app filter") =>
|
||||||
{
|
{
|
||||||
log::info!("Clipboard entry excluded by app filter");
|
log::info!("clipboard entry excluded by app filter");
|
||||||
last_hash = Some(current_hash);
|
last_hash = Some(current_hash);
|
||||||
},
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Failed to store clipboard entry: {e}");
|
log::error!("failed to store clipboard entry: {e}");
|
||||||
last_hash = Some(current_hash);
|
last_hash = Some(current_hash);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -102,12 +446,267 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let error_msg = e.to_string();
|
let error_msg = e.to_string();
|
||||||
if !error_msg.contains("empty") {
|
if !error_msg.contains("empty") {
|
||||||
log::error!("Failed to get clipboard contents: {e}");
|
log::error!("failed to get clipboard contents: {e}");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
Timer::after(Duration::from_millis(500)).await;
|
|
||||||
|
// Calculate sleep time: min of poll interval and time until next
|
||||||
|
// expiration
|
||||||
|
let sleep_duration = if let Some(next_exp) = exp_queue.peek_next() {
|
||||||
|
let now = SqliteClipboardDb::now();
|
||||||
|
let time_to_exp = (next_exp - now).max(0.0);
|
||||||
|
poll_interval.min(Duration::from_secs_f64(time_to_exp))
|
||||||
|
} else {
|
||||||
|
poll_interval
|
||||||
|
};
|
||||||
|
Timer::after(sleep_duration).await;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Given ordered offers and a preference, return the
|
||||||
|
/// chosen MIME type. This mirrors the selection logic in
|
||||||
|
/// [`negotiate_mime_type`] without requiring a Wayland connection.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn pick_mime<'a>(
|
||||||
|
offered: &'a [String],
|
||||||
|
preference: &str,
|
||||||
|
) -> Option<&'a String> {
|
||||||
|
if preference == "image" {
|
||||||
|
offered
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.starts_with("image/"))
|
||||||
|
.or_else(|| offered.first())
|
||||||
|
} else {
|
||||||
|
let has_image = offered.iter().any(|m| m.starts_with("image/"));
|
||||||
|
if has_image {
|
||||||
|
offered
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.starts_with("image/"))
|
||||||
|
.or_else(|| offered.first())
|
||||||
|
} else if offered.first().is_some_and(|m| m == "text/html") {
|
||||||
|
offered
|
||||||
|
.iter()
|
||||||
|
.find(|m| *m != "text/html")
|
||||||
|
.or_else(|| offered.first())
|
||||||
|
} else {
|
||||||
|
offered.first()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pick_first_offered() {
|
||||||
|
let offered = vec!["text/uri-list".to_string(), "text/plain".to_string()];
|
||||||
|
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/uri-list");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pick_image_preference_finds_image() {
|
||||||
|
let offered = vec![
|
||||||
|
"text/html".to_string(),
|
||||||
|
"image/png".to_string(),
|
||||||
|
"text/plain".to_string(),
|
||||||
|
];
|
||||||
|
assert_eq!(pick_mime(&offered, "image").unwrap(), "image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pick_image_preference_falls_back() {
|
||||||
|
let offered = vec!["text/html".to_string(), "text/plain".to_string()];
|
||||||
|
// No image types offered — falls back to first
|
||||||
|
assert_eq!(pick_mime(&offered, "image").unwrap(), "text/html");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pick_empty_offered() {
|
||||||
|
let offered: Vec<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,13 +0,0 @@
|
||||||
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
|
|
||||||
|
|
||||||
pub trait WipeCommand {
|
|
||||||
fn wipe(&self) -> Result<(), StashError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WipeCommand for SqliteClipboardDb {
|
|
||||||
fn wipe(&self) -> Result<(), StashError> {
|
|
||||||
self.wipe_db()?;
|
|
||||||
log::info!("Database wiped");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1393
src/db/mod.rs
1393
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
220
src/main.rs
220
src/main.rs
|
|
@ -1,18 +1,30 @@
|
||||||
|
mod clipboard;
|
||||||
|
mod commands;
|
||||||
|
mod db;
|
||||||
|
mod hash;
|
||||||
|
mod mime;
|
||||||
|
mod multicall;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
env,
|
env,
|
||||||
io::{self, IsTerminal},
|
io::{self, IsTerminal},
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
|
time::Duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
use clap::{CommandFactory, Parser, Subcommand};
|
use clap::{CommandFactory, Parser, Subcommand};
|
||||||
|
use color_eyre::eyre;
|
||||||
|
use humantime::parse_duration;
|
||||||
use inquire::Confirm;
|
use inquire::Confirm;
|
||||||
|
|
||||||
mod commands;
|
// While the module is named "wayland", the Wayland module is *strictly* for the
|
||||||
pub(crate) mod db;
|
// use-toplevel feature as it requires some low-level wayland crates that are
|
||||||
mod multicall;
|
// 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;
|
#[cfg(feature = "use-toplevel")] mod wayland;
|
||||||
|
|
||||||
use crate::commands::{
|
use crate::{
|
||||||
|
commands::{
|
||||||
decode::DecodeCommand,
|
decode::DecodeCommand,
|
||||||
delete::DeleteCommand,
|
delete::DeleteCommand,
|
||||||
import::ImportCommand,
|
import::ImportCommand,
|
||||||
|
|
@ -20,7 +32,8 @@ use crate::commands::{
|
||||||
query::QueryCommand,
|
query::QueryCommand,
|
||||||
store::StoreCommand,
|
store::StoreCommand,
|
||||||
watch::WatchCommand,
|
watch::WatchCommand,
|
||||||
wipe::WipeCommand,
|
},
|
||||||
|
db::{ClipboardDb, DEFAULT_MAX_ENTRY_SIZE},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
|
|
@ -39,6 +52,16 @@ struct Cli {
|
||||||
#[arg(long, default_value_t = 20)]
|
#[arg(long, default_value_t = 20)]
|
||||||
max_dedupe_search: u64,
|
max_dedupe_search: u64,
|
||||||
|
|
||||||
|
/// Minimum size (in bytes) for clipboard entries. Entries smaller than this
|
||||||
|
/// will not be stored.
|
||||||
|
#[arg(long, env = "STASH_MIN_SIZE")]
|
||||||
|
min_size: Option<usize>,
|
||||||
|
|
||||||
|
/// Maximum size (in bytes) for clipboard entries. Entries larger than this
|
||||||
|
/// will not be stored. Defaults to 5MB.
|
||||||
|
#[arg(long, default_value_t = DEFAULT_MAX_ENTRY_SIZE, env = "STASH_MAX_SIZE")]
|
||||||
|
max_size: usize,
|
||||||
|
|
||||||
/// Maximum width (in characters) for clipboard entry previews in list
|
/// Maximum width (in characters) for clipboard entry previews in list
|
||||||
/// output.
|
/// output.
|
||||||
#[arg(long, default_value_t = 100)]
|
#[arg(long, default_value_t = 100)]
|
||||||
|
|
@ -71,6 +94,14 @@ enum Command {
|
||||||
/// Output format: "tsv" (default) or "json"
|
/// Output format: "tsv" (default) or "json"
|
||||||
#[arg(long, value_parser = ["tsv", "json"])]
|
#[arg(long, value_parser = ["tsv", "json"])]
|
||||||
format: Option<String>,
|
format: Option<String>,
|
||||||
|
|
||||||
|
/// Show only expired entries (diagnostic, does not remove them)
|
||||||
|
#[arg(long)]
|
||||||
|
expired: bool,
|
||||||
|
|
||||||
|
/// Reverse the order of entries (oldest first instead of newest first)
|
||||||
|
#[arg(long)]
|
||||||
|
reverse: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Decode and output clipboard entry by id
|
/// Decode and output clipboard entry by id
|
||||||
|
|
@ -92,11 +123,10 @@ enum Command {
|
||||||
ask: bool,
|
ask: bool,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Wipe all clipboard history
|
/// Database management operations
|
||||||
Wipe {
|
Db {
|
||||||
/// Ask for confirmation before wiping
|
#[command(subcommand)]
|
||||||
#[arg(long)]
|
action: DbAction,
|
||||||
ask: bool,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Import clipboard data from stdin (default: TSV format)
|
/// Import clipboard data from stdin (default: TSV format)
|
||||||
|
|
@ -111,7 +141,39 @@ enum Command {
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Start a process to watch clipboard for changes and store automatically.
|
/// Start a process to watch clipboard for changes and store automatically.
|
||||||
Watch,
|
Watch {
|
||||||
|
/// Expire new entries after duration (e.g., "3s", "500ms", "1h30m").
|
||||||
|
#[arg(long, value_parser = parse_duration)]
|
||||||
|
expire_after: Option<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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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>(
|
fn report_error<T>(
|
||||||
|
|
@ -127,9 +189,27 @@ fn report_error<T>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
#[allow(clippy::too_many_lines)] // whatever
|
||||||
fn main() -> color_eyre::eyre::Result<()> {
|
fn main() -> eyre::Result<()> {
|
||||||
|
color_eyre::install()?;
|
||||||
|
|
||||||
// Check if we're being called as a multicall binary
|
// 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| {
|
let program_name = env::args().next().map(|s| {
|
||||||
PathBuf::from(s)
|
PathBuf::from(s)
|
||||||
.file_name()
|
.file_name()
|
||||||
|
|
@ -155,19 +235,25 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
.filter_level(cli.verbosity.into())
|
.filter_level(cli.verbosity.into())
|
||||||
.init();
|
.init();
|
||||||
|
|
||||||
let db_path = cli.db_path.unwrap_or_else(|| {
|
let db_path = match cli.db_path {
|
||||||
dirs::cache_dir()
|
Some(path) => path,
|
||||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
None => {
|
||||||
.join("stash")
|
let cache_dir = dirs::cache_dir().ok_or_else(|| {
|
||||||
.join("db")
|
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 Some(parent) = db_path.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let conn = rusqlite::Connection::open(&db_path)?;
|
let conn = rusqlite::Connection::open(&db_path)?;
|
||||||
let db = db::SqliteClipboardDb::new(conn)?;
|
let db = db::SqliteClipboardDb::new(conn, db_path)?;
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Some(Command::Store) => {
|
Some(Command::Store) => {
|
||||||
|
|
@ -182,20 +268,26 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
&cli.excluded_apps,
|
&cli.excluded_apps,
|
||||||
#[cfg(not(feature = "use-toplevel"))]
|
#[cfg(not(feature = "use-toplevel"))]
|
||||||
&[],
|
&[],
|
||||||
|
cli.min_size,
|
||||||
|
cli.max_size,
|
||||||
),
|
),
|
||||||
"failed to store entry",
|
"failed to store entry",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
Some(Command::List { format }) => {
|
Some(Command::List {
|
||||||
|
format,
|
||||||
|
expired,
|
||||||
|
reverse,
|
||||||
|
}) => {
|
||||||
match format.as_deref() {
|
match format.as_deref() {
|
||||||
Some("tsv") => {
|
Some("tsv") => {
|
||||||
report_error(
|
report_error(
|
||||||
db.list(io::stdout(), cli.preview_width),
|
db.list(io::stdout(), cli.preview_width, expired, reverse),
|
||||||
"failed to list entries",
|
"failed to list entries",
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
Some("json") => {
|
Some("json") => {
|
||||||
match db.list_json() {
|
match db.list_json(expired, reverse) {
|
||||||
Ok(json) => {
|
Ok(json) => {
|
||||||
println!("{json}");
|
println!("{json}");
|
||||||
},
|
},
|
||||||
|
|
@ -210,12 +302,12 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
None => {
|
None => {
|
||||||
if std::io::stdout().is_terminal() {
|
if std::io::stdout().is_terminal() {
|
||||||
report_error(
|
report_error(
|
||||||
db.list_tui(cli.preview_width),
|
db.list_tui(cli.preview_width, expired, reverse),
|
||||||
"failed to list entries in TUI",
|
"failed to list entries in TUI",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
report_error(
|
report_error(
|
||||||
db.list(io::stdout(), cli.preview_width),
|
db.list(io::stdout(), cli.preview_width, expired, reverse),
|
||||||
"failed to list entries",
|
"failed to list entries",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -232,10 +324,7 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
let mut should_proceed = true;
|
let mut should_proceed = true;
|
||||||
if ask {
|
if ask {
|
||||||
should_proceed =
|
should_proceed =
|
||||||
Confirm::new("Are you sure you want to delete clipboard entries?")
|
confirm("Are you sure you want to delete clipboard entries?");
|
||||||
.with_default(false)
|
|
||||||
.prompt()
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if !should_proceed {
|
if !should_proceed {
|
||||||
log::info!("aborted by user.");
|
log::info!("aborted by user.");
|
||||||
|
|
@ -286,34 +375,67 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Some(Command::Wipe { ask }) => {
|
|
||||||
|
Some(Command::Db { action }) => {
|
||||||
|
match action {
|
||||||
|
DbAction::Wipe { expired, ask } => {
|
||||||
let mut should_proceed = true;
|
let mut should_proceed = true;
|
||||||
if ask {
|
if ask {
|
||||||
should_proceed = Confirm::new(
|
let message = if expired {
|
||||||
"Are you sure you want to wipe all clipboard history?",
|
"Are you sure you want to wipe all expired clipboard entries?"
|
||||||
)
|
} else {
|
||||||
.with_default(false)
|
"Are you sure you want to wipe ALL clipboard history?"
|
||||||
.prompt()
|
};
|
||||||
.unwrap_or(false);
|
should_proceed = confirm(message);
|
||||||
if !should_proceed {
|
if !should_proceed {
|
||||||
log::info!("wipe command aborted by user.");
|
log::info!("db wipe command aborted by user.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if should_proceed {
|
if should_proceed {
|
||||||
report_error(db.wipe(), "failed to wipe database");
|
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 }) => {
|
Some(Command::Import { r#type, ask }) => {
|
||||||
let mut should_proceed = true;
|
let mut should_proceed = true;
|
||||||
if ask {
|
if ask {
|
||||||
should_proceed = Confirm::new(
|
should_proceed = confirm(
|
||||||
"Are you sure you want to import clipboard data? This may \
|
"Are you sure you want to import clipboard data? This may \
|
||||||
overwrite existing entries.",
|
overwrite existing entries.",
|
||||||
)
|
);
|
||||||
.with_default(false)
|
|
||||||
.prompt()
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !should_proceed {
|
if !should_proceed {
|
||||||
log::info!("import command aborted by user.");
|
log::info!("import command aborted by user.");
|
||||||
}
|
}
|
||||||
|
|
@ -334,7 +456,11 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Some(Command::Watch) => {
|
Some(Command::Watch {
|
||||||
|
expire_after,
|
||||||
|
mime_type,
|
||||||
|
persist,
|
||||||
|
}) => {
|
||||||
db.watch(
|
db.watch(
|
||||||
cli.max_dedupe_search,
|
cli.max_dedupe_search,
|
||||||
cli.max_items,
|
cli.max_items,
|
||||||
|
|
@ -342,7 +468,13 @@ fn main() -> color_eyre::eyre::Result<()> {
|
||||||
&cli.excluded_apps,
|
&cli.excluded_apps,
|
||||||
#[cfg(not(feature = "use-toplevel"))]
|
#[cfg(not(feature = "use-toplevel"))]
|
||||||
&[],
|
&[],
|
||||||
);
|
expire_after,
|
||||||
|
&mime_type,
|
||||||
|
cli.min_size,
|
||||||
|
cli.max_size,
|
||||||
|
persist,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
},
|
},
|
||||||
|
|
||||||
None => {
|
None => {
|
||||||
|
|
|
||||||
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -360,7 +360,7 @@ fn execute_watch_command(
|
||||||
|
|
||||||
/// Select the best MIME type from available types when none is specified.
|
/// Select the best MIME type from available types when none is specified.
|
||||||
/// Prefers specific content types (image/*, application/*) over generic
|
/// Prefers specific content types (image/*, application/*) over generic
|
||||||
/// text representations (TEXT, STRING, UTF8_STRING).
|
/// text representations (TEXT, STRING, `UTF8_STRING`).
|
||||||
fn select_best_mime_type(
|
fn select_best_mime_type(
|
||||||
types: &std::collections::HashSet<String>,
|
types: &std::collections::HashSet<String>,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
|
|
@ -421,7 +421,7 @@ fn handle_regular_paste(
|
||||||
let selected_type = available_types.as_ref().and_then(select_best_mime_type);
|
let selected_type = available_types.as_ref().and_then(select_best_mime_type);
|
||||||
|
|
||||||
let mime_type = if let Some(ref best) = selected_type {
|
let mime_type = if let Some(ref best) = selected_type {
|
||||||
log::debug!("Auto-selecting MIME type: {}", best);
|
log::debug!("auto-selecting MIME type: {best}");
|
||||||
PasteMimeType::Specific(best)
|
PasteMimeType::Specific(best)
|
||||||
} else {
|
} else {
|
||||||
get_paste_mime_type(args.mime_type.as_deref())
|
get_paste_mime_type(args.mime_type.as_deref())
|
||||||
|
|
@ -461,14 +461,14 @@ fn handle_regular_paste(
|
||||||
|
|
||||||
// Only add newline for text content, not binary data
|
// Only add newline for text content, not binary data
|
||||||
// Check if the MIME type indicates text content
|
// Check if the MIME type indicates text content
|
||||||
let is_text_content = if !types.is_empty() {
|
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.starts_with("text/")
|
||||||
|| types == "application/json"
|
|| types == "application/json"
|
||||||
|| types == "application/xml"
|
|| types == "application/xml"
|
||||||
|| types == "application/x-sh"
|
|| types == "application/x-sh"
|
||||||
} else {
|
|
||||||
// If no MIME type, check if content is valid UTF-8
|
|
||||||
std::str::from_utf8(&buf).is_ok()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if !args.no_newline
|
if !args.no_newline
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
sync::{LazyLock, Mutex},
|
sync::{Arc, LazyLock, Mutex},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use arc_swap::ArcSwapOption;
|
||||||
use log::debug;
|
use log::debug;
|
||||||
use wayland_client::{
|
use wayland_client::{
|
||||||
Connection as WaylandConnection,
|
Connection as WaylandConnection,
|
||||||
|
|
@ -17,7 +18,7 @@ use wayland_protocols_wlr::foreign_toplevel::v1::client::{
|
||||||
zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1},
|
zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1},
|
||||||
};
|
};
|
||||||
|
|
||||||
static FOCUSED_APP: Mutex<Option<String>> = Mutex::new(None);
|
static FOCUSED_APP: ArcSwapOption<String> = ArcSwapOption::const_empty();
|
||||||
static TOPLEVEL_APPS: LazyLock<Mutex<HashMap<ObjectId, String>>> =
|
static TOPLEVEL_APPS: LazyLock<Mutex<HashMap<ObjectId, String>>> =
|
||||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
|
@ -32,12 +33,11 @@ pub fn init_wayland_state() {
|
||||||
|
|
||||||
/// Get the currently focused window application name using Wayland protocols
|
/// Get the currently focused window application name using Wayland protocols
|
||||||
pub fn get_focused_window_app() -> Option<String> {
|
pub fn get_focused_window_app() -> Option<String> {
|
||||||
// Try Wayland protocol first
|
// Load the focused app using lock-free arc-swap
|
||||||
if let Ok(focused) = FOCUSED_APP.lock()
|
let focused = FOCUSED_APP.load();
|
||||||
&& let Some(ref app) = *focused
|
if let Some(app) = focused.as_ref() {
|
||||||
{
|
|
||||||
debug!("Found focused app via Wayland protocol: {app}");
|
debug!("Found focused app via Wayland protocol: {app}");
|
||||||
return Some(app.clone());
|
return Some(app.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!("No focused window detection method worked");
|
debug!("No focused window detection method worked");
|
||||||
|
|
@ -152,12 +152,11 @@ impl Dispatch<ZwlrForeignToplevelHandleV1, ()> for AppState {
|
||||||
}) {
|
}) {
|
||||||
debug!("Toplevel activated");
|
debug!("Toplevel activated");
|
||||||
// Update focused app to the `app_id` of this handle
|
// Update focused app to the `app_id` of this handle
|
||||||
if let (Ok(apps), Ok(mut focused)) =
|
if let Ok(apps) = TOPLEVEL_APPS.lock()
|
||||||
(TOPLEVEL_APPS.lock(), FOCUSED_APP.lock())
|
|
||||||
&& let Some(app_id) = apps.get(&handle_id)
|
&& let Some(app_id) = apps.get(&handle_id)
|
||||||
{
|
{
|
||||||
debug!("Setting focused app to: {app_id}");
|
debug!("Setting focused app to: {app_id}");
|
||||||
*focused = Some(app_id.clone());
|
FOCUSED_APP.store(Some(Arc::new(app_id.clone())));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue