Compare commits

..

No commits in common. "main" and "v0.2.6" have entirely different histories.

36 changed files with 1191 additions and 8519 deletions

View file

@ -1,23 +1,13 @@
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

View file

@ -1,10 +1,10 @@
name: Build and Cache with Nix name: "Populate cachix cache"
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: [ "main" ] branches: [ "main" ]
paths: [ 'src/**.rs', 'build.rs', 'Cargo.toml', 'Cargo.lock', 'nix/package.nix', 'flake.nix', 'flake.lock' ] paths: [ 'src/**.rs', 'Cargo.toml', 'Cargo.lock', 'nix/package.nix' ]
permissions: permissions:
contents: read contents: read
@ -13,17 +13,16 @@ jobs:
populate-cache: populate-cache:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: "Checkout" - name: "CHeckout"
uses: actions/checkout@v6 uses: actions/checkout@v5
- uses: cachix/install-nix-action@v31 - uses: cachix/install-nix-action@v31
with: with:
nix_path: nixpkgs=channel:nixos-unstable nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v17 - uses: cachix/cachix-action@v16
with: with:
name: nyx name: nyx
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
- name: "Build with Nix" - run: nix build
run: nix build

View file

@ -9,30 +9,7 @@ permissions:
contents: write contents: write
jobs: jobs:
tag-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Read version
run: |
echo -n "stash_version=v" >> "$GITHUB_ENV"
nix run nixpkgs#fq -- -r '.package.version' Cargo.toml >> "$GITHUB_ENV"
cat "$GITHUB_ENV"
- name: Tag
run: |
set -x
git tag $ndg_version
git push --tags || :
create-release: create-release:
needs: tag-release
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }} upload_url: ${{ steps.create_release.outputs.upload_url }}
@ -40,7 +17,7 @@ jobs:
steps: steps:
- name: Create Release - name: Create Release
id: create_release id: create_release
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v2
with: with:
draft: false draft: false
prerelease: false prerelease: false
@ -62,7 +39,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v5
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@ -98,7 +75,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@v3 uses: softprops/action-gh-release@v2
with: with:
files: ${{ matrix.name }} files: ${{ matrix.name }}
@ -106,7 +83,7 @@ jobs:
needs: [create-release, build-release] needs: [create-release, build-release]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v5
- name: Download Assets - name: Download Assets
uses: robinraju/release-downloader@v1 uses: robinraju/release-downloader@v1
@ -120,7 +97,7 @@ jobs:
sha256sum stash-* > SHA256SUMS sha256sum stash-* > SHA256SUMS
- name: Upload Checksums - name: Upload Checksums
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v2
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
files: SHA256SUMS files: SHA256SUMS

View file

@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v5
- name: Build - name: Build
run: cargo build --verbose run: cargo build --verbose

37
.gitignore vendored
View file

@ -1,34 +1,3 @@
# Ignore everything by default target/
/* .direnv/
!/ result/
!/nix
!/src
!/contrib
# Rust/Cargo
!/Cargo.lock
!/Cargo.toml
!/build.rs
# Configuration files
!/.config/
!/.rustfmt.toml
!/.clippy.toml
!/.taplo.toml
!/.gitattributes
!/.gitignore
!/.github
!/.editorconfig
# Nix
!/flake/**/*.nix
!/flake.nix
!/flake.lock
!/shell.nix
!/default.nix
!/.envrc
# Misc
!/README.md
!/LICENSE

View file

@ -1,6 +1,6 @@
condense_wildcard_suffixes = true condense_wildcard_suffixes = true
doc_comment_code_block_width = 80 doc_comment_code_block_width = 80
edition = "2024" # Keep in sync with Cargo.toml. edition = "2024" # Keep in sync with Cargo.toml.
enum_discrim_align_threshold = 60 enum_discrim_align_threshold = 60
force_explicit_abi = false force_explicit_abi = false
force_multiline_blocks = true force_multiline_blocks = true
@ -24,3 +24,4 @@ unstable_features = true
use_field_init_shorthand = true use_field_init_shorthand = true
use_try_shorthand = true use_try_shorthand = true
wrap_comments = true wrap_comments = true

View file

@ -1,13 +0,0 @@
[formatting]
align_entries = true
column_width = 110
compact_arrays = false
reorder_inline_tables = false
reorder_keys = true
[[rule]]
include = [ "**/Cargo.toml" ]
keys = [ "package" ]
[rule.formatting]
reorder_keys = false

2867
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,59 +1,37 @@
[package] [package]
name = "stash-clipboard" name = "stash"
description = "Wayland clipboard manager with fast persistent history and multi-media support" version = "0.2.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.85"
rust-version = "1.91.0"
[[bin]]
name = "stash" # actual binary name for Nix, Cargo, etc.
path = "src/main.rs"
[dependencies] [dependencies]
arc-swap = { version = "1.9.1", optional = true } clap = { version = "4.5.45", features = ["derive"] }
base64 = "0.22.1" clap-verbosity-flag = "3.0.4"
blocking = "1.6.2" dirs = "6.0.0"
clap = { version = "4.6.0", features = [ "derive", "env" ] } imagesize = "0.14.0"
clap-verbosity-flag = "3.0.4" inquire = { default-features = false, version = "0.7.5", features = [
color-eyre = "0.6.5" "crossterm",
crossterm = "0.29.0" ] }
ctrlc = "3.5.2" log = "0.4.27"
dirs = "6.0.0" env_logger = "0.11.8"
env_logger = "0.11.10" thiserror = "2.0.16"
humantime = "2.3.0" wl-clipboard-rs = "0.9.2"
imagesize = "0.14.0" rusqlite = { version = "0.37.0", features = ["bundled"] }
inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] } smol = "2.0.2"
libc = "0.2.185" serde = { version = "1.0.219", features = ["derive"] }
log = "0.4.29" serde_json = "1.0.143"
mime-sniffer = "0.1.3" base64 = "0.22.1"
notify-rust = { version = "4.14.0", optional = true } regex = "1.11.1"
ratatui = "0.30.0" ratatui = "0.29.0"
regex = "1.12.3" crossterm = "0.29.0"
rusqlite = { version = "0.39.0", features = [ "bundled" ] } unicode-segmentation = "1.12.0"
serde = { version = "1.0.228", features = [ "derive" ] } unicode-width = "0.2.0"
serde_json = "1.0.149"
smol = "2.0.2"
thiserror = "2.0.18"
unicode-segmentation = "1.13.2"
unicode-width = "0.2.2"
wayland-client = { version = "0.31.14", features = [ "log" ], optional = true }
wayland-protocols-wlr = { version = "0.3.12", default-features = false, optional = true }
wl-clipboard-rs = "0.9.3"
[dev-dependencies]
futures = "0.3.32"
tempfile = "3.27.0"
[features]
default = [ "notifications", "use-toplevel" ]
notifications = [ "dep:notify-rust" ]
use-toplevel = [ "dep:arc-swap", "dep:wayland-client", "dep:wayland-protocols-wlr" ]
[profile.release] [profile.release]
lto = true lto = true
opt-level = "z" opt-level = "z"
strip = true strip = true

423
README.md
View file

@ -5,13 +5,13 @@
</h1> </h1>
<div align="center"> <div align="center">
<a alt="CI Status" href="https://github.com/NotAShelf/stash/actions"> <a alt="CI" href="https://github.com/NotAShelf/stash/actions">
<img <img
src="https://github.com/NotAShelf/stash/actions/workflows/rust.yml/badge.svg" src="https://github.com/NotAShelf/stash/actions/workflows/rust.yml/badge.svg"
alt="Build Status" alt="Build Status"
/> />
</a> </a>
<a alt="Dependencies" href="https://deps.rs/repo/github/notashelf/stash"> <a alt="Dopendencies" href="https://deps.rs/repo/github/notashelf/stash">
<img <img
src="https://deps.rs/repo/github/notashelf/stash/status.svg" src="https://deps.rs/repo/github/notashelf/stash/status.svg"
alt="Dependency Status" alt="Dependency Status"
@ -20,23 +20,23 @@
</div> </div>
<div align="center"> <div align="center">
Lightweight & feature-rich Wayland clipboard "manager" with fast persistent history and Wayland clipboard "manager" with fast persistent history and multi-media
robust multi-media support. Stores and previews clipboard entries (text, images) support. Stores and previews clipboard entries (text, images) on the command
on the clipboard with a neat TUI and advanced scripting capabilities. line.
</div> </div>
<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> | <a href="#usage">Motivation</a></br> <a href="#installation">Installation</a> | <a href="#usage">Usage</a><br/>
<a href="#tips--tricks">Tips and Tricks</a> <a href="#tips--tricks">Tips and Tricks</a>
<br/> <br/>
</div> </div>
## Features ## Features
Stash is a feature-rich, yet simple and lightweight clipboard management utility Stash is a feature-rich, yet simple clipboard management utility with many
with many features such as but not necessarily limited to: features such as but not limited to:
- Automatic MIME detection for stored entries - Automatic MIME detection for stored entries
- Fast persistent storage using SQLite - Fast persistent storage using SQLite
@ -44,41 +44,25 @@ with many features such as but not necessarily limited to:
- Backwards compatible with Cliphist TSV format - Backwards compatible with Cliphist TSV format
- 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)
- Deduplication and entry limit control
- Text previews with customizable width - Text previews with customizable width
- 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`)
- Sensitive clipboard filtering via regex (see below) - Sensitive clipboard filtering via regex (see below)
- Sensitive clipboard filtering by application (see below)
on top of the existing features of Cliphist, which are as follows: See [usage section](#usage) for more details.
- 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.
- Wont 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 (and developing!) Stash. You can Nix is the recommended way of downloading Stash. You can install it using Nix
install it using Nix flakes using `nix profile add` if on non-nixos or add Stash flakes using `nix profile add` if on non-nixos or add Stash as a flake input if
as a flake input if you are on NixOS. you are on NixOS.
```nix ```nix
{ {
# Add Stash to your inputs like so # Add Stash to your inputs like so
inputs.stash.url = "github:NotAShelf/stash"; inputs.stash.url = "github:notashelf/stash";
outputs = { /* ... */ }; outputs = { /* ... */ };
} }
@ -100,12 +84,10 @@ in {
} }
``` ```
If you want to give Stash a try before you switch to it, you may also run it one You can also run it one time with `nix run`
time with `nix run`.
```sh ```sh
# Run directly from the git repository; will be garbage collected nix run github:notashelf/stash -- watch # start the watch daemon
$ nix run github:NotAShelf/stash -- watch # start the watch daemon
``` ```
### Without Nix ### Without Nix
@ -114,84 +96,29 @@ $ nix run github:NotAShelf/stash -- watch # start the watch daemon
You can also install Stash on any of your systems _without_ using Nix. New You can also install Stash on any of your systems _without_ using Nix. New
releases are made when a version gets tagged, and are available under releases are made when a version gets tagged, and are available under
[GitHub Releases]. To install Stash on your system without Nix, either: [GitHub Releases]. To install Stash on your system without Nix, eiter:
- Download a tagged release from [GitHub Releases] for your platform and place - Download a tagged release from [GitHub Releases] for your platform and place
the binary in your `$PATH`. Instructions may differ based on your the binary in your `$PATH`. Instructions may differ based on your
distribution, but generally you want to download the built binary from distribution, but generally you want to download the built binary from
releases and put it somewhere like `/usr/bin` or `~/.local/bin` depending on releases and put it somewhere like `/usr/bin`.
your distribution.
- Build and install from source with Cargo: - Build and install from source with Cargo:
```bash ```bash
cargo install stash --locked cargo install --git https://github.com/notashelf/stash
``` ```
Additionally, you may get Stash from source via `cargo install` using
`cargo install --git https://github.com/notashelf/stash --locked` or you may
check out to the repository, and use Cargo to build it. You'll need Rust 1.91.0
or above. Most distributions should package this version already. You may, of
course, prefer to package the built releases if you'd like.
## Usage ## Usage
> [!IMPORTANT] Command interface is only slightly different from Cliphist. In most cases, it
will be as simple as replacing `cliphist` with `stash` in your commands, aliases
or scripts.
> [!NOTE]
> It is not a priority to provide 1:1 backwards compatibility with Cliphist. > It is not a priority to provide 1:1 backwards compatibility with Cliphist.
> While the interface is generally similar, Stash chooses to build upon > While the interface is _almost_ identical, Stash chooses to build upon
> Cliphist's design and extend existing design choices. See > Cliphist's design and extend existing design choices. See
> [Migrating from Cliphist](#migrating-from-cliphist) for more details. Refer to > [Migrating from Cliphist](#migrating-from-cliphist) for more details.
> help text if confused.
The command interface of Stash is _only slightly_ different from Cliphist. In
most cases, you may simply replace `cliphist` with `stash` and your commands,
aliases or scripts will continue to work as intended.
Some of the commands allow further fine-graining with flags such as `--type` or
`--format` to allow specific input and output specifiers. See `--help` for
individual subcommands if in doubt.
<!-- markdownlint-disable MD013 -->
```console
$ stash help
Wayland clipboard manager
Usage: stash [OPTIONS] [COMMAND]
Commands:
store Store clipboard contents
list List clipboard history
decode Decode and output clipboard entry by id
delete Delete clipboard entry by id (if numeric), or entries matching a query (if not). Numeric arguments are treated as ids. Use --type to specify explicitly
db Database management operations
import Import clipboard data from stdin (default: TSV format)
watch Start a process to watch clipboard for changes and store automatically
help Print this message or the help of the given subcommand(s)
Options:
--max-items <MAX_ITEMS>
Maximum number of clipboard entries to keep [default: 18446744073709551615]
--max-dedupe-search <MAX_DEDUPE_SEARCH>
Number of recent entries to check for duplicates when storing new clipboard data [default: 20]
--preview-width <PREVIEW_WIDTH>
Maximum width (in characters) for clipboard entry previews in list output [default: 100]
--db-path <DB_PATH>
Path to the `SQLite` clipboard database file [env: STASH_DB_PATH=]
--excluded-apps <EXCLUDED_APPS>
Application names to exclude from clipboard history [env: STASH_EXCLUDED_APPS=]
--ask
Ask for confirmation before destructive operations
-v, --verbose...
Increase logging verbosity
-q, --quiet...
Decrease logging verbosity
-h, --help
Print help
-V, --version
Print version
```
<!-- markdownlint-enable MD013 -->
### Store an entry ### Store an entry
@ -205,39 +132,18 @@ echo "some clipboard text" | stash store
stash list stash list
``` ```
Stash list will list all entries in an interactive TUI that allows navigation
and copying/deleting entries. This behaviour is EXCLUSIVE TO TTYs and Stash will
display entries in Cliphist-compatible TSV format in Bash scripts. You may also
enforce the output format with `stash list --format <tsv | json>`.
You may also view your clipboard _with the addition of expired entries_, i.e.,
entries that have reached their TTL and are marked as expired, using the
`--expired` flag as `stash list --expired`. Expired entries are not cleaned up
when using this flag, allowing you to inspect them before running cleanup.
### Decode an entry by ID ### Decode an entry by ID
```bash ```bash
stash decode <input ID> stash decode --input "1234"
``` ```
> [!TIP]
> Decoding from dmenu-compatible tools:
>
> ```bash
> stash list | tofi | stash decode
> ```
### Delete entries matching a query ### Delete entries matching a query
```bash ```bash
stash delete --type [id | query] <text or ID> stash delete --type query --arg "some text"
``` ```
By default stash will try to guess the type of an entry, but this may not be
desirable for all users. If you wish to be explicit, pass `--type` to
`stash delete`.
### Delete multiple entries by ID (from a file or stdin) ### Delete multiple entries by ID (from a file or stdin)
```bash ```bash
@ -246,33 +152,10 @@ 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
@ -282,63 +165,9 @@ stash watch
This runs a daemon that monitors the clipboard and stores new entries This runs a daemon that monitors the clipboard and stores new entries
automatically. This is designed as an alternative to shelling out to automatically. This is designed as an alternative to shelling out to
`wl-paste --watch` inside a Systemd service or XDG autostart. You may find a `wl-paste --watch` inside a Systemd service or XDG autostart. You may find a
premade Systemd service in `contrib/`. Packagers are encouraged to vendor the premade Systemd service in `vendor/`. Packagers are encouraged to vendor the
service unless adding their own. service unless adding their own.
#### Automatic Clipboard Clearing on Expiration
When `stash watch` is running and a clipboard entry expires, Stash will detect
if the current clipboard still contains that expired content and automatically
clear it. This prevents stale data from remaining in your clipboard after an
entry has expired from history.
> [!NOTE]
> This behavior only applies when the watch daemon is actively running. Manual
> expiration or deletion of entries will not clear the clipboard.
#### MIME Type Preference for Watch
`stash watch` supports a `--mime-type` (short `-t`) option that lets you
prioritise which MIME type the daemon should request from the clipboard when
multiple representations are available.
- `any` (default): Request any available representation (current behaviour).
- `text`: Prefer text representations (e.g. `text/plain`, `text/html`).
- `image`: Prefer image representations (e.g. `image/png`, `image/jpeg`) so that
image copies from browsers or file managers are stored as images rather than
HTML fragments.
Example: prefer images when running the watch daemon
```bash
stash watch --mime-type image
```
This is useful when copying images from browsers or file managers where the
clipboard may offer both HTML and image representations; selecting `image` will
ask the compositor for image data first. Most users will be fine using the
default value (`any`) but in the case your browser (or other applications!)
regularly misrepresent data, you might wish to prioritize a different type.
#### Clipboard Persistence
By default, when you copy something and close the source application, Wayland
clears the clipboard. Stash can optionally keep the clipboard contents available
after the source closes using the `--persist` flag.
```bash
stash watch --persist
```
When enabled, Stash will fork a background process to serve the clipboard
contents, keeping them available even after the original application exits.
> [!NOTE]
> This feature is **opt-in** and disabled by default, as it may not be desirable
> for all users and can leave clipboard data in memory longer than expected. You
> must start the `stash watch` daemon with `--persist` for clipboard
> persistence.
### Options ### Options
Some commands take additional flags to modify Stash's behavior. See each Some commands take additional flags to modify Stash's behavior. See each
@ -350,84 +179,35 @@ commands `--help` text for more details. The following are generally standard:
- `--preview-width <N>`: Text preview max width for `list` - `--preview-width <N>`: Text preview max width for `list`
- `--version`: Print the current version and exit - `--version`: Print the current version and exit
### Sensitive Clipboard Filtering #### Sensitive Clipboard Filtering
Stash can be configured to avoid storing clipboard entries that match a Stash can be configured to avoid storing clipboard entries that match a
sensitive pattern, using a regular expression. This is useful for preventing sensitive pattern, using a regular expression. This is useful for preventing
accidental storage of secrets, passwords, or other sensitive data. You don't accidental storage of secrets, passwords, or other sensitive data. You don't
want sensitive data ending up in your persistent clipboard, right? want sensitive data ending up in your persistent clipboard, right?
The filter can be configured in one of three ways, as part of two separate The filter can be configured in one of two ways:
features.
#### Clipboard Filtering by Entry Regex - **Environment variable**: Set `STASH_SENSITIVE_REGEX` to a valid regex
pattern. If clipboard text matches, it will not be stored.
- **Systemd LoadCredential**: If running as a service, you can provide a regex
pattern via a credential file. For example, add to your `stash.service`:
This can be configured in one of two ways. You can use the **environment ```ini
variable** `STASTH_SENSITIVE_REGEX` to a valid regex pattern, and if the LoadCredential=clipboard_filter:/etc/stash/clipboard_filter
clipboard text matches the regex it will not be stored. This can be used for ```
trivial secrets such as but not limited to GitHub tokens or secrets that follow
a rule, e.g. a prefix. You would typically set this in your `~/.bashrc` or
similar but in some cases this might be a security flaw.
The safer alternative to this is using **Systemd LoadCrediental**. If Stash is The file `/etc/stash/clipboard_filter` should contain your regex pattern (no
running as a Systemd service, you can provide a regex pattern using a crediental quotes). This is done automatically in the vendored Systemd service. Remember
file. For example, add to your `stash.service`: to set the appropriate file permissions if using this option.
```dosini
LoadCredential=clipboard_filter:/etc/stash/clipboard_filter
```
The file `/etc/stash/clipboard_filter` should contain your regex pattern (no
quotes). This is done automatically in the
[vendored Systemd service](./contrib/stash.service). Remember to set the
appropriate file permissions if using this option.
The service will check the credential file first, then the environment variable. The service will check the credential file first, then the environment variable.
If a clipboard entry matches the regex, it will be skipped and a warning will be If a clipboard entry matches the regex, it will be skipped and a warning will be
logged. logged.
> [!TIP] **Example regex to block common password patterns**:
> **Example regex to block common password patterns**:
>
> `(password|secret|api[_-]?key|token)[=: ]+[^\s]+`
>
> For security reasons, you are recommended to use the regex only for generic
> tokens that follow a specific rule, for example a generic prefix or suffix.
#### Clipboard Filtering by Application Class - `(password|secret|api[_-]?key|token)[=: ]+[^\s]+`
Stash allows blocking an entry from the persistent history if it has been copied
from certain applications. This depends on the `use-toplevel` feature flag and
uses the the `wlr-foreign-toplevel-management-v1` protocol for precise focus
detection. While this feature flag is enabled (the default) you may use
`--excluded-apps` in, e.g., `stash watch` or set the `STASH_EXCLUDED_APPS`
environment variable to block entries from persisting in the database if they
are coming from your password manager for example. The entry is still copied to
the clipboard, but it will never be put inside the database.
This is a more robust alternative to using the regex method above, since you
likely do not want to catch your passwords with a regex. Simply pass your
password manager's **window class** to `--excluded-apps` and your passwords will
be only copied to the clipboard.
> [!TIP]
> **Example startup command for Stash daemon**:
>
> `stash --excluded-apps Bitwarden watch`
## Motivation
I've been a long-time user of Cliphist. You can probably tell by the number of
times it has been mentioned in the README, if not for the attributions section,
that Stash is _clearly_ inspired and adapted from it. It's actually a great
clipboard manager if your needs are simple, but mine aren't. I need an
**all-in-one** solution, that I can freely hack on, with simple solutions to
complex problems that I've had with managing my clipboard. I wanted it to be
scriptable _and_ interactive, I wanted it to be performant, I wanted it to be...
You get the point. Perhaps you also share similar needs, or just like Rust
software in general on your desktop. In either case, Stash hopes to serve as an
excellent clipboard manager for your needs, with _excellent_ performance.
## Tips & Tricks ## Tips & Tricks
@ -452,7 +232,7 @@ should know.
- Stash adds a `watch` command to automatically store clipboard changes. This is - Stash adds a `watch` command to automatically store clipboard changes. This is
an alternative to `wl-paste --watch cliphist list`. You can avoid shelling out an alternative to `wl-paste --watch cliphist list`. You can avoid shelling out
and depending on `wl-paste` as Stash implements it through `wl-clipboard-rs` and depending on `wl-paste` as Stash implements it through `wl-clipboard-rs`
crate and provides its own `wl-copy` and `wl-paste` binaries. crate.
### TSV Export and Import ### TSV Export and Import
@ -505,116 +285,3 @@ figured out something new, e.g. a neat shell trick, feel free to add it here!
```bash ```bash
cliphist list --db ~/.cache/cliphist/db | stash import cliphist list --db ~/.cache/cliphist/db | stash import
``` ```
3. Stash provides its own implementation of `wl-copy` and `wl-paste` commands
backed by `wl-clipboard-rs`. Those implementations are backwards compatible
with `wl-clipboard`, and may be used as **drop-in** replacements. The default
build wrapper in `build.rs` links `stash` to `stash-copy` and `stash-paste`,
which are also available as `wl-copy` and `wl-paste` respectively. The Nix
package automatically links those to `$out/bin` for you, which means they are
installed by default but other package managers may need additional steps by
the packagers. While building from source, you may link
`target/release/stash` manually.
### Entry Expiration
Stash supports time-to-live (TTL) for clipboard entries. When an entry's
expiration time is reached, it is marked as expired rather than immediately
deleted. This allows for inspection of expired entries and automatic clipboard
cleanup.
#### How Expiration Works
When `stash watch` is running with `--expire-after`, it monitors the clipboard
and processes expired entries periodically. Upon expiration:
1. The entry's `is_expired` flag is set to `1` in the database
2. If the current clipboard content matches the expired entry, Stash clears the
clipboard to prevent pasting stale data
3. Expired entries are excluded from normal list operations unless `--expired`
is specified
> [!NOTE]
> By default, entries do not expire. Use `stash watch --expire-after DURATION`
> to enable expiration (e.g., `--expire-after 24h` for 24-hour TTL).
#### Viewing Expired Entries
Use `stash list --expired` to include expired entries in the output. This is
useful for:
- Inspecting what has expired from your clipboard history
- Verifying that sensitive data has been properly expired
- Debugging expiration behavior
```bash
# View all entries including expired ones
stash list --expired
# View expired entries in JSON format
stash list --expired --format json
```
#### Cleaning Up Expired Entries
The watch daemon automatically cleans up expired entries when it processes them.
For manual cleanup, use:
```bash
# Remove all expired entries from the database
stash db wipe --expired
```
> [!NOTE]
> If you have a large number of expired entries, consider running
> `stash db vacuum` afterward to reclaim disk space.
#### Automatic Clipboard Clearing
When `stash watch` is running and an entry expires, Stash checks if the current
clipboard still contains that expired content. If it matches, Stash clears the
clipboard automatically. This prevents accidentally pasting outdated content.
> [!TIP]
> This behavior only applies when the watch daemon is actively running. Manual
> expiration or deletion of entries will not clear the clipboard.
#### Database Maintenance
Stash uses SQLite for persistent storage. Over time, deleted entries and
fragmentation can affect performance. Use the `stash db` command to maintain
your database:
- **Check statistics**: `stash db stats` shows entry counts and storage usage.
Use this to monitor growth and decide when to clean up.
- **Remove expired entries**: `stash db wipe --expired` removes entries that
have reached their TTL. The daemon normally handles this, but this is useful
for manual cleanup.
- **Optimize storage**: `stash db vacuum` runs SQLite's VACUUM command to
reclaim space and defragment the database. This is safe to run periodically.
It is recommended to run `stash db vacuum` occasionally (e.g., monthly) to keep
the database compact, especially after deleting many entries. You can, of
course, wipe the database entirely if it has grown too large.
## Attributions
My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the
[wl-clipboard-rs](https://github.com/YaLTeR/wl-clipboard-rs) crate. Stash is
powered by [several crates](./Cargo.toml), but none of them were as detrimental
in Stash's design process.
Secondly, but by no means less importantly, I would like to thank
[cliphist](https://github.com/sentriz/cliphist) for the excellent reference it
has provided to me as a "solid clipboard manager." The interface of Stash is
inspired by Cliphist, and it has served me very well for a very long time.
Additional and definitely heartfelt thanks to my testers, who have tested
earlier versions of Stash, helped with packaging and provided feedback. Thank
you :)
## License
This project is made available under Mozilla Public License (MPL) version 2.0.
See [LICENSE](LICENSE) for more details on the exact conditions. An online copy
is provided [here](https://www.mozilla.org/en-US/MPL/2.0/).

12
flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": { "nodes": {
"crane": { "crane": {
"locked": { "locked": {
"lastModified": 1776635034, "lastModified": 1754269165,
"narHash": "sha256-OEOJrT3ZfwbChzODfIH4GzlNTtOFuZFWPtW7jIeR8xU=", "narHash": "sha256-0tcS8FHd4QjbCVoxN9jI+PjHgA4vc/IjkUSp+N3zy0U=",
"owner": "ipetkov", "owner": "ipetkov",
"repo": "crane", "repo": "crane",
"rev": "dc7496d8ea6e526b1254b55d09b966e94673750f", "rev": "444e81206df3f7d92780680e45858e31d2f07a08",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -17,11 +17,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1775710090, "lastModified": 1754725699,
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=", "narHash": "sha256-iAcj9T/Y+3DBy2J0N+yF9XQQQ8IEb5swLFzs23CdP88=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "4c1018dae018162ec878d42fec712642d214fdfa", "rev": "85dbfc7aaf52ecb755f87e577ddbe6dbbdbc1054",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -1,8 +1,6 @@
{ {
inputs = { inputs.nixpkgs.url = "github:NixOS/nixpkgs?ref=nixos-unstable";
nixpkgs.url = "github:NixOS/nixpkgs?ref=nixos-unstable"; inputs.crane.url = "github:ipetkov/crane";
crane.url = "github:ipetkov/crane";
};
outputs = { outputs = {
self, self,
@ -13,16 +11,10 @@
forEachSystem = nixpkgs.lib.genAttrs systems; forEachSystem = nixpkgs.lib.genAttrs systems;
pkgsForEach = nixpkgs.legacyPackages; pkgsForEach = nixpkgs.legacyPackages;
in { in {
nixosModules = { packages = forEachSystem (system: {
stash = import ./nix/modules/nixos.nix self; default = pkgsForEach.${system}.callPackage ./nix/package.nix {
default = self.nixosModules.stash; craneLib = crane.mkLib pkgsForEach.${system};
}; };
packages = forEachSystem (system: let
craneLib = crane.mkLib pkgsForEach.${system};
in {
stash = pkgsForEach.${system}.callPackage ./nix/package.nix {inherit craneLib;};
default = self.packages.${system}.stash;
}); });
devShells = forEachSystem (system: { devShells = forEachSystem (system: {

View file

@ -1,78 +0,0 @@
self: {
config,
lib,
pkgs,
...
}: let
inherit (lib.modules) mkIf;
inherit (lib.options) mkOption mkEnableOption mkPackageOption literalMD;
inherit (lib.types) listOf str;
inherit (lib.strings) concatStringsSep;
inherit (lib.meta) getExe;
cfg = config.services.stash-clipboard;
in {
options.services.stash-clipboard = {
enable = mkEnableOption "stash, a Wayland clipboard manager";
package = mkPackageOption self.packages.${pkgs.system} ["stash"] {};
flags = mkOption {
type = listOf str;
default = [];
example = ["--max-items 10"];
description = "Flags to pass to stash watch.";
};
filterFile = mkOption {
type = str;
default = "";
example = "{file}`/etc/stash/clipboard_filter`";
description = literalMD ''
File containing a regular expression to catch sensitive patterns. The file
passed to this option must contain your regex pattern with no quotes.
::: {.tip}
Example regex to block common password patterns:
* `(password|secret|api[_-]?key|token)[=: ]+[^\s]+`
:::
'';
};
excludedApps = mkOption {
type = listOf str;
default = [];
example = ["Bitwarden"];
description = ''
Stash will avoid storing data if the active window class matches the
entries passed to this option. This is useful for avoiding persistent
passwords in the database, while still allowing one-time copies.
Entries from these apps are still copied to the clipboard, but it will
never be put inside the database.
'';
};
};
config = mkIf cfg.enable {
environment.systemPackages = [cfg.package];
systemd = {
packages = [cfg.package];
user.services.stash-clipboard = {
description = "Stash clipboard manager daemon";
wantedBy = ["graphical-session.target"];
after = ["graphical-session.target"];
serviceConfig = {
ExecStart = "${getExe cfg.package} ${concatStringsSep " " cfg.flags} watch";
LoadCredential = mkIf (cfg.filterFile != "") "clipboard_filter:${cfg.filterFile}";
};
environment = mkIf (cfg.excludedApps != []) {
STASH_EXCLUDED_APPS = concatStringsSep "," cfg.excludedApps;
};
};
};
};
}

View file

@ -1,14 +1,9 @@
{ {
lib, lib,
craneLib, craneLib,
stdenv,
mold,
versionCheckHook,
useMold ? stdenv.isLinux,
createSymlinks ? true,
}: let }: let
pname = "stash"; pname = "stash";
version = (lib.importTOML ../Cargo.toml).package.version; version = (builtins.fromTOML (builtins.readFile ../Cargo.toml)).package.version;
src = let src = let
fs = lib.fileset; fs = lib.fileset;
s = ../.; s = ../.;
@ -33,39 +28,18 @@ in
strictDeps = true; strictDeps = true;
# Since Crane doesn't have a good way of enforcing that our symlinks # Install Systemd service for Stash into $out/share.
# generated by the build wrapper are correctly linked, we should link # This can be used to use Stash in 'systemd.packages'
# them *manually*. The postInstallCheck phase that follows will check postInstall = ''
# to verify if all of those links are in place.
postInstall = lib.optionalString createSymlinks ''
mkdir -p $out mkdir -p $out
for bin in stash-copy stash-paste wl-copy wl-paste; do install -Dm755 ${../vendor/stash.service} $out/share/stash.service
ln -sf $out/bin/stash $out/bin/$bin
done
''; '';
nativeInstallCheckInputs = [versionCheckHook];
doInstallCheck = true;
# After the version check, let's see if all binaries are linked correctly.
# We could probably add a check phase to get the versions of each.
postInstallCheck = lib.optionalString createSymlinks ''
for bin in stash stash-copy stash-paste wl-copy wl-paste; do
[ -x "$out/bin/$bin" ] || { echo "$bin missing"; exit 1; }
done
'';
env = lib.optionalAttrs useMold {
CARGO_LINKER = "clang";
CARGO_RUSTFLAGS = "-Clink-arg=-fuse-ld=${mold}/bin/mold";
};
meta = { meta = {
description = "Wayland clipboard manager with fast persistent history and multi-media support"; description = "Wayland clipboard manager with fast persistent history and multi-media support";
homepage = "https://github.com/notashelf/stash"; homepage = "https://github.com/notashelf/stash";
license = lib.licenses.mpl20; license = lib.licenses.mpl20;
maintainers = [lib.maintainers.NotAShelf]; maintainers = [lib.maintainers.NotAShelf];
mainProgram = "stash"; mainProgram = "stash";
platforms = lib.platforms.linux;
}; };
} }

View file

@ -6,7 +6,6 @@
clippy, clippy,
taplo, taplo,
rust-analyzer-unwrapped, rust-analyzer-unwrapped,
cargo-nextest,
rustPlatform, rustPlatform,
}: }:
mkShell { mkShell {
@ -21,9 +20,6 @@ mkShell {
cargo cargo
taplo taplo
rust-analyzer-unwrapped rust-analyzer-unwrapped
# Additional Cargo Tooling
cargo-nextest
]; ];
RUST_SRC_PATH = "${rustPlatform.rustLibSrc}"; RUST_SRC_PATH = "${rustPlatform.rustLibSrc}";

View file

@ -1,3 +0,0 @@
pub mod persist;
pub use persist::{ClipboardData, get_serving_pid, persist_clipboard};

View file

@ -1,262 +0,0 @@
use std::{
process::exit,
sync::atomic::{AtomicI32, Ordering},
};
use wl_clipboard_rs::copy::{
ClipboardType,
MimeType as CopyMimeType,
Options,
PreparedCopy,
ServeRequests,
Source,
};
/// Maximum number of paste requests to serve before exiting. This (hopefully)
/// prevents runaway processes while still providing persistence.
const MAX_SERVE_REQUESTS: usize = 1000;
/// PID of the current clipboard persistence child process. Used to detect when
/// clipboard content is from our own serve process.
static SERVING_PID: AtomicI32 = AtomicI32::new(0);
/// Get the current serving PID if any. Used by the watch loop to avoid
/// duplicate persistence processes.
pub fn get_serving_pid() -> Option<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");
}
}

View file

@ -26,30 +26,30 @@ impl DecodeCommand for SqliteClipboardDb {
let mut buf = String::new(); let mut buf = String::new();
in_ in_
.read_to_string(&mut buf) .read_to_string(&mut buf)
.map_err(|e| StashError::DecodeRead(e.to_string().into()))?; .map_err(|e| StashError::DecodeRead(e.to_string()))?;
buf buf
}; };
// 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)
{ {
let mut buf = Vec::new(); let mut buf = Vec::new();
reader.read_to_end(&mut buf).map_err(|e| { reader.read_to_end(&mut buf).map_err(|e| {
StashError::DecodeRead( StashError::DecodeRead(format!(
format!("Failed to read clipboard for relay: {e}").into(), "Failed to read clipboard for relay: {e}"
) ))
})?; })?;
out.write_all(&buf).map_err(|e| { out.write_all(&buf).map_err(|e| {
StashError::DecodeWrite( StashError::DecodeWrite(format!(
format!("Failed to write clipboard relay: {e}").into(), "Failed to write clipboard relay: {e}"
) ))
})?; })?;
} else { } else {
return Err(StashError::DecodeGet( return Err(StashError::DecodeGet(
"Failed to get clipboard contents for relay".into(), "Failed to get clipboard contents for relay".to_string(),
)); ));
} }
return Ok(()); return Ok(());
@ -69,14 +69,14 @@ impl DecodeCommand for SqliteClipboardDb {
{ {
let mut buf = Vec::new(); let mut buf = Vec::new();
reader.read_to_end(&mut buf).map_err(|err| { reader.read_to_end(&mut buf).map_err(|err| {
StashError::DecodeRead( StashError::DecodeRead(format!(
format!("Failed to read clipboard for relay: {err}").into(), "Failed to read clipboard for relay: {err}"
) ))
})?; })?;
out.write_all(&buf).map_err(|err| { out.write_all(&buf).map_err(|err| {
StashError::DecodeWrite( StashError::DecodeWrite(format!(
format!("Failed to write clipboard relay: {err}").into(), "Failed to write clipboard relay: {err}"
) ))
})?; })?;
Ok(()) Ok(())
} else { } else {

View file

@ -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)
} }
} }

View file

@ -1,6 +1,12 @@
use std::io::{self, BufRead}; use std::io::{self, BufRead};
use crate::db::{ClipboardDb, Entry, SqliteClipboardDb, StashError}; use crate::db::{
ClipboardDb,
Entry,
SqliteClipboardDb,
StashError,
detect_mime,
};
pub trait ImportCommand { pub trait ImportCommand {
/// Import clipboard entries from TSV format. /// Import clipboard entries from TSV format.
@ -21,24 +27,24 @@ impl ImportCommand for SqliteClipboardDb {
let mut imported = 0; let mut imported = 0;
for (lineno, line) in reader.lines().enumerate() { for (lineno, line) in reader.lines().enumerate() {
let line = line.map_err(|e| { let line = line.map_err(|e| {
StashError::Store(format!("Failed to read line {lineno}: {e}").into()) StashError::Store(format!("Failed to read line {lineno}: {e}"))
})?; })?;
let mut parts = line.splitn(2, '\t'); let mut parts = line.splitn(2, '\t');
let (Some(id_str), Some(val)) = (parts.next(), parts.next()) else { let (Some(id_str), Some(val)) = (parts.next(), parts.next()) else {
return Err(StashError::Store( return Err(StashError::Store(format!(
format!("Malformed TSV line {lineno}: {line:?}").into(), "Malformed TSV line {lineno}: {line:?}"
)); )));
}; };
let Ok(_id) = id_str.parse::<u64>() else { let Ok(_id) = id_str.parse::<u64>() else {
return Err(StashError::Store( return Err(StashError::Store(format!(
format!("Failed to parse id from line {lineno}: {id_str}").into(), "Failed to parse id from line {lineno}: {id_str}"
)); )));
}; };
let entry = Entry { let entry = Entry {
contents: val.as_bytes().to_vec(), contents: val.as_bytes().to_vec(),
mime: crate::mime::detect_mime(val.as_bytes()), mime: detect_mime(val.as_bytes()),
}; };
self self
@ -48,18 +54,18 @@ impl ImportCommand for SqliteClipboardDb {
rusqlite::params![entry.contents, entry.mime], rusqlite::params![entry.contents, entry.mime],
) )
.map_err(|e| { .map_err(|e| {
StashError::Store( StashError::Store(format!(
format!("Failed to insert entry at line {lineno}: {e}").into(), "Failed to insert entry at line {lineno}: {e}"
) ))
})?; })?;
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(())
} }

View file

@ -6,13 +6,8 @@ use unicode_width::UnicodeWidthStr;
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
pub trait ListCommand { pub trait ListCommand {
fn list( fn list(&self, out: impl Write, preview_width: u32)
&self, -> Result<(), StashError>;
out: impl Write,
preview_width: u32,
include_expired: bool,
reverse: bool,
) -> Result<(), StashError>;
} }
impl ListCommand for SqliteClipboardDb { impl ListCommand for SqliteClipboardDb {
@ -20,278 +15,18 @@ 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 self.list_entries(out, preview_width).map(|_| ())
.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( pub fn list_tui(&self, preview_width: u32) -> Result<(), StashError> {
&self,
preview_width: u32,
include_expired: bool,
reverse: bool,
) -> Result<(), StashError> {
use std::io::stdout; use std::io::stdout;
use crossterm::{ use crossterm::{
event::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
self,
DisableMouseCapture,
EnableMouseCapture,
Event,
KeyCode,
KeyModifiers,
},
execute, execute,
terminal::{ terminal::{
EnterAlternateScreen, EnterAlternateScreen,
@ -300,7 +35,6 @@ impl SqliteClipboardDb {
enable_raw_mode, enable_raw_mode,
}, },
}; };
use notify_rust::Notification;
use ratatui::{ use ratatui::{
Terminal, Terminal,
backend::CrosstermBackend, backend::CrosstermBackend,
@ -308,175 +42,75 @@ impl SqliteClipboardDb {
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState}, widgets::{Block, Borders, List, ListItem, ListState},
}; };
use wl_clipboard_rs::copy::{MimeType, Options, Source};
// One-time column-width metadata (no blob reads). // Query entries from DB
let (max_id_width, max_mime_width) = let mut stmt = self
global_column_widths(self, include_expired)?; .conn
.prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC")
.map_err(|e| StashError::ListDecode(e.to_string()))?;
let mut rows = stmt
.query([])
.map_err(|e| StashError::ListDecode(e.to_string()))?;
enable_raw_mode() let mut entries: Vec<(u64, String, String)> = Vec::new();
.map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut max_id_width = 2;
let mut max_mime_width = 8;
while let Some(row) = rows
.next()
.map_err(|e| StashError::ListDecode(e.to_string()))?
{
let id: u64 = row
.get(0)
.map_err(|e| StashError::ListDecode(e.to_string()))?;
let contents: Vec<u8> = row
.get(1)
.map_err(|e| StashError::ListDecode(e.to_string()))?;
let mime: Option<String> = row
.get(2)
.map_err(|e| StashError::ListDecode(e.to_string()))?;
let preview =
crate::db::preview_entry(&contents, mime.as_deref(), preview_width);
let mime_str = mime.as_deref().unwrap_or("").to_string();
let id_str = id.to_string();
max_id_width = max_id_width.max(id_str.width());
max_mime_width = max_mime_width.max(mime_str.width());
entries.push((id, preview, mime_str));
}
enable_raw_mode().map_err(|e| StashError::ListDecode(e.to_string()))?;
let mut stdout = stdout(); let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture) execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?; .map_err(|e| StashError::ListDecode(e.to_string()))?;
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
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()))?;
// Derive initial window size from current terminal height. let mut state = ListState::default();
let initial_height = terminal if !entries.is_empty() {
.size() state.select(Some(0));
.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. let res = (|| -> Result<(), StashError> {
struct EventActions { loop {
quit: bool,
net_down: i64, // positive=down, negative=up, 0=none
copy: bool,
delete: bool,
toggle_search: bool, // enter/exit search mode
search_input: Option<char>, // character typed in search mode
search_backspace: bool, // backspace in search mode
clear_search: bool, // clear search query (ESC in search mode)
}
/// Drain all pending key events and return what actions to perform.
/// Navigation is capped to +-1 per frame to prevent jumpy scrolling when
/// the key-repeat rate exceeds the render frame rate.
fn drain_events(tui: &TuiState) -> Result<EventActions, StashError> {
let mut actions = EventActions {
quit: false,
net_down: 0,
copy: false,
delete: false,
toggle_search: false,
search_input: None,
search_backspace: false,
clear_search: false,
};
while event::poll(std::time::Duration::from_millis(0))
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
{
if let Event::Key(key) = event::read()
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
{
if tui.search_mode {
// In search mode, handle text input
match (key.code, key.modifiers) {
(KeyCode::Esc, _) => {
actions.clear_search = true;
},
(KeyCode::Enter, _) => {
actions.toggle_search = true; // exit search mode
},
(KeyCode::Backspace, _) => {
actions.search_backspace = true;
},
(KeyCode::Char(c), _) => {
actions.search_input = Some(c);
},
_ => {},
}
} else {
// Normal mode navigation commands
match (key.code, key.modifiers) {
(KeyCode::Char('q') | KeyCode::Esc, _) => actions.quit = true,
(KeyCode::Down | KeyCode::Char('j'), _) => {
// Cap at +1 per frame for smooth scrolling
if actions.net_down < 1 {
actions.net_down += 1;
}
},
(KeyCode::Up | KeyCode::Char('k'), _) => {
// Cap at -1 per frame for smooth scrolling
if actions.net_down > -1 {
actions.net_down -= 1;
}
},
(KeyCode::Enter, _) => actions.copy = true,
(KeyCode::Char('D'), KeyModifiers::SHIFT) => {
actions.delete = true;
},
(KeyCode::Char('/'), _) => actions.toggle_search = true,
_ => {},
}
}
}
}
Ok(actions)
}
let draw_frame =
|terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
tui: &mut TuiState,
list_state: &mut ListState,
max_id_width: usize,
max_mime_width: usize|
-> Result<(), StashError> {
// Reserve 2 rows for search bar when in search mode
let search_bar_height = if tui.search_mode { 2 } else { 0 };
let term_height = terminal
.size()
.map(|r| r.height.saturating_sub(2 + search_bar_height) as usize)
.unwrap_or(24)
.max(1);
tui.resize(term_height);
tui.sync(self, include_expired, preview_width)?;
if tui.total == 0 {
list_state.select(None);
} else {
list_state.select(Some(tui.local_cursor()));
}
terminal terminal
.draw(|f| { .draw(|f| {
let area = f.area(); let area = f.area();
let block = Block::default()
// Build title based on search state .title("Clipboard Entries (j/k/↑/↓ to move, q/ESC to quit)")
let title = if tui.search_mode { .borders(Borders::ALL);
format!("Search: {}", tui.search_query)
} else if tui.search_query.is_empty() {
"Clipboard Entries (j/k/↑/↓ to move, / to search, Enter to copy, \
Shift+D to delete, q/ESC to quit)"
.to_string()
} else {
format!(
"Clipboard Entries (filtered: '{}' - {} results, / to search, \
ESC to clear, q to quit)",
tui.search_query, tui.total
)
};
let block = Block::default().title(title).borders(Borders::ALL);
let border_width = 2; let 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; let spaces = 3; // [id][ ][preview][ ][mime]
// 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
@ -485,6 +119,7 @@ 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 {
@ -507,13 +142,13 @@ impl SqliteClipboardDb {
preview_col = min_preview_width; preview_col = min_preview_width;
} }
let selected = list_state.selected(); let selected = state.selected();
let list_items: Vec<ListItem> = tui let list_items: Vec<ListItem> = entries
.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) {
@ -525,6 +160,7 @@ 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) {
@ -537,6 +173,8 @@ 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 {
@ -583,162 +221,49 @@ impl SqliteClipboardDb {
.fg(Color::Yellow) .fg(Color::Yellow)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
) )
.highlight_symbol(""); .highlight_symbol(""); // handled manually
f.render_stateful_widget(list, area, list_state); f.render_stateful_widget(list, area, &mut state);
}) })
.map_err(|e| StashError::ListDecode(e.to_string().into()))?; .map_err(|e| StashError::ListDecode(e.to_string()))?;
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()))?
{ {
let actions = drain_events(&tui)?; if let Event::Key(key) =
event::read().map_err(|e| StashError::ListDecode(e.to_string()))?
if actions.quit { {
break; match key.code {
} KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Down | KeyCode::Char('j') => {
// Handle search mode actions let i = match state.selected() {
if actions.toggle_search { Some(i) => {
tui.toggle_search_mode(); if i >= entries.len() - 1 {
} 0
} else {
if actions.clear_search && tui.clear_search() { i + 1
// Search was cleared, refresh count
tui.total =
self.count_entries(include_expired, tui.search_filter())?;
}
if let Some(c) = actions.search_input {
let new_query = format!("{}{}", tui.search_query, c);
if tui.set_search(new_query) {
// Search changed, refresh count and reset
tui.total =
self.count_entries(include_expired, tui.search_filter())?;
}
}
if actions.search_backspace {
let new_query = tui
.search_query
.chars()
.next_back()
.map(|_| {
tui
.search_query
.chars()
.take(tui.search_query.len() - 1)
.collect::<String>()
})
.unwrap_or_default();
if tui.set_search(new_query) {
// Search changed, refresh count and reset
tui.total =
self.count_entries(include_expired, tui.search_filter())?;
}
}
// Apply navigation (capped at ±1 per frame for smooth scrolling).
if !tui.search_mode {
if actions.net_down > 0 {
tui.move_down();
} else if actions.net_down < 0 {
tui.move_up();
}
if actions.delete
&& let Some(&(id, ..)) = tui.selected_entry()
{
self
.conn
.execute(
"DELETE FROM clipboard WHERE id = ?1",
rusqlite::params![id],
)
.map_err(|e| {
StashError::DeleteEntry(id, e.to_string().into())
})?;
tui.on_delete();
let _ = Notification::new()
.summary("Stash")
.body("Deleted entry")
.show();
}
if actions.copy
&& let Some(&(id, ..)) = tui.selected_entry()
{
if tui.copying_entry == Some(id) {
log::debug!(
"Skipping duplicate copy for entry {id} (already in \
progress)"
);
} else {
tui.copying_entry = Some(id);
match self.copy_entry(id) {
Ok((new_id, contents, mime)) => {
if new_id != id {
tui.dirty = true;
}
let opts = Options::new();
let mime_type = match mime {
Some(ref m) if m == "text/plain" => MimeType::Text,
Some(ref m) => MimeType::Specific(m.clone().clone()),
None => MimeType::Text,
};
let copy_result = opts
.copy(Source::Bytes(contents.clone().into()), mime_type);
match copy_result {
Ok(()) => {
let _ = Notification::new()
.summary("Stash")
.body("Copied entry to clipboard")
.show();
},
Err(e) => {
log::error!("failed to copy entry to clipboard: {e}");
let _ = Notification::new()
.summary("Stash")
.body(&format!("Failed to copy to clipboard: {e}"))
.show();
},
} }
}, },
Err(e) => { None => 0,
log::error!("failed to fetch entry {id}: {e}"); };
let _ = Notification::new() state.select(Some(i));
.summary("Stash") },
.body(&format!("Failed to fetch entry: {e}")) KeyCode::Up | KeyCode::Char('k') => {
.show(); let i = match state.selected() {
Some(i) => {
if i == 0 {
entries.len() - 1
} else {
i - 1
}
}, },
} None => 0,
tui.copying_entry = None; };
} state.select(Some(i));
},
_ => {},
} }
} }
// Redraw once after processing all accumulated input.
draw_frame(
&mut terminal,
&mut tui,
&mut list_state,
max_id_width,
max_mime_width,
)?;
} }
} }
Ok(()) Ok(())

View file

@ -5,3 +5,4 @@ pub mod list;
pub mod query; pub mod query;
pub mod store; pub mod store;
pub mod watch; pub mod watch;
pub mod wipe;

View file

@ -6,6 +6,6 @@ pub trait QueryCommand {
impl QueryCommand for SqliteClipboardDb { impl QueryCommand for SqliteClipboardDb {
fn query_delete(&self, query: &str) -> Result<usize, StashError> { fn query_delete(&self, query: &str) -> Result<usize, StashError> {
<Self as ClipboardDb>::delete_query(self, query) <SqliteClipboardDb as ClipboardDb>::delete_query(self, query)
} }
} }

View file

@ -2,7 +2,6 @@ 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,9 +9,6 @@ pub trait StoreCommand {
max_dedupe_search: u64, max_dedupe_search: u64,
max_items: u64, max_items: u64,
state: Option<String>, state: Option<String>,
excluded_apps: &[String],
min_size: Option<usize>,
max_size: usize,
) -> Result<(), crate::db::StashError>; ) -> Result<(), crate::db::StashError>;
} }
@ -23,25 +19,13 @@ impl StoreCommand for SqliteClipboardDb {
max_dedupe_search: u64, max_dedupe_search: u64,
max_items: u64, max_items: u64,
state: Option<String>, state: Option<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, max_dedupe_search, max_items)?;
input, log::info!("Entry stored");
max_dedupe_search,
max_items,
Some(excluded_apps),
min_size,
max_size,
None, // no pre-computed hash for CLI store
None, // no mime types for CLI store
)?;
log::info!("entry stored");
} }
Ok(()) Ok(())
} }

View file

@ -1,712 +1,80 @@
use std::{collections::BinaryHeap, hash::Hasher, io::Read, time::Duration}; use std::{io::Read, time::Duration};
use smol::Timer; use smol::Timer;
use wl_clipboard_rs::{ use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents};
copy::{MimeType as CopyMimeType, Options, Source},
paste::{
ClipboardType,
MimeType as PasteMimeType,
Seat,
get_contents,
get_mime_types_ordered,
},
};
use crate::{ use crate::db::{ClipboardDb, Entry, SqliteClipboardDb};
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 {
async fn watch( fn watch(&self, max_dedupe_search: u64, max_items: u64);
&self,
max_dedupe_search: u64,
max_items: u64,
excluded_apps: &[String],
expire_after: Option<Duration>,
mime_type_preference: &str,
min_size: Option<usize>,
max_size: usize,
persist: bool,
);
} }
impl WatchCommand for SqliteClipboardDb { impl WatchCommand for SqliteClipboardDb {
async fn watch( fn watch(&self, max_dedupe_search: u64, max_items: u64) {
&self, smol::block_on(async {
max_dedupe_search: u64, log::info!("Starting clipboard watch daemon");
max_items: u64,
excluded_apps: &[String],
expire_after: Option<Duration>,
mime_type_preference: &str,
min_size: Option<usize>,
max_size: usize,
persist: bool,
) {
let async_db = AsyncClipboardDb::new(self.db_path.clone());
log::info!(
"Starting clipboard watch daemon with MIME type preference: \
{mime_type_preference}"
);
if persist { // Preallocate buffer for clipboard contents
log::info!("clipboard persistence enabled"); let mut last_contents: Option<Vec<u8>> = None;
} let mut buf = Vec::with_capacity(4096); // reasonable default, hopefully
// Build expiration queue from existing entries // Initialize with current clipboard to avoid duplicating on startup
let mut exp_queue = ExpirationQueue::new(); if let Ok((mut reader, _)) = get_contents(
ClipboardType::Regular,
// Load all expirations from database asynchronously Seat::Unspecified,
match async_db.load_all_expirations().await { wl_clipboard_rs::paste::MimeType::Any,
Ok(expirations) => { ) {
for (expires_at, id) in expirations { buf.clear();
exp_queue.push(expires_at, id); if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
last_contents = Some(buf.clone());
} }
if !exp_queue.is_empty() {
log::info!("loaded {} expirations from database", exp_queue.len());
}
},
Err(e) => {
log::warn!("failed to load expirations: {e}");
},
}
// We use hashes for comparison instead of storing full contents
let mut last_hash: Option<u64> = None;
let mut buf = Vec::with_capacity(4096);
// Helper to hash clipboard contents using FNV-1a (deterministic across
// runs)
let hash_contents = |data: &[u8]| -> u64 {
let mut hasher = Fnv1aHasher::new();
hasher.write(data);
hasher.finish()
};
// Initialize with current clipboard using smart MIME negotiation
if let Ok((mut reader, ..)) = negotiate_mime_type(mime_type_preference) {
buf.clear();
if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
last_hash = Some(hash_contents(&buf));
} }
}
let poll_interval = Duration::from_millis(500); loop {
match get_contents(
ClipboardType::Regular,
Seat::Unspecified,
wl_clipboard_rs::paste::MimeType::Any,
) {
Ok((mut reader, mime_type)) => {
buf.clear();
if let Err(e) = reader.read_to_end(&mut buf) {
log::error!("Failed to read clipboard contents: {e}");
Timer::after(Duration::from_millis(500)).await;
continue;
}
loop { // Only store if changed and not empty
// Process any pending expirations that are due now if !buf.is_empty() && (last_contents.as_ref() != Some(&buf)) {
if let Some(next_exp) = exp_queue.peek_next() { last_contents = Some(std::mem::take(&mut buf));
let now = SqliteClipboardDb::now(); let mime = Some(mime_type.to_string());
if next_exp <= now { let entry = Entry {
// Expired entries to process contents: last_contents.as_ref().unwrap().clone(),
let expired_ids = exp_queue.pop_expired(now); mime,
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
},
}; };
let id = self.next_sequence();
if let Some(stored_hash) = expired_hash { match self.store_entry(
// Mark as expired &entry.contents[..],
if let Err(e) = async_db.mark_expired(id).await { max_dedupe_search,
log::warn!("failed to mark entry {id} as expired: {e}"); max_items,
} else { ) {
log::info!("entry {id} marked as expired"); Ok(_) => log::info!("Stored new clipboard entry (id: {id})"),
Err(e) => log::error!("Failed to store clipboard entry: {e}"),
} }
// Check if this expired entry is currently in the clipboard // Drop clipboard contents after storing
if let Ok((mut reader, ..)) = last_contents = None;
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(&current_buf);
// Convert stored i64 to u64 for comparison (preserves bit
// pattern)
if current_hash == stored_hash as u64 {
// Clear the clipboard since expired content is still
// there
let mut opts = Options::new();
opts
.clipboard(wl_clipboard_rs::copy::ClipboardType::Regular);
if opts
.copy(
Source::Bytes(Vec::new().into()),
CopyMimeType::Autodetect,
)
.is_ok()
{
log::info!(
"cleared clipboard containing expired entry {id}"
);
last_hash = None; // reset tracked hash
} else {
log::warn!(
"failed to clear clipboard for expired entry {id}"
);
}
}
}
}
} }
} },
Err(e) => {
let error_msg = e.to_string();
if !error_msg.contains("empty") {
log::error!("Failed to get clipboard contents: {e}");
}
},
} }
Timer::after(Duration::from_millis(500)).await;
} }
});
// Normal clipboard polling (always run, even when expirations are
// pending)
match negotiate_mime_type(mime_type_preference) {
Ok((mut reader, _mime_type, _all_mimes)) => {
buf.clear();
if let Err(e) = reader.read_to_end(&mut buf) {
log::error!("failed to read clipboard contents: {e}");
Timer::after(Duration::from_millis(500)).await;
continue;
}
// Only store if changed and not empty
if !buf.is_empty() {
let current_hash = hash_contents(&buf);
if last_hash != Some(current_hash) {
// Clone buf for the async operation since it needs 'static
let buf_clone = buf.clone();
#[allow(clippy::cast_possible_wrap)]
let content_hash = Some(current_hash as i64);
// Clone data for persistence after successful store
let buf_for_persist = buf.clone();
let mime_types_for_persist = _all_mimes.clone();
let selected_mime = _mime_type.clone();
match async_db
.store_entry(
buf_clone,
max_dedupe_search,
max_items,
Some(excluded_apps.to_vec()),
min_size,
max_size,
content_hash,
Some(mime_types_for_persist.clone()),
)
.await
{
Ok(id) => {
log::info!("stored new clipboard entry (id: {id})");
last_hash = Some(current_hash);
// Persist clipboard: fork child to serve data
// This keeps the clipboard alive when source app closes
// Check if we're already serving to avoid duplicate processes
if persist && get_serving_pid().is_none() {
let clipboard_data = ClipboardData::new(
buf_for_persist,
mime_types_for_persist,
selected_mime,
);
// Validate and persist in blocking task
if clipboard_data.is_valid().is_ok() {
smol::spawn(async move {
// Use blocking task for fork operation
let result = smol::unblock(move || unsafe {
clipboard::persist_clipboard(clipboard_data)
})
.await;
if let Err(e) = result {
log::debug!("clipboard persistence failed: {e}");
}
})
.detach();
}
} else if persist {
log::trace!(
"Already serving clipboard, skipping persistence fork"
);
}
// Set expiration if configured
if let Some(duration) = expire_after {
let expires_at =
SqliteClipboardDb::now() + duration.as_secs_f64();
if let Err(e) =
async_db.set_expiration(id, expires_at).await
{
log::warn!(
"Failed to set expiration for entry {id}: {e}"
);
} else {
exp_queue.push(expires_at, id);
}
}
},
Err(crate::db::StashError::ExcludedByApp(_)) => {
log::info!("clipboard entry excluded by app filter");
last_hash = Some(current_hash);
},
Err(crate::db::StashError::Store(ref msg))
if msg.contains("Excluded by app filter") =>
{
log::info!("clipboard entry excluded by app filter");
last_hash = Some(current_hash);
},
Err(e) => {
log::error!("failed to store clipboard entry: {e}");
last_hash = Some(current_hash);
},
}
}
}
},
Err(e) => {
let error_msg = e.to_string();
if !error_msg.contains("empty") {
log::error!("failed to get clipboard contents: {e}");
}
},
}
// Calculate sleep time: min of poll interval and time until next
// expiration
let sleep_duration = if let Some(next_exp) = exp_queue.peek_next() {
let now = SqliteClipboardDb::now();
let time_to_exp = (next_exp - now).max(0.0);
poll_interval.min(Duration::from_secs_f64(time_to_exp))
} else {
poll_interval
};
Timer::after(sleep_duration).await;
}
}
}
/// Given ordered offers and a preference, return the
/// chosen MIME type. This mirrors the selection logic in
/// [`negotiate_mime_type`] without requiring a Wayland connection.
#[cfg(test)]
fn pick_mime<'a>(
offered: &'a [String],
preference: &str,
) -> Option<&'a String> {
if preference == "image" {
offered
.iter()
.find(|m| m.starts_with("image/"))
.or_else(|| offered.first())
} else {
let has_image = offered.iter().any(|m| m.starts_with("image/"));
if has_image {
offered
.iter()
.find(|m| m.starts_with("image/"))
.or_else(|| offered.first())
} else if offered.first().is_some_and(|m| m == "text/html") {
offered
.iter()
.find(|m| *m != "text/html")
.or_else(|| offered.first())
} else {
offered.first()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pick_first_offered() {
let offered = vec!["text/uri-list".to_string(), "text/plain".to_string()];
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/uri-list");
}
#[test]
fn test_pick_image_preference_finds_image() {
let offered = vec![
"text/html".to_string(),
"image/png".to_string(),
"text/plain".to_string(),
];
assert_eq!(pick_mime(&offered, "image").unwrap(), "image/png");
}
#[test]
fn test_pick_image_preference_falls_back() {
let offered = vec!["text/html".to_string(), "text/plain".to_string()];
// No image types offered — falls back to first
assert_eq!(pick_mime(&offered, "image").unwrap(), "text/html");
}
#[test]
fn test_pick_empty_offered() {
let offered: Vec<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");
} }
} }

13
src/commands/wipe.rs Normal file
View file

@ -0,0 +1,13 @@
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
pub trait WipeCommand {
fn wipe(&self) -> Result<(), StashError>;
}
impl WipeCommand for SqliteClipboardDb {
fn wipe(&self) -> Result<(), StashError> {
self.wipe_db()?;
log::info!("Database wiped");
Ok(())
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,375 +0,0 @@
use std::path::PathBuf;
use rusqlite::OptionalExtension;
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
/// Async wrapper for database operations that runs blocking operations
/// on a thread pool to avoid blocking the async runtime. Since
/// [`rusqlite::Connection`] is not Send, we store the database path and open a
/// new connection for each operation.
pub struct AsyncClipboardDb {
db_path: PathBuf,
}
impl AsyncClipboardDb {
pub fn new(db_path: PathBuf) -> Self {
Self { db_path }
}
#[expect(clippy::too_many_arguments)]
pub async fn store_entry(
&self,
data: Vec<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");
});
}
}

View file

@ -1,101 +0,0 @@
/// FNV-1a hasher for deterministic hashing across process runs.
///
/// Unlike `std::collections::hash_map::DefaultHasher` (which uses SipHash
/// with a random seed), this produces stable hashes suitable for persistent
/// storage and cross-process comparison.
///
/// # Example
///
/// ```
/// use std::hash::Hasher;
///
/// use stash::hash::Fnv1aHasher;
///
/// let mut hasher = Fnv1aHasher::new();
/// hasher.write(b"hello");
/// let hash = hasher.finish();
/// ```
#[derive(Clone, Copy, Debug)]
pub struct Fnv1aHasher {
state: u64,
}
impl Fnv1aHasher {
const FNV_OFFSET: u64 = 0xCBF29CE484222325;
const FNV_PRIME: u64 = 0x100000001B3;
/// Creates a new hasher initialized with the FNV-1a offset basis.
#[must_use]
pub fn new() -> Self {
Self {
state: Self::FNV_OFFSET,
}
}
}
impl Default for Fnv1aHasher {
fn default() -> Self {
Self::new()
}
}
impl std::hash::Hasher for Fnv1aHasher {
fn write(&mut self, bytes: &[u8]) {
for byte in bytes {
self.state ^= u64::from(*byte);
self.state = self.state.wrapping_mul(Self::FNV_PRIME);
}
}
fn finish(&self) -> u64 {
self.state
}
}
#[cfg(test)]
mod tests {
use std::hash::Hasher;
use super::*;
#[test]
fn test_fnv1a_basic() {
let mut hasher = Fnv1aHasher::new();
hasher.write(b"hello");
// FNV-1a hash for "hello" (little-endian u64)
assert_eq!(hasher.finish(), 0xA430D84680AABD0B);
}
#[test]
fn test_fnv1a_empty() {
let hasher = Fnv1aHasher::new();
// Empty input should return offset basis
assert_eq!(hasher.finish(), Fnv1aHasher::FNV_OFFSET);
}
#[test]
fn test_fnv1a_deterministic() {
// Same input must produce same hash
let mut h1 = Fnv1aHasher::new();
let mut h2 = Fnv1aHasher::new();
h1.write(b"test data");
h2.write(b"test data");
assert_eq!(h1.finish(), h2.finish());
}
#[test]
fn test_default_trait() {
let h1 = Fnv1aHasher::new();
let h2 = Fnv1aHasher::default();
assert_eq!(h1.finish(), h2.finish());
}
#[test]
fn test_copy_trait() {
let mut hasher = Fnv1aHasher::new();
hasher.write(b"data");
let copied = hasher;
// Both should have same state after copy
assert_eq!(hasher.finish(), copied.finish());
}
}

View file

@ -1,39 +1,25 @@
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, process,
}; };
use clap::{CommandFactory, Parser, Subcommand}; use clap::{CommandFactory, Parser, Subcommand};
use color_eyre::eyre;
use humantime::parse_duration;
use inquire::Confirm; use inquire::Confirm;
// While the module is named "wayland", the Wayland module is *strictly* for the mod commands;
// use-toplevel feature as it requires some low-level wayland crates that are mod db;
// not required *by default*. The module is named that way because "toplevel"
// sounded too silly. Stash is strictly a Wayland clipboard manager.
#[cfg(feature = "use-toplevel")] mod wayland;
use crate::{ use crate::commands::{
commands::{ decode::DecodeCommand,
decode::DecodeCommand, delete::DeleteCommand,
delete::DeleteCommand, import::ImportCommand,
import::ImportCommand, list::ListCommand,
list::ListCommand, query::QueryCommand,
query::QueryCommand, store::StoreCommand,
store::StoreCommand, watch::WatchCommand,
watch::WatchCommand, wipe::WipeCommand,
},
db::{ClipboardDb, DEFAULT_MAX_ENTRY_SIZE},
}; };
#[derive(Parser)] #[derive(Parser)]
@ -49,33 +35,18 @@ struct Cli {
/// Number of recent entries to check for duplicates when storing new /// Number of recent entries to check for duplicates when storing new
/// clipboard data. /// clipboard data.
#[arg(long, default_value_t = 20)] #[arg(long, default_value_t = 100)]
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)]
preview_width: u32, preview_width: u32,
/// Path to the `SQLite` clipboard database file. /// Path to the `SQLite` clipboard database file.
#[arg(long, env = "STASH_DB_PATH")] #[arg(long)]
db_path: Option<PathBuf>, db_path: Option<PathBuf>,
/// Application names to exclude from clipboard history
#[cfg(feature = "use-toplevel")]
#[arg(long, value_delimiter = ',', env = "STASH_EXCLUDED_APPS")]
excluded_apps: Vec<String>,
/// Ask for confirmation before destructive operations /// Ask for confirmation before destructive operations
#[arg(long)] #[arg(long)]
ask: bool, ask: bool,
@ -94,14 +65,6 @@ 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
@ -123,10 +86,11 @@ enum Command {
ask: bool, ask: bool,
}, },
/// Database management operations /// Wipe all clipboard history
Db { Wipe {
#[command(subcommand)] /// Ask for confirmation before wiping
action: DbAction, #[arg(long)]
ask: bool,
}, },
/// Import clipboard data from stdin (default: TSV format) /// Import clipboard data from stdin (default: TSV format)
@ -140,40 +104,8 @@ enum Command {
ask: bool, ask: bool,
}, },
/// Start a process to watch clipboard for changes and store automatically. /// 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>(
@ -189,126 +121,80 @@ 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() -> eyre::Result<()> { fn main() {
color_eyre::install()?;
// Check if we're being called as a multicall binary
//
// NOTE: We cannot use clap's multicall here because it requires the main
// command to have no arguments (only subcommands), but our Cli has global
// arguments like --max-items, --db-path, etc. Instead, we manually detect
// the invocation name and route appropriately. While this is ugly, it's
// seemingly the only option.
let program_name = env::args().next().map(|s| {
PathBuf::from(s)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("stash")
.to_string()
});
if let Some(ref name) = program_name {
if name == "wl-copy" || name == "stash-copy" {
crate::multicall::wl_copy::wl_copy_main()?;
return Ok(());
} else if name == "wl-paste" || name == "stash-paste" {
crate::multicall::wl_paste::wl_paste_main()?;
return Ok(());
}
}
// Normal CLI handling
smol::block_on(async { smol::block_on(async {
let cli = Cli::parse(); let cli = Cli::parse();
env_logger::Builder::new() env_logger::Builder::new()
.filter_level(cli.verbosity.into()) .filter_level(cli.verbosity.into())
.init(); .init();
let db_path = match cli.db_path { let db_path = cli.db_path.unwrap_or_else(|| {
Some(path) => path, dirs::cache_dir()
None => { .unwrap_or_else(|| PathBuf::from("/tmp"))
let cache_dir = dirs::cache_dir().ok_or_else(|| { .join("stash")
eyre::eyre!( .join("db")
"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)?; if let Err(e) = std::fs::create_dir_all(parent) {
log::error!("Failed to create database directory: {e}");
process::exit(1);
}
} }
let conn = rusqlite::Connection::open(&db_path)?; let conn = rusqlite::Connection::open(&db_path).unwrap_or_else(|e| {
let db = db::SqliteClipboardDb::new(conn, db_path)?; log::error!("Failed to open SQLite database: {e}");
process::exit(1);
});
let db = match db::SqliteClipboardDb::new(conn) {
Ok(db) => db,
Err(e) => {
log::error!("Failed to initialize SQLite database: {e}");
process::exit(1);
},
};
match cli.command { match cli.command {
Some(Command::Store) => { Some(Command::Store) => {
let state = env::var("STASH_CLIPBOARD_STATE").ok(); let state = env::var("STASH_CLIPBOARD_STATE").ok();
report_error( report_error(
db.store( db.store(io::stdin(), cli.max_dedupe_search, cli.max_items, state),
io::stdin(), "Failed to store entry",
cli.max_dedupe_search,
cli.max_items,
state,
#[cfg(feature = "use-toplevel")]
&cli.excluded_apps,
#[cfg(not(feature = "use-toplevel"))]
&[],
cli.min_size,
cli.max_size,
),
"failed to store entry",
); );
}, },
Some(Command::List { Some(Command::List { format }) => {
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, expired, reverse), db.list(io::stdout(), cli.preview_width),
"failed to list entries", "Failed to list entries",
); );
}, },
Some("json") => { Some("json") => {
match db.list_json(expired, reverse) { match db.list_json() {
Ok(json) => { Ok(json) => {
println!("{json}"); println!("{json}");
}, },
Err(e) => { Err(e) => {
log::error!("failed to list entries as JSON: {e}"); log::error!("Failed to list entries as JSON: {e}");
}, },
} }
}, },
Some(other) => { Some(other) => {
log::error!("unsupported format: {other}"); log::error!("Unsupported format: {other}");
}, },
None => { None => {
if std::io::stdout().is_terminal() { if std::io::stdout().is_terminal() {
report_error( report_error(
db.list_tui(cli.preview_width, expired, reverse), db.list_tui(cli.preview_width),
"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, expired, reverse), db.list(io::stdout(), cli.preview_width),
"failed to list entries", "Failed to list entries",
); );
} }
}, },
@ -317,17 +203,20 @@ fn main() -> eyre::Result<()> {
Some(Command::Decode { input }) => { Some(Command::Decode { input }) => {
report_error( report_error(
db.decode(io::stdin(), io::stdout(), input), db.decode(io::stdin(), io::stdout(), input),
"failed to decode entry", "Failed to decode entry",
); );
}, },
Some(Command::Delete { arg, r#type, ask }) => { Some(Command::Delete { arg, r#type, ask }) => {
let mut should_proceed = true; let mut should_proceed = true;
if ask { if ask {
should_proceed = should_proceed =
confirm("Are you sure you want to delete clipboard entries?"); Confirm::new("Are you sure you want to delete clipboard entries?")
.with_default(false)
.prompt()
.unwrap_or(false);
if !should_proceed { if !should_proceed {
log::info!("aborted by user."); log::info!("Aborted by user.");
} }
} }
if should_proceed { if should_proceed {
@ -340,13 +229,13 @@ fn main() -> eyre::Result<()> {
"Failed to delete entry by id", "Failed to delete entry by id",
); );
} else { } else {
log::error!("argument is not a valid id"); log::error!("Argument is not a valid id");
} }
}, },
(Some(s), Some("query")) => { (Some(s), Some("query")) => {
report_error( report_error(
db.query_delete(&s), db.query_delete(&s),
"failed to delete entry by query", "Failed to delete entry by query",
); );
}, },
(Some(s), None) => { (Some(s), None) => {
@ -354,90 +243,57 @@ fn main() -> eyre::Result<()> {
use std::io::Cursor; use std::io::Cursor;
report_error( report_error(
db.delete(Cursor::new(format!("{id}\n"))), db.delete(Cursor::new(format!("{id}\n"))),
"failed to delete entry by id", "Failed to delete entry by id",
); );
} else { } else {
report_error( report_error(
db.query_delete(&s), db.query_delete(&s),
"failed to delete entry by query", "Failed to delete entry by query",
); );
} }
}, },
(None, _) => { (None, _) => {
report_error( report_error(
db.delete(io::stdin()), db.delete(io::stdin()),
"failed to delete entry from stdin", "Failed to delete entry from stdin",
); );
}, },
(_, Some(_)) => { (_, Some(_)) => {
log::error!("unknown type for --type. Use \"id\" or \"query\"."); log::error!("Unknown type for --type. Use \"id\" or \"query\".");
}, },
} }
} }
}, },
Some(Command::Wipe { ask }) => {
Some(Command::Db { action }) => { let mut should_proceed = true;
match action { if ask {
DbAction::Wipe { expired, ask } => { should_proceed = Confirm::new(
let mut should_proceed = true; "Are you sure you want to wipe all clipboard history?",
if ask { )
let message = if expired { .with_default(false)
"Are you sure you want to wipe all expired clipboard entries?" .prompt()
} else { .unwrap_or(false);
"Are you sure you want to wipe ALL clipboard history?" if !should_proceed {
}; log::info!("Aborted by user.");
should_proceed = confirm(message); }
if !should_proceed { }
log::info!("db wipe command aborted by user."); if should_proceed {
} report_error(db.wipe(), "Failed to wipe database");
}
if should_proceed {
if expired {
match db.cleanup_expired() {
Ok(count) => {
log::info!("wiped {count} expired entries");
},
Err(e) => {
log::error!("failed to wipe expired entries: {e}");
},
}
} else {
report_error(db.wipe_db(), "failed to wipe database");
}
}
},
DbAction::Vacuum => {
match db.vacuum() {
Ok(()) => {
log::info!("database optimized successfully");
},
Err(e) => {
log::error!("failed to vacuum database: {e}");
},
}
},
DbAction::Stats => {
match db.stats() {
Ok(stats) => {
println!("{stats}");
},
Err(e) => {
log::error!("failed to get database stats: {e}");
},
}
},
} }
}, },
Some(Command::Import { r#type, ask }) => { Some(Command::Import { r#type, ask }) => {
let mut should_proceed = true; let mut should_proceed = true;
if ask { if ask {
should_proceed = confirm( should_proceed = Confirm::new(
"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!("Aborted by user.");
} }
} }
if should_proceed { if should_proceed {
@ -447,41 +303,24 @@ fn main() -> eyre::Result<()> {
if let Err(e) = if let Err(e) =
ImportCommand::import_tsv(&db, io::stdin(), cli.max_items) ImportCommand::import_tsv(&db, io::stdin(), cli.max_items)
{ {
log::error!("failed to import TSV: {e}"); log::error!("Failed to import TSV: {e}");
} }
}, },
_ => { _ => {
log::error!("unsupported import format: {format}"); log::error!("Unsupported import format: {format}");
}, },
} }
} }
}, },
Some(Command::Watch { Some(Command::Watch) => {
expire_after, db.watch(cli.max_dedupe_search, cli.max_items);
mime_type,
persist,
}) => {
db.watch(
cli.max_dedupe_search,
cli.max_items,
#[cfg(feature = "use-toplevel")]
&cli.excluded_apps,
#[cfg(not(feature = "use-toplevel"))]
&[],
expire_after,
&mime_type,
cli.min_size,
cli.max_size,
persist,
)
.await;
}, },
None => { None => {
Cli::command().print_help()?; if let Err(e) = Cli::command().print_help() {
log::error!("Failed to print help: {e}");
}
println!(); println!();
}, },
} }
Ok(()) });
})
} }

View file

@ -1,273 +0,0 @@
use imagesize::ImageType;
/// Detect MIME type of clipboard data. We try binary detection first using
/// [`imagesize`] followed by a check for text/uri-list for file manager copies
/// and finally fall back to text/plain for UTF-8 or [`None`] for binary.
pub fn detect_mime(data: &[u8]) -> Option<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"
);
}
}

View file

@ -1,6 +0,0 @@
// Reference documentation:
// https://wayland.freedesktop.org/docs/html/apa.html#protocol-spec-wl_data_device
// https://docs.rs/wl-clipboard-rs/latest/wl_clipboard_rs
// https://github.com/YaLTeR/wl-clipboard-rs/blob/master/wl-clipboard-rs-tools/src/bin/wl_copy.rs
pub mod wl_copy;
pub mod wl_paste;

View file

@ -1,296 +0,0 @@
use std::io::{self, Read};
use clap::{ArgAction, Parser};
use color_eyre::eyre::{Context, Result, bail};
use wl_clipboard_rs::{
copy::{
ClipboardType as CopyClipboardType,
MimeType as CopyMimeType,
Options,
Seat as CopySeat,
ServeRequests,
Source,
},
utils::{PrimarySelectionCheckError, is_primary_selection_supported},
};
// Maximum clipboard content size to prevent memory exhaustion (100MB)
const MAX_CLIPBOARD_SIZE: usize = 100 * 1024 * 1024;
#[derive(Parser, Debug)]
#[command(
name = "wl-copy",
about = "Copy clipboard contents on Wayland.",
version
)]
#[allow(clippy::struct_excessive_bools)]
struct WlCopyArgs {
/// Serve only a single paste request and then exit
#[arg(short = 'o', long = "paste-once", action = ArgAction::SetTrue)]
paste_once: bool,
/// Stay in the foreground instead of forking
#[arg(short = 'f', long = "foreground", action = ArgAction::SetTrue)]
foreground: bool,
/// Clear the clipboard instead of copying
#[arg(short = 'c', long = "clear", action = ArgAction::SetTrue)]
clear: bool,
/// Use the "primary" clipboard
#[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)]
primary: bool,
/// Use the regular clipboard
#[arg(short = 'r', long = "regular", action = ArgAction::SetTrue)]
regular: bool,
/// Trim the trailing newline character before copying
#[arg(short = 'n', long = "trim-newline", action = ArgAction::SetTrue)]
trim_newline: bool,
/// Pick the seat to work with
#[arg(short = 's', long = "seat")]
seat: Option<String>,
/// Override the inferred MIME type for the content
#[arg(short = 't', long = "type")]
mime_type: Option<String>,
/// Enable verbose logging
#[arg(short = 'v', long = "verbose", action = ArgAction::Count)]
verbose: u8,
/// Check if primary selection is supported and exit
#[arg(long = "check-primary", action = ArgAction::SetTrue)]
check_primary: bool,
/// Do not offer additional text mime types (stash extension)
#[arg(long = "omit-additional-text-mime-types", action = ArgAction::SetTrue, hide = true)]
omit_additional_text_mime_types: bool,
/// Number of paste requests to serve before exiting (stash extension)
#[arg(short = 'x', long = "serve-requests", hide = true)]
serve_requests: Option<usize>,
/// Text to copy (if not given, read from stdin)
#[arg(value_name = "TEXT TO COPY", action = ArgAction::Append)]
text: Vec<String>,
}
fn handle_check_primary() {
let exit_code = match is_primary_selection_supported() {
Ok(true) => {
log::info!("primary selection is supported.");
0
},
Ok(false) => {
log::info!("primary selection is NOT supported.");
1
},
Err(PrimarySelectionCheckError::NoSeats) => {
log::error!("could not determine: no seats available.");
2
},
Err(PrimarySelectionCheckError::MissingProtocol) => {
log::error!("data-control protocol not supported by compositor.");
3
},
Err(e) => {
log::error!("error checking primary selection support: {e}");
4
},
};
// Exit with the relevant code
std::process::exit(exit_code);
}
const fn get_clipboard_type(primary: bool) -> CopyClipboardType {
if primary {
CopyClipboardType::Primary
} else {
CopyClipboardType::Regular
}
}
fn get_mime_type(mime_arg: Option<&str>) -> CopyMimeType {
match mime_arg {
Some("text" | "text/plain") => CopyMimeType::Text,
Some("autodetect") | None => CopyMimeType::Autodetect,
Some(specific) => CopyMimeType::Specific(specific.to_string()),
}
}
fn read_input_data(text_args: &[String]) -> Result<Vec<u8>> {
if text_args.is_empty() {
let mut buffer = Vec::new();
let mut stdin = io::stdin();
// Read with size limit to prevent memory exhaustion
let mut temp_buffer = [0; 8192];
loop {
let bytes_read = stdin
.read(&mut temp_buffer)
.context("failed to read from stdin")?;
if bytes_read == 0 {
break;
}
if buffer.len() + bytes_read > MAX_CLIPBOARD_SIZE {
bail!(
"input exceeds maximum clipboard size of {} bytes",
MAX_CLIPBOARD_SIZE
);
}
buffer.extend_from_slice(&temp_buffer[..bytes_read]);
}
Ok(buffer)
} else {
let content = text_args.join(" ");
if content.len() > MAX_CLIPBOARD_SIZE {
bail!(
"input exceeds maximum clipboard size of {} bytes",
MAX_CLIPBOARD_SIZE
);
}
Ok(content.into_bytes())
}
}
fn configure_copy_options(
args: &WlCopyArgs,
clipboard: CopyClipboardType,
) -> Options {
let mut opts = Options::new();
opts.clipboard(clipboard);
opts.seat(
args
.seat
.as_deref()
.map_or(CopySeat::All, |s| CopySeat::Specific(s.to_string())),
);
if args.trim_newline {
opts.trim_newline(true);
}
if args.omit_additional_text_mime_types {
opts.omit_additional_text_mime_types(true);
}
if args.paste_once {
opts.serve_requests(ServeRequests::Only(1));
} else if let Some(n) = args.serve_requests {
opts.serve_requests(ServeRequests::Only(n));
}
opts
}
fn handle_clear_clipboard(
args: &WlCopyArgs,
clipboard: CopyClipboardType,
mime_type: CopyMimeType,
) -> Result<()> {
let mut opts = Options::new();
opts.clipboard(clipboard);
opts.seat(
args
.seat
.as_deref()
.map_or(CopySeat::All, |s| CopySeat::Specific(s.to_string())),
);
opts
.copy(Source::Bytes(Vec::new().into()), mime_type)
.context("failed to clear clipboard")?;
Ok(())
}
fn fork_and_serve(prepared_copy: wl_clipboard_rs::copy::PreparedCopy) {
// Use proper Unix fork() to create a child process that continues
// serving clipboard content after parent exits.
// XXX: I wanted to choose and approach without fork, but we could not
// ensure persistence after the thread dies. Alas, we gotta fork.
unsafe {
match libc::fork() {
0 => {
// Child process - serve clipboard content
if let Err(e) = prepared_copy.serve() {
log::error!("background clipboard service failed: {e}");
std::process::exit(1);
}
std::process::exit(0);
},
-1 => {
// Fork failed
log::error!("failed to fork background process");
std::process::exit(1);
},
_ => {
// Parent process - exit immediately
log::debug!("forked background process to serve clipboard content");
std::process::exit(0);
},
}
}
}
pub fn wl_copy_main() -> Result<()> {
let args = WlCopyArgs::parse();
if args.check_primary {
handle_check_primary();
}
let clipboard = get_clipboard_type(args.primary);
let mime_type = get_mime_type(args.mime_type.as_deref());
// Handle clear operation
if args.clear {
handle_clear_clipboard(&args, clipboard, mime_type)?;
return Ok(());
}
// Read input data
let input =
read_input_data(&args.text).context("failed to read input data")?;
// Configure copy options
let opts = configure_copy_options(&args, clipboard);
// Handle foreground vs background mode
if args.foreground {
// Foreground mode: copy content and serve in current process
// Use prepare_copy + serve to ensure proper clipboard registration
let mut opts_fg = opts;
opts_fg.foreground(true);
let prepared_copy = opts_fg
.prepare_copy(Source::Bytes(input.into()), mime_type)
.context("failed to prepare copy")?;
// Serve in foreground - blocks until interrupted (Ctrl+C, etc.)
prepared_copy
.serve()
.context("failed to serve clipboard content")?;
} else {
// Background mode: spawn child process to serve requests
// First prepare to copy to validate before spawning
let mut opts_fg = opts;
opts_fg.foreground(true);
let prepared_copy = opts_fg
.prepare_copy(Source::Bytes(input.into()), mime_type)
.context("failed to prepare copy")?;
fork_and_serve(prepared_copy);
}
Ok(())
}

View file

@ -1,531 +0,0 @@
// https://wayland.freedesktop.org/docs/html/apa.html#protocol-spec-wl_data_device
// https://docs.rs/wl-clipboard-rs/latest/wl_clipboard_rs
// https://github.com/YaLTeR/wl-clipboard-rs/blob/master/wl-clipboard-rs-tools/src/bin/wl_paste.rs
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
io::{self, Read, Write},
process::{Command, Stdio},
sync::{Arc, Mutex},
thread,
time::{Duration, Instant},
};
use clap::{ArgAction, Parser};
use color_eyre::eyre::{Context, Result, bail};
use wl_clipboard_rs::paste::{
ClipboardType as PasteClipboardType,
Error as PasteError,
MimeType as PasteMimeType,
Seat as PasteSeat,
get_contents,
get_mime_types,
};
// Watch mode timing constants
const WATCH_POLL_INTERVAL_MS: u64 = 500;
const WATCH_DEBOUNCE_INTERVAL_MS: u64 = 1000;
// Maximum clipboard content size to prevent memory exhaustion (100MB)
const MAX_CLIPBOARD_SIZE: usize = 100 * 1024 * 1024;
#[derive(Parser, Debug)]
#[command(
name = "wl-paste",
about = "Paste clipboard contents on Wayland.",
version,
disable_help_subcommand = true
)]
struct WlPasteArgs {
/// List the offered MIME types instead of pasting
#[arg(short = 'l', long = "list-types", action = ArgAction::SetTrue)]
list_types: bool,
/// Use the "primary" clipboard
#[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)]
primary: bool,
/// Do not append a newline character
#[arg(short = 'n', long = "no-newline", action = ArgAction::SetTrue)]
no_newline: bool,
/// Pick the seat to work with
#[arg(short = 's', long = "seat")]
seat: Option<String>,
/// Request the given MIME type instead of inferring the MIME type
#[arg(short = 't', long = "type")]
mime_type: Option<String>,
/// Enable verbose logging
#[arg(short = 'v', long = "verbose", action = ArgAction::Count)]
verbose: u8,
/// Watch for clipboard changes and run a command
#[arg(short = 'w', long = "watch")]
watch: Option<Vec<String>>,
}
fn get_paste_mime_type(mime_arg: Option<&str>) -> PasteMimeType<'_> {
match mime_arg {
None | Some("text" | "autodetect") => PasteMimeType::Text,
Some(other) => PasteMimeType::Specific(other),
}
}
fn handle_list_types(
clipboard: PasteClipboardType,
seat: PasteSeat,
) -> Result<()> {
match get_mime_types(clipboard, seat) {
Ok(types) => {
for mime_type in types {
println!("{mime_type}");
}
#[allow(clippy::needless_return)]
return Ok(());
},
Err(PasteError::NoSeats) => {
bail!("no seats available (is a Wayland compositor running?)");
},
Err(e) => {
bail!("failed to list types: {e}");
},
}
}
fn handle_watch_mode(
args: &WlPasteArgs,
clipboard: PasteClipboardType,
seat: PasteSeat,
) -> Result<()> {
let watch_args = args.watch.as_ref().unwrap();
if watch_args.is_empty() {
bail!("--watch requires a command to run");
}
log::info!("starting clipboard watch mode");
// Shared state for tracking last content and shutdown signal
let last_content_hash = Arc::new(Mutex::new(None::<u64>));
let shutdown = Arc::new(Mutex::new(false));
// Set up signal handler for graceful shutdown
let shutdown_clone = shutdown.clone();
ctrlc::set_handler(move || {
log::info!("received shutdown signal, stopping watch mode");
if let Ok(mut shutdown_guard) = shutdown_clone.lock() {
*shutdown_guard = true;
} else {
log::error!("failed to acquire shutdown lock in signal handler");
}
})
.context("failed to set signal handler")?;
let poll_interval = Duration::from_millis(WATCH_POLL_INTERVAL_MS);
let debounce_interval = Duration::from_millis(WATCH_DEBOUNCE_INTERVAL_MS);
let mut last_change_time = Instant::now();
loop {
// Check for shutdown signal
match shutdown.lock() {
Ok(shutdown_guard) => {
if *shutdown_guard {
log::info!("shutting down watch mode");
break Ok(());
}
},
Err(e) => {
log::error!("failed to acquire shutdown lock: {e}");
thread::sleep(poll_interval);
continue;
},
}
// Get current clipboard content
let current_hash = match get_clipboard_content_hash(clipboard, seat) {
Ok(hash) => hash,
Err(e) => {
log::error!("failed to get clipboard content hash: {e}");
thread::sleep(poll_interval);
continue;
},
};
// Check if content has changed
match last_content_hash.lock() {
Ok(mut last_hash_guard) => {
let changed = *last_hash_guard != Some(current_hash);
if changed {
let now = Instant::now();
// Debounce rapid changes
if now.duration_since(last_change_time) >= debounce_interval {
*last_hash_guard = Some(current_hash);
last_change_time = now;
drop(last_hash_guard); // Release lock before spawning command
log::info!("clipboard content changed, executing watch command");
// Execute the watch command
if let Err(e) = execute_watch_command(watch_args, clipboard, seat) {
log::error!("failed to execute watch command: {e}");
// Continue watching even if command fails
}
}
}
changed
},
Err(e) => {
log::error!("failed to acquire last_content_hash lock: {e}");
thread::sleep(poll_interval);
continue;
},
};
thread::sleep(poll_interval);
}
}
fn get_clipboard_content_hash(
clipboard: PasteClipboardType,
seat: PasteSeat,
) -> Result<u64> {
match get_contents(clipboard, seat, PasteMimeType::Text) {
Ok((mut reader, _types)) => {
let mut content = Vec::new();
let mut temp_buffer = [0; 8192];
loop {
let bytes_read = reader
.read(&mut temp_buffer)
.context("failed to read clipboard content")?;
if bytes_read == 0 {
break;
}
if content.len() + bytes_read > MAX_CLIPBOARD_SIZE {
bail!(
"clipboard content exceeds maximum size of {} bytes",
MAX_CLIPBOARD_SIZE
);
}
content.extend_from_slice(&temp_buffer[..bytes_read]);
}
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
Ok(hasher.finish())
},
Err(PasteError::ClipboardEmpty) => {
Ok(0) // Empty clipboard has hash 0
},
Err(e) => bail!("clipboard error: {e}"),
}
}
/// Validate command name to prevent command injection
fn validate_command_name(cmd: &str) -> Result<()> {
if cmd.is_empty() {
bail!("command name cannot be empty");
}
// Reject commands with shell metacharacters or path traversal
if cmd.contains(|c| {
['|', '&', ';', '$', '`', '(', ')', '<', '>', '"', '\'', '\\'].contains(&c)
}) {
bail!("command contains invalid characters: {cmd}");
}
// Reject absolute paths and relative path traversal
if cmd.starts_with('/') || cmd.contains("..") {
bail!("command paths are not allowed: {cmd}");
}
Ok(())
}
/// Set environment variable safely with validation
fn set_clipboard_state_env(has_content: bool) -> Result<()> {
let value = if has_content { "data" } else { "nil" };
// Validate the environment variable value
if !matches!(value, "data" | "nil") {
bail!("invalid clipboard state value: {value}");
}
// Safe to set environment variable with validated, known-safe value
unsafe {
std::env::set_var("STASH_CLIPBOARD_STATE", value);
}
Ok(())
}
fn execute_watch_command(
watch_args: &[String],
clipboard: PasteClipboardType,
seat: PasteSeat,
) -> Result<()> {
if watch_args.is_empty() {
bail!("watch command cannot be empty");
}
// Validate command name for security
validate_command_name(&watch_args[0])?;
let mut cmd = Command::new(&watch_args[0]);
if watch_args.len() > 1 {
cmd.args(&watch_args[1..]);
}
// Get clipboard content and pipe it to the command
match get_contents(clipboard, seat, PasteMimeType::Text) {
Ok((mut reader, _types)) => {
let mut content = Vec::new();
let mut temp_buffer = [0; 8192];
loop {
let bytes_read = reader
.read(&mut temp_buffer)
.context("failed to read clipboard")?;
if bytes_read == 0 {
break;
}
if content.len() + bytes_read > MAX_CLIPBOARD_SIZE {
bail!(
"clipboard content exceeds maximum size of {} bytes",
MAX_CLIPBOARD_SIZE
);
}
content.extend_from_slice(&temp_buffer[..bytes_read]);
}
// Set environment variable safely
set_clipboard_state_env(!content.is_empty())?;
// Spawn the command with the content as stdin
cmd.stdin(Stdio::piped());
let mut child = cmd.spawn()?;
if let Some(stdin) = child.stdin.take() {
let mut stdin = stdin;
if let Err(e) = stdin.write_all(&content) {
bail!("failed to write to command stdin: {e}");
}
}
match child.wait() {
Ok(status) => {
if !status.success() {
log::warn!("watch command exited with status: {status}");
}
},
Err(e) => {
bail!("failed to wait for command: {e}");
},
}
},
Err(PasteError::ClipboardEmpty) => {
// Set environment variable safely
set_clipboard_state_env(false)?;
// Run command with /dev/null as stdin
cmd.stdin(Stdio::null());
match cmd.status() {
Ok(status) => {
if !status.success() {
log::warn!("watch command exited with status: {status}");
}
},
Err(e) => {
bail!("failed to run command: {e}");
},
}
},
Err(e) => {
bail!("clipboard error: {e}");
},
}
Ok(())
}
/// Select the best MIME type from available types when none is specified.
/// Prefers specific content types (image/*, application/*) over generic
/// text representations (TEXT, STRING, `UTF8_STRING`).
fn select_best_mime_type(
types: &std::collections::HashSet<String>,
) -> Option<String> {
if types.is_empty() {
return None;
}
// If only one type available, use it
if types.len() == 1 {
return types.iter().next().cloned();
}
// Prefer specific MIME types with slashes (e.g., image/png, application/pdf)
// over generic X11 selections (TEXT, STRING, UTF8_STRING)
let specific_types: Vec<_> =
types.iter().filter(|t| t.contains('/')).collect();
if !specific_types.is_empty() {
// Among specific types, prefer non-text types first
for mime in &specific_types {
if !mime.starts_with("text/") {
return Some((*mime).clone());
}
}
// If all are text types, prefer text/plain with charset
for mime in &specific_types {
if mime.starts_with("text/plain;charset=") {
return Some((*mime).clone());
}
}
// Otherwise return first specific type
return Some(specific_types[0].clone());
}
// Fall back to generic text selections in order of preference
for fallback in &["UTF8_STRING", "STRING", "TEXT"] {
if types.contains(*fallback) {
return Some((*fallback).to_string());
}
}
// Last resort: return any available type
types.iter().next().cloned()
}
fn handle_regular_paste(
args: &WlPasteArgs,
clipboard: PasteClipboardType,
seat: PasteSeat,
) -> Result<()> {
// If no MIME type specified, select the best available MIME type
let available_types = if args.mime_type.is_none() {
get_mime_types(clipboard, seat).ok()
} else {
None
};
let selected_type = available_types.as_ref().and_then(select_best_mime_type);
let mime_type = if let Some(ref best) = selected_type {
log::debug!("auto-selecting MIME type: {best}");
PasteMimeType::Specific(best)
} else {
get_paste_mime_type(args.mime_type.as_deref())
};
match get_contents(clipboard, seat, mime_type) {
Ok((mut reader, types)) => {
let mut out = io::stdout();
let mut buf = Vec::new();
let mut temp_buffer = [0; 8192];
loop {
let bytes_read = reader
.read(&mut temp_buffer)
.context("failed to read clipboard")?;
if bytes_read == 0 {
break;
}
if buf.len() + bytes_read > MAX_CLIPBOARD_SIZE {
bail!(
"clipboard content exceeds maximum size of {} bytes",
MAX_CLIPBOARD_SIZE
);
}
buf.extend_from_slice(&temp_buffer[..bytes_read]);
}
if buf.is_empty() && args.no_newline {
bail!("no content available and --no-newline specified");
}
if let Err(e) = out.write_all(&buf) {
bail!("failed to write to stdout: {e}");
}
// Only add newline for text content, not binary data
// Check if the MIME type indicates text content
let is_text_content = if types.is_empty() {
// If no MIME type, check if content is valid UTF-8
std::str::from_utf8(&buf).is_ok()
} else {
types.starts_with("text/")
|| types == "application/json"
|| types == "application/xml"
|| types == "application/x-sh"
};
if !args.no_newline
&& is_text_content
&& !buf.ends_with(b"\n")
&& let Err(e) = out.write_all(b"\n")
{
bail!("failed to write newline to stdout: {e}");
}
},
Err(PasteError::NoSeats) => {
bail!("no seats available (is a Wayland compositor running?)");
},
Err(PasteError::ClipboardEmpty) => {
if args.no_newline {
bail!("clipboard empty and --no-newline specified");
}
// Otherwise, exit successfully with no output
},
Err(PasteError::NoMimeType) => {
bail!("clipboard does not contain requested MIME type");
},
Err(e) => {
bail!("clipboard error: {e}");
},
}
Ok(())
}
pub fn wl_paste_main() -> Result<()> {
let args = WlPasteArgs::parse();
let clipboard = if args.primary {
PasteClipboardType::Primary
} else {
PasteClipboardType::Regular
};
let seat = args
.seat
.as_deref()
.map_or(PasteSeat::Unspecified, PasteSeat::Specific);
// Handle list-types option
if args.list_types {
handle_list_types(clipboard, seat)?;
return Ok(());
}
// Handle watch mode
if args.watch.is_some() {
handle_watch_mode(&args, clipboard, seat)?;
return Ok(());
}
// Regular paste mode
handle_regular_paste(&args, clipboard, seat)?;
Ok(())
}

View file

@ -1,179 +0,0 @@
use std::{
collections::HashMap,
sync::{Arc, LazyLock, Mutex},
};
use arc_swap::ArcSwapOption;
use log::debug;
use wayland_client::{
Connection as WaylandConnection,
Dispatch,
Proxy,
QueueHandle,
backend::ObjectId,
protocol::wl_registry,
};
use wayland_protocols_wlr::foreign_toplevel::v1::client::{
zwlr_foreign_toplevel_handle_v1::{self, ZwlrForeignToplevelHandleV1},
zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1},
};
static FOCUSED_APP: ArcSwapOption<String> = ArcSwapOption::const_empty();
static TOPLEVEL_APPS: LazyLock<Mutex<HashMap<ObjectId, String>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Initialize Wayland state for window management in a background thread
pub fn init_wayland_state() {
std::thread::spawn(|| {
if let Err(e) = run_wayland_event_loop() {
debug!("Wayland event loop error: {e}");
}
});
}
/// Get the currently focused window application name using Wayland protocols
pub fn get_focused_window_app() -> Option<String> {
// Load the focused app using lock-free arc-swap
let focused = FOCUSED_APP.load();
if let Some(app) = focused.as_ref() {
debug!("Found focused app via Wayland protocol: {app}");
return Some(app.to_string());
}
debug!("No focused window detection method worked");
None
}
/// Run the Wayland event loop
fn run_wayland_event_loop() -> Result<(), Box<dyn std::error::Error>> {
let conn = match WaylandConnection::connect_to_env() {
Ok(conn) => conn,
Err(e) => {
debug!("Failed to connect to Wayland: {e}");
return Ok(());
},
};
let display = conn.display();
let mut event_queue = conn.new_event_queue();
let qh = event_queue.handle();
let _registry = display.get_registry(&qh, ());
loop {
event_queue.blocking_dispatch(&mut AppState)?;
}
}
struct AppState;
impl Dispatch<wl_registry::WlRegistry, ()> for AppState {
fn event(
_state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_data: &(),
_conn: &WaylandConnection,
qh: &QueueHandle<Self>,
) {
if let wl_registry::Event::Global {
name,
interface,
version: _,
} = event
&& interface == "zwlr_foreign_toplevel_manager_v1"
{
let _manager: ZwlrForeignToplevelManagerV1 =
registry.bind(name, 1, qh, ());
}
}
fn event_created_child(
_opcode: u16,
qhandle: &QueueHandle<Self>,
) -> std::sync::Arc<dyn wayland_client::backend::ObjectData> {
qhandle.make_data::<ZwlrForeignToplevelManagerV1, ()>(())
}
}
impl Dispatch<ZwlrForeignToplevelManagerV1, ()> for AppState {
fn event(
_state: &mut Self,
_manager: &ZwlrForeignToplevelManagerV1,
event: zwlr_foreign_toplevel_manager_v1::Event,
_data: &(),
_conn: &WaylandConnection,
_qh: &QueueHandle<Self>,
) {
if let zwlr_foreign_toplevel_manager_v1::Event::Toplevel { toplevel } =
event
{
// New toplevel created
// We'll track it for focus events
let _: ZwlrForeignToplevelHandleV1 = toplevel;
}
}
fn event_created_child(
_opcode: u16,
qhandle: &QueueHandle<Self>,
) -> std::sync::Arc<dyn wayland_client::backend::ObjectData> {
qhandle.make_data::<ZwlrForeignToplevelHandleV1, ()>(())
}
}
impl Dispatch<ZwlrForeignToplevelHandleV1, ()> for AppState {
fn event(
_state: &mut Self,
handle: &ZwlrForeignToplevelHandleV1,
event: zwlr_foreign_toplevel_handle_v1::Event,
_data: &(),
_conn: &WaylandConnection,
_qh: &QueueHandle<Self>,
) {
let handle_id = handle.id();
match event {
zwlr_foreign_toplevel_handle_v1::Event::AppId { app_id } => {
debug!("Toplevel app_id: {app_id}");
// Store the app_id for this handle
if let Ok(mut apps) = TOPLEVEL_APPS.lock() {
apps.insert(handle_id, app_id);
}
},
zwlr_foreign_toplevel_handle_v1::Event::State {
state: toplevel_state,
} => {
// Check if this toplevel is activated (focused)
let states: Vec<u8> = toplevel_state;
// Check for activated state (value 2 in the enum)
if states.chunks_exact(4).any(|chunk| {
u32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]) == 2
}) {
debug!("Toplevel activated");
// Update focused app to the `app_id` of this handle
if let Ok(apps) = TOPLEVEL_APPS.lock()
&& let Some(app_id) = apps.get(&handle_id)
{
debug!("Setting focused app to: {app_id}");
FOCUSED_APP.store(Some(Arc::new(app_id.clone())));
}
}
},
zwlr_foreign_toplevel_handle_v1::Event::Closed => {
// Clean up when toplevel is closed
if let Ok(mut apps) = TOPLEVEL_APPS.lock() {
apps.remove(&handle_id);
}
},
_ => {},
}
}
fn event_created_child(
_opcode: u16,
qhandle: &QueueHandle<Self>,
) -> std::sync::Arc<dyn wayland_client::backend::ObjectData> {
qhandle.make_data::<ZwlrForeignToplevelHandleV1, ()>(())
}
}