Compare commits

..

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

27 changed files with 1060 additions and 3454 deletions

View file

@ -1,23 +1,13 @@
version: 2
updates:
# Update Cargo deps
- package-ecosystem: cargo
directory: "/"
schedule:
interval: "weekly"
# Update used workflows
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: daily
# Update Cargo deps
- package-ecosystem: cargo
directory: "/"
cooldown:
default-days: 7
schedule:
interval: "weekly"
# Update Nixpkgs & Crane
- package-ecosystem: nix
directory: "/"
cooldown:
default-days: 7
schedule:
interval: daily

View file

@ -20,7 +20,7 @@ jobs:
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v17
- uses: cachix/cachix-action@v16
with:
name: nyx
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'

View file

@ -40,7 +40,7 @@ jobs:
steps:
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
draft: false
prerelease: false
@ -98,7 +98,7 @@ jobs:
cp target/${{ matrix.target }}/release/stash ${{ matrix.name }}
- name: Upload Release Asset
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
files: ${{ matrix.name }}
@ -120,7 +120,7 @@ jobs:
sha256sum stash-* > SHA256SUMS
- name: Upload Checksums
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
files: SHA256SUMS

View file

@ -1,26 +1,26 @@
condense_wildcard_suffixes = true
condense_wildcard_suffixes = true
doc_comment_code_block_width = 80
edition = "2024" # Keep in sync with Cargo.toml.
edition = "2024" # Keep in sync with Cargo.toml.
enum_discrim_align_threshold = 60
force_explicit_abi = false
force_multiline_blocks = true
format_code_in_doc_comments = true
format_macro_matchers = true
format_strings = true
group_imports = "StdExternalCrate"
hex_literal_case = "Upper"
imports_granularity = "Crate"
imports_layout = "HorizontalVertical"
inline_attribute_width = 60
match_block_trailing_comma = true
max_width = 80
newline_style = "Unix"
normalize_comments = true
normalize_doc_attributes = true
overflow_delimited_expr = true
force_explicit_abi = false
force_multiline_blocks = true
format_code_in_doc_comments = true
format_macro_matchers = true
format_strings = true
group_imports = "StdExternalCrate"
hex_literal_case = "Upper"
imports_granularity = "Crate"
imports_layout = "HorizontalVertical"
inline_attribute_width = 60
match_block_trailing_comma = true
max_width = 80
newline_style = "Unix"
normalize_comments = true
normalize_doc_attributes = true
overflow_delimited_expr = true
struct_field_align_threshold = 60
tab_spaces = 2
unstable_features = true
use_field_init_shorthand = true
use_try_shorthand = true
wrap_comments = true
tab_spaces = 2
unstable_features = true
use_field_init_shorthand = true
use_try_shorthand = true
wrap_comments = true

View file

@ -11,3 +11,4 @@ keys = [ "package" ]
[rule.formatting]
reorder_keys = false

1069
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,57 +1,53 @@
[package]
name = "stash-clipboard"
description = "Wayland clipboard manager with fast persistent history and multi-media support"
version = "0.3.6"
version = "0.3.5"
edition = "2024"
authors = [ "NotAShelf <raf@notashelf.dev>" ]
license = "MPL-2.0"
readme = true
repository = "https://github.com/notashelf/stash"
rust-version = "1.91.0"
rust-version = "1.90"
[[bin]]
name = "stash" # actual binary name for Nix, Cargo, etc.
path = "src/main.rs"
[dependencies]
arc-swap = { version = "1.9.1", optional = true }
base64 = "0.22.1"
blocking = "1.6.2"
clap = { version = "4.6.0", features = [ "derive", "env" ] }
clap = { version = "4.5.56", features = [ "derive", "env" ] }
clap-verbosity-flag = "3.0.4"
color-eyre = "0.6.5"
crossterm = "0.29.0"
ctrlc = "3.5.2"
ctrlc = "3.5.1"
dirs = "6.0.0"
env_logger = "0.11.10"
env_logger = "0.11.8"
humantime = "2.3.0"
imagesize = "0.14.0"
inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] }
libc = "0.2.184"
inquire = { version = "0.9.2", default-features = false, features = [ "crossterm" ] }
libc = "0.2.180"
log = "0.4.29"
mime-sniffer = "0.1.3"
notify-rust = { version = "4.14.0", optional = true }
notify-rust = { version = "4.11.7", optional = true }
ratatui = "0.30.0"
regex = "1.12.3"
rusqlite = { version = "0.39.0", features = [ "bundled" ] }
regex = "1.12.2"
rusqlite = { version = "0.38.0", features = [ "bundled" ] }
serde = { version = "1.0.228", features = [ "derive" ] }
serde_json = "1.0.149"
smol = "2.0.2"
thiserror = "2.0.18"
unicode-segmentation = "1.13.2"
unicode-segmentation = "1.12.0"
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 }
wayland-client = { version = "0.31.12", features = [ "log" ], optional = true }
wayland-protocols-wlr = { version = "0.3.10", default-features = false, optional = true }
wl-clipboard-rs = "0.9.3"
[dev-dependencies]
futures = "0.3.32"
tempfile = "3.27.0"
tempfile = "3.24.0"
[features]
default = [ "notifications", "use-toplevel" ]
notifications = [ "dep:notify-rust" ]
use-toplevel = [ "dep:arc-swap", "dep:wayland-client", "dep:wayland-protocols-wlr" ]
use-toplevel = [ "dep:wayland-client", "dep:wayland-protocols-wlr" ]
[profile.release]
lto = true

View file

@ -20,7 +20,7 @@
</div>
<div align="center">
Lightweight & feature-rich Wayland clipboard "manager" with fast persistent history and
Lightweight Wayland clipboard "manager" with fast persistent history and
robust multi-media support. Stores and previews clipboard entries (text, images)
on the clipboard with a neat TUI and advanced scripting capabilities.
</div>
@ -28,7 +28,7 @@
<div align="center">
<br/>
<a href="#features">Features</a><br/>
<a href="#installation">Installation</a> | <a href="#usage">Usage</a> | <a href="#usage">Motivation</a></br>
<a href="#installation">Installation</a> | <a href="#usage">Usage</a><br/>
<a href="#tips--tricks">Tips and Tricks</a>
<br/>
</div>
@ -46,34 +46,21 @@ with many features such as but not necessarily limited to:
- Image preview (shows dimensions and format)
- Text previews with customizable width
- De-duplication, whitespace prevention and entry limit control
- Automatic clipboard monitoring with
[`stash watch`](#watch-clipboard-for-changes-and-store-automatically)
- Automatic clipboard monitoring with `stash watch`
- Configurable auto-expiry of old entries in watch mode as a safety buffer
- Drop-in replacement for `wl-clipboard` tools (`wl-copy` and `wl-paste`)
- Sensitive clipboard filtering via regex (see below)
- Sensitive clipboard filtering by application (see below)
on top of the existing features of Cliphist, which are as follows:
- Write clipboard changes to a history file.
- Recall history with dmenu, rofi, wofi (or whatever other picker you like).
- Both text and images are supported.
- Clipboard is preserved byte-for-byte.
- Leading/trailing whitespace, no whitespace, or newlines are preserved.
- 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.
See [usage section](#usage) for more details.
## Installation
### With Nix
Nix is the recommended way of downloading (and developing!) Stash. You can
install it using Nix flakes using `nix profile add` if on non-nixos or add Stash
as a flake input if you are on NixOS.
Nix is the recommended way of downloading Stash. You can install it using Nix
flakes using `nix profile add` if on non-nixos or add Stash as a flake input if
you are on NixOS.
```nix
{
@ -104,8 +91,7 @@ If you want to give Stash a try before you switch to it, you may also run it one
time with `nix run`.
```sh
# Run directly from the git repository; will be garbage collected
$ nix run github:NotAShelf/stash -- watch # start the watch daemon
nix run github:NotAShelf/stash -- watch # start the watch daemon
```
### Without Nix
@ -124,23 +110,16 @@ releases are made when a version gets tagged, and are available under
- Build and install from source with Cargo:
```bash
cargo install stash --locked
cargo install --git https://github.com/notashelf/stash
```
Additionally, you may get Stash from source via `cargo install` using
`cargo install --git https://github.com/notashelf/stash --locked` or you may
check out to the repository, and use Cargo to build it. You'll need Rust 1.91.0
or above. Most distributions should package this version already. You may, of
course, prefer to package the built releases if you'd like.
## Usage
> [!IMPORTANT]
> [!NOTE]
> It is not a priority to provide 1:1 backwards compatibility with Cliphist.
> While the interface is generally similar, Stash chooses to build upon
> While the interface is _almost_ identical, Stash chooses to build upon
> Cliphist's design and extend existing design choices. See
> [Migrating from Cliphist](#migrating-from-cliphist) for more details. Refer to
> help text if confused.
> [Migrating from Cliphist](#migrating-from-cliphist) for more details.
The command interface of Stash is _only slightly_ different from Cliphist. In
most cases, you may simply replace `cliphist` with `stash` and your commands,
@ -296,7 +275,7 @@ entry has expired from history.
> 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
### 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
@ -320,25 +299,6 @@ ask the compositor for image data first. Most users will be fine using the
default value (`any`) but in the case your browser (or other applications!)
regularly misrepresent data, you might wish to prioritize a different type.
#### Clipboard Persistence
By default, when you copy something and close the source application, Wayland
clears the clipboard. Stash can optionally keep the clipboard contents available
after the source closes using the `--persist` flag.
```bash
stash watch --persist
```
When enabled, Stash will fork a background process to serve the clipboard
contents, keeping them available even after the original application exits.
> [!NOTE]
> This feature is **opt-in** and disabled by default, as it may not be desirable
> for all users and can leave clipboard data in memory longer than expected. You
> must start the `stash watch` daemon with `--persist` for clipboard
> persistence.
### Options
Some commands take additional flags to modify Stash's behavior. See each
@ -415,20 +375,6 @@ be only copied to the clipboard.
>
> `stash --excluded-apps Bitwarden watch`
## Motivation
I've been a long-time user of Cliphist. You can probably tell by the number of
times it has been mentioned in the README, if not for the attributions section,
that Stash is _clearly_ inspired and adapted from it. It's actually a great
clipboard manager if your needs are simple, but mine aren't. I need an
**all-in-one** solution, that I can freely hack on, with simple solutions to
complex problems that I've had with managing my clipboard. I wanted it to be
scriptable _and_ interactive, I wanted it to be performant, I wanted it to be...
You get the point. Perhaps you also share similar needs, or just like Rust
software in general on your desktop. In either case, Stash hopes to serve as an
excellent clipboard manager for your needs, with _excellent_ performance.
## Tips & Tricks
### Migrating from Cliphist
@ -594,8 +540,7 @@ your database:
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.
the database compact, especially after deleting many entries.
## Attributions
@ -604,14 +549,8 @@ My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the
powered by [several crates](./Cargo.toml), but none of them were as detrimental
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 :)
Additional thanks to my testers, who have tested earlier versions of Stash and
provided feedback. Thank you :)
## License

46
build.rs Normal file
View file

@ -0,0 +1,46 @@
use std::{env, fs, path::Path};
/// List of multicall symlinks to create (name, target)
const MULTICALL_LINKS: &[&str] =
&["stash-copy", "stash-paste", "wl-copy", "wl-paste"];
fn main() {
// OUT_DIR is something like .../target/debug/build/<pkg>/out
// We want .../target/debug or .../target/release
let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set");
let bin_dir = Path::new(&out_dir)
.ancestors()
.nth(3)
.expect("Failed to find binary dir");
// Path to the main stash binary
let stash_bin = bin_dir.join("stash");
// Create symlinks for each multicall binary
for link in MULTICALL_LINKS {
let link_path = bin_dir.join(link);
// Remove existing symlink or file if present
let _ = fs::remove_file(&link_path);
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
match symlink(&stash_bin, &link_path) {
Ok(()) => {
println!(
"cargo:warning=Created symlink: {} -> {}",
link_path.display(),
stash_bin.display()
);
},
Err(e) => {
println!(
"cargo:warning=Failed to create symlink {} -> {}: {}",
link_path.display(),
stash_bin.display(),
e
);
},
}
}
}
}

12
flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1775839657,
"narHash": "sha256-SPm9ck7jh3Un9nwPuMGbRU04UroFmOHjLP56T10MOeM=",
"lastModified": 1766194365,
"narHash": "sha256-4AFsUZ0kl6MXSm4BaQgItD0VGlEKR3iq7gIaL7TjBvc=",
"owner": "ipetkov",
"repo": "crane",
"rev": "7cf72d978629469c4bd4206b95c402514c1f6000",
"rev": "7d8ec2c71771937ab99790b45e6d9b93d15d9379",
"type": "github"
},
"original": {
@ -17,11 +17,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1775710090,
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
"lastModified": 1766309749,
"narHash": "sha256-3xY8CZ4rSnQ0NqGhMKAy5vgC+2IVK0NoVEzDoOh4DA4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "4c1018dae018162ec878d42fec712642d214fdfa",
"rev": "a6531044f6d0bef691ea18d4d4ce44d0daa6e816",
"type": "github"
},
"original": {

View file

@ -4,11 +4,9 @@
stdenv,
mold,
versionCheckHook,
useMold ? stdenv.isLinux,
createSymlinks ? true,
}: let
pname = "stash";
version = (lib.importTOML ../Cargo.toml).package.version;
version = (builtins.fromTOML (builtins.readFile ../Cargo.toml)).package.version;
src = let
fs = lib.fileset;
s = ../.;
@ -19,6 +17,7 @@
(fs.fileFilter (file: builtins.any file.hasExt ["rs"]) (s + /src))
(s + /Cargo.lock)
(s + /Cargo.toml)
(s + /build.rs)
];
};
@ -37,7 +36,7 @@ in
# generated by the build wrapper are correctly linked, we should link
# them *manually*. The postInstallCheck phase that follows will check
# to verify if all of those links are in place.
postInstall = lib.optionalString createSymlinks ''
postInstall = ''
mkdir -p $out
for bin in stash-copy stash-paste wl-copy wl-paste; do
ln -sf $out/bin/stash $out/bin/$bin
@ -49,13 +48,13 @@ in
# 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 ''
postInstallCheck = ''
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 {
env = lib.optionalAttrs (stdenv.isLinux && !stdenv.hostPlatform.isAarch) {
CARGO_LINKER = "clang";
CARGO_RUSTFLAGS = "-Clink-arg=-fuse-ld=${mold}/bin/mold";
};
@ -66,6 +65,5 @@ in
license = lib.licenses.mpl20;
maintainers = [lib.maintainers.NotAShelf];
mainProgram = "stash";
platforms = lib.platforms.linux;
};
}

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

@ -32,7 +32,7 @@ impl DecodeCommand for SqliteClipboardDb {
// If input is empty or whitespace, treat as error and trigger fallback
if input_str.trim().is_empty() {
log::debug!("no input provided to decode; relaying clipboard to stdout");
log::debug!("No input provided to decode; relaying clipboard to stdout");
if let Ok((mut reader, _mime)) =
get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Any)
{

View file

@ -9,7 +9,7 @@ pub trait DeleteCommand {
impl DeleteCommand for SqliteClipboardDb {
fn delete(&self, input: impl Read) -> Result<usize, StashError> {
let deleted = self.delete_entries(input)?;
log::info!("deleted {deleted} entries");
log::info!("Deleted {deleted} entries");
Ok(deleted)
}
}

View file

@ -55,11 +55,11 @@ impl ImportCommand for SqliteClipboardDb {
imported += 1;
}
log::info!("imported {imported} records from TSV into SQLite database.");
log::info!("Imported {imported} records from TSV into SQLite database.");
// Trim database to max_items after import
self.trim_db(max_items)?;
log::info!("trimmed clipboard database to max_items = {max_items}");
log::info!("Trimmed clipboard database to max_items = {max_items}");
Ok(())
}

View file

@ -11,7 +11,6 @@ pub trait ListCommand {
out: impl Write,
preview_width: u32,
include_expired: bool,
reverse: bool,
) -> Result<(), StashError>;
}
@ -21,265 +20,19 @@ impl ListCommand for SqliteClipboardDb {
out: impl Write,
preview_width: u32,
include_expired: bool,
reverse: bool,
) -> Result<(), StashError> {
self
.list_entries(out, preview_width, include_expired, reverse)
.list_entries(out, preview_width, include_expired)
.map(|_| ())
}
}
/// All mutable state for the TUI list view.
struct TuiState {
/// Total number of entries matching the current filter in the DB.
total: usize,
/// Global cursor position: index into the full ordered result set.
cursor: usize,
/// DB offset of `window[0]`, i.e., the first row currently loaded.
viewport_offset: usize,
/// The loaded slice of entries: `(id, preview, mime)`.
window: Vec<(i64, String, String)>,
/// How many rows the window holds (== visible list height).
window_size: usize,
/// Whether the window needs to be re-fetched from the DB.
dirty: bool,
/// Current search query. Empty string means no filter.
search_query: String,
/// Whether we're currently in search input mode.
search_mode: bool,
/// Whether to show entries in reverse order (oldest first).
reverse: bool,
/// ID of entry currently being copied.
copying_entry: Option<i64>,
}
impl TuiState {
/// Create initial state: count total rows, load the first window.
fn new(
db: &SqliteClipboardDb,
include_expired: bool,
window_size: usize,
preview_width: u32,
reverse: bool,
) -> Result<Self, StashError> {
let total = db.count_entries(include_expired, None)?;
let window = if total > 0 {
db.fetch_entries_window(
include_expired,
0,
window_size,
preview_width,
None,
reverse,
)?
} else {
Vec::new()
};
Ok(Self {
total,
cursor: 0,
viewport_offset: 0,
window,
window_size,
dirty: false,
search_query: String::new(),
search_mode: false,
reverse,
copying_entry: None,
})
}
/// Return the current search filter (`None` if empty).
fn search_filter(&self) -> Option<&str> {
if self.search_query.is_empty() {
None
} else {
Some(&self.search_query)
}
}
/// Update search query and reset cursor. Returns true if search changed.
fn set_search(&mut self, query: String) -> bool {
let changed = self.search_query != query;
if changed {
self.search_query = query;
self.cursor = 0;
self.viewport_offset = 0;
self.dirty = true;
}
changed
}
/// Clear search and reset state. Returns true if was searching.
fn clear_search(&mut self) -> bool {
let had_search = !self.search_query.is_empty();
self.search_query.clear();
self.search_mode = false;
if had_search {
self.cursor = 0;
self.viewport_offset = 0;
self.dirty = true;
}
had_search
}
/// Toggle search mode.
fn toggle_search_mode(&mut self) {
self.search_mode = !self.search_mode;
if self.search_mode {
// When entering search mode, clear query if there was one
// or start fresh
self.search_query.clear();
self.dirty = true;
}
}
/// Return the cursor position relative to the current window
/// (`window[local_cursor]` == the selected entry).
#[inline]
fn local_cursor(&self) -> usize {
self.cursor.saturating_sub(self.viewport_offset)
}
/// Return the selected `(id, preview, mime)` if any entry is selected.
fn selected_entry(&self) -> Option<&(i64, String, String)> {
if self.total == 0 {
return None;
}
self.window.get(self.local_cursor())
}
/// Move the cursor down by one, wrapping to 0 at the bottom.
fn move_down(&mut self) {
if self.total == 0 {
return;
}
self.cursor = if self.cursor + 1 >= self.total {
0
} else {
self.cursor + 1
};
self.dirty = true;
}
/// Move the cursor up by one, wrapping to `total - 1` at the top.
fn move_up(&mut self) {
if self.total == 0 {
return;
}
self.cursor = if self.cursor == 0 {
self.total - 1
} else {
self.cursor - 1
};
self.dirty = true;
}
/// Resize the window (e.g. terminal resized). Marks dirty so the
/// viewport is reloaded on the next frame.
fn resize(&mut self, new_size: usize) {
if new_size != self.window_size {
self.window_size = new_size;
self.dirty = true;
}
}
/// After a delete the total shrinks by one and the cursor may need
/// clamping. The caller is responsible for the DB deletion itself.
fn on_delete(&mut self) {
if self.total == 0 {
return;
}
self.total -= 1;
if self.total == 0 {
self.cursor = 0;
} else if self.cursor >= self.total {
self.cursor = self.total - 1;
}
self.dirty = true;
}
/// Reload the window from the DB if `dirty` is set or if the cursor
/// has drifted outside the currently loaded range.
fn sync(
&mut self,
db: &SqliteClipboardDb,
include_expired: bool,
preview_width: u32,
) -> Result<(), StashError> {
let cursor_out_of_window = self.cursor < self.viewport_offset
|| self.cursor >= self.viewport_offset + self.window.len().max(1);
if !self.dirty && !cursor_out_of_window {
return Ok(());
}
// Re-anchor the viewport so the cursor sits in the upper half when
// scrolling downward, or at a sensible position when wrapping.
let half = self.window_size / 2;
self.viewport_offset = if self.cursor >= half {
(self.cursor - half).min(self.total.saturating_sub(self.window_size))
} else {
0
};
let search = self.search_filter();
self.window = if self.total > 0 {
db.fetch_entries_window(
include_expired,
self.viewport_offset,
self.window_size,
preview_width,
search,
self.reverse,
)?
} else {
Vec::new()
};
self.dirty = false;
Ok(())
}
}
/// Query the maximum id digit-width and maximum mime byte-length across
/// all entries. This is pretty damn fast as it touches only index/metadata,
/// not blobs.
fn global_column_widths(
db: &SqliteClipboardDb,
include_expired: bool,
) -> Result<(usize, usize), StashError> {
let filter = if include_expired {
""
} else {
"WHERE (is_expired IS NULL OR is_expired = 0)"
};
let query = format!(
"SELECT COALESCE(MAX(LENGTH(CAST(id AS TEXT))), 2), \
COALESCE(MAX(LENGTH(mime)), 8) FROM clipboard {filter}"
);
let (id_w, mime_w): (i64, i64) = db
.conn
.query_row(&query, [], |r| Ok((r.get(0)?, r.get(1)?)))
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
Ok((id_w.max(2) as usize, mime_w.max(8) as usize))
}
impl SqliteClipboardDb {
#[allow(clippy::too_many_lines)]
pub fn list_tui(
&self,
preview_width: u32,
include_expired: bool,
reverse: bool,
) -> Result<(), StashError> {
use std::io::stdout;
@ -310,9 +63,46 @@ impl SqliteClipboardDb {
};
use wl_clipboard_rs::copy::{MimeType, Options, Source};
// One-time column-width metadata (no blob reads).
let (max_id_width, max_mime_width) =
global_column_widths(self, include_expired)?;
// Query entries from DB
let query = if include_expired {
"SELECT id, contents, mime FROM clipboard ORDER BY last_accessed DESC, \
id DESC"
} else {
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \
is_expired = 0) ORDER BY last_accessed DESC, id DESC"
};
let mut stmt = self
.conn
.prepare(query)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut rows = stmt
.query([])
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut entries: Vec<(i64, String, String)> = Vec::new();
let mut max_id_width = 2;
let mut max_mime_width = 8;
while let Some(row) = rows
.next()
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
{
let id: i64 = row
.get(0)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let contents: Vec<u8> = row
.get(1)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mime: Option<String> = row
.get(2)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let preview =
crate::db::preview_entry(&contents, mime.as_deref(), preview_width);
let mime_str = mime.as_deref().unwrap_or("").to_string();
let id_str = id.to_string();
max_id_width = max_id_width.max(id_str.width());
max_mime_width = max_mime_width.max(mime_str.width());
entries.push((id, preview, mime_str));
}
enable_raw_mode()
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
@ -323,160 +113,35 @@ impl SqliteClipboardDb {
let mut terminal = Terminal::new(backend)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
// Derive initial window size from current terminal height.
let initial_height = terminal
.size()
.map(|r| r.height.saturating_sub(2) as usize)
.unwrap_or(24);
let initial_height = initial_height.max(1);
let mut tui = TuiState::new(
self,
include_expired,
initial_height,
preview_width,
reverse,
)?;
// ratatui ListState; only tracks selection within the *window* slice.
let mut list_state = ListState::default();
if tui.total > 0 {
list_state.select(Some(0));
let mut state = ListState::default();
if !entries.is_empty() {
state.select(Some(0));
}
/// Accumulated actions from draining the event queue.
struct EventActions {
quit: bool,
net_down: i64, // positive=down, negative=up, 0=none
copy: bool,
delete: bool,
toggle_search: bool, // enter/exit search mode
search_input: Option<char>, // character typed in search mode
search_backspace: bool, // backspace in search mode
clear_search: bool, // clear search query (ESC in search mode)
}
/// Drain all pending key events and return what actions to perform.
/// Navigation is capped to +-1 per frame to prevent jumpy scrolling when
/// the key-repeat rate exceeds the render frame rate.
fn drain_events(tui: &TuiState) -> Result<EventActions, StashError> {
let mut actions = EventActions {
quit: false,
net_down: 0,
copy: false,
delete: false,
toggle_search: false,
search_input: None,
search_backspace: false,
clear_search: false,
};
while event::poll(std::time::Duration::from_millis(0))
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
{
if let Event::Key(key) = event::read()
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
{
if tui.search_mode {
// In search mode, handle text input
match (key.code, key.modifiers) {
(KeyCode::Esc, _) => {
actions.clear_search = true;
},
(KeyCode::Enter, _) => {
actions.toggle_search = true; // exit search mode
},
(KeyCode::Backspace, _) => {
actions.search_backspace = true;
},
(KeyCode::Char(c), _) => {
actions.search_input = Some(c);
},
_ => {},
}
} else {
// Normal mode navigation commands
match (key.code, key.modifiers) {
(KeyCode::Char('q') | KeyCode::Esc, _) => actions.quit = true,
(KeyCode::Down | KeyCode::Char('j'), _) => {
// Cap at +1 per frame for smooth scrolling
if actions.net_down < 1 {
actions.net_down += 1;
}
},
(KeyCode::Up | KeyCode::Char('k'), _) => {
// Cap at -1 per frame for smooth scrolling
if actions.net_down > -1 {
actions.net_down -= 1;
}
},
(KeyCode::Enter, _) => actions.copy = true,
(KeyCode::Char('D'), KeyModifiers::SHIFT) => {
actions.delete = true;
},
(KeyCode::Char('/'), _) => actions.toggle_search = true,
_ => {},
}
}
}
}
Ok(actions)
}
let draw_frame =
|terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
tui: &mut TuiState,
list_state: &mut ListState,
max_id_width: usize,
max_mime_width: usize|
-> Result<(), StashError> {
// Reserve 2 rows for search bar when in search mode
let search_bar_height = if tui.search_mode { 2 } else { 0 };
let term_height = terminal
.size()
.map(|r| r.height.saturating_sub(2 + search_bar_height) as usize)
.unwrap_or(24)
.max(1);
tui.resize(term_height);
tui.sync(self, include_expired, preview_width)?;
if tui.total == 0 {
list_state.select(None);
} else {
list_state.select(Some(tui.local_cursor()));
}
let res = (|| -> Result<(), StashError> {
loop {
terminal
.draw(|f| {
let area = f.area();
// Build title based on search state
let title = if tui.search_mode {
format!("Search: {}", tui.search_query)
} else if tui.search_query.is_empty() {
"Clipboard Entries (j/k/↑/↓ to move, / to search, Enter to copy, \
Shift+D to delete, q/ESC to quit)"
.to_string()
} else {
format!(
"Clipboard Entries (filtered: '{}' - {} results, / to search, \
ESC to clear, q to quit)",
tui.search_query, tui.total
let block = Block::default()
.title(
"Clipboard Entries (j/k/↑/↓ to move, Enter to copy, Shift+D \
to delete, q/ESC to quit)",
)
};
let block = Block::default().title(title).borders(Borders::ALL);
.borders(Borders::ALL);
let border_width = 2;
let highlight_symbol = ">";
let highlight_width = 1;
let content_width = area.width as usize - border_width;
// Minimum widths for columns
let min_id_width = 2;
let min_mime_width = 6;
let min_preview_width = 4;
let spaces = 3;
let spaces = 3; // [id][ ][preview][ ][mime]
// Dynamically allocate widths
let mut id_col = max_id_width.max(min_id_width);
let mut mime_col = max_mime_width.max(min_mime_width);
let mut preview_col = content_width
@ -485,6 +150,7 @@ impl SqliteClipboardDb {
.saturating_sub(mime_col)
.saturating_sub(spaces);
// If not enough space, shrink columns
if preview_col < min_preview_width {
let needed = min_preview_width - preview_col;
if mime_col > min_mime_width {
@ -507,13 +173,13 @@ impl SqliteClipboardDb {
preview_col = min_preview_width;
}
let selected = list_state.selected();
let selected = state.selected();
let list_items: Vec<ListItem> = tui
.window
let list_items: Vec<ListItem> = entries
.iter()
.enumerate()
.map(|(i, entry)| {
// Truncate preview by grapheme clusters and display width
let mut preview = String::new();
let mut width = 0;
for g in entry.1.graphemes(true) {
@ -525,6 +191,7 @@ impl SqliteClipboardDb {
preview.push_str(g);
width += g_width;
}
// Truncate and pad mimetype
let mut mime = String::new();
let mut mwidth = 0;
for g in entry.2.graphemes(true) {
@ -537,6 +204,8 @@ impl SqliteClipboardDb {
mwidth += g_width;
}
// Compose the row as highlight + id + space + preview + space +
// mimetype
let mut spans = Vec::new();
let (id, preview, mime) = entry;
if Some(i) == selected {
@ -583,121 +252,70 @@ impl SqliteClipboardDb {
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("");
.highlight_symbol(""); // handled manually
f.render_stateful_widget(list, area, list_state);
f.render_stateful_widget(list, area, &mut state);
})
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
Ok(())
};
// Initial draw.
draw_frame(
&mut terminal,
&mut tui,
&mut list_state,
max_id_width,
max_mime_width,
)?;
let res = (|| -> Result<(), StashError> {
loop {
// Block waiting for events, then drain and process all queued input.
if event::poll(std::time::Duration::from_millis(250))
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
&& let Event::Key(key) = event::read()
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
{
let actions = drain_events(&tui)?;
if actions.quit {
break;
}
// Handle search mode actions
if actions.toggle_search {
tui.toggle_search_mode();
}
if actions.clear_search && tui.clear_search() {
// Search was cleared, refresh count
tui.total =
self.count_entries(include_expired, tui.search_filter())?;
}
if let Some(c) = actions.search_input {
let new_query = format!("{}{}", tui.search_query, c);
if tui.set_search(new_query) {
// Search changed, refresh count and reset
tui.total =
self.count_entries(include_expired, tui.search_filter())?;
}
}
if actions.search_backspace {
let new_query = tui
.search_query
.chars()
.next_back()
.map(|_| {
tui
.search_query
.chars()
.take(tui.search_query.len() - 1)
.collect::<String>()
})
.unwrap_or_default();
if tui.set_search(new_query) {
// Search changed, refresh count and reset
tui.total =
self.count_entries(include_expired, tui.search_filter())?;
}
}
// Apply navigation (capped at ±1 per frame for smooth scrolling).
if !tui.search_mode {
if actions.net_down > 0 {
tui.move_down();
} else if actions.net_down < 0 {
tui.move_up();
}
if actions.delete
&& let Some(&(id, ..)) = tui.selected_entry()
{
self
.conn
.execute(
"DELETE FROM clipboard WHERE id = ?1",
rusqlite::params![id],
)
.map_err(|e| {
StashError::DeleteEntry(id, e.to_string().into())
})?;
tui.on_delete();
let _ = Notification::new()
.summary("Stash")
.body("Deleted entry")
.show();
}
if actions.copy
&& let Some(&(id, ..)) = tui.selected_entry()
{
if tui.copying_entry == Some(id) {
log::debug!(
"Skipping duplicate copy for entry {id} (already in \
progress)"
);
match (key.code, key.modifiers) {
(KeyCode::Char('q') | KeyCode::Esc, _) => break,
(KeyCode::Down | KeyCode::Char('j'), _) => {
if entries.is_empty() {
state.select(None);
} else {
tui.copying_entry = Some(id);
match self.copy_entry(id) {
let i = match state.selected() {
Some(i) => {
if i >= entries.len() - 1 {
0
} else {
i + 1
}
},
None => 0,
};
state.select(Some(i));
}
},
(KeyCode::Up | KeyCode::Char('k'), _) => {
if entries.is_empty() {
state.select(None);
} else {
let i = match state.selected() {
Some(i) => {
if i == 0 {
entries.len() - 1
} else {
i - 1
}
},
None => 0,
};
state.select(Some(i));
}
},
(KeyCode::Enter, _) => {
if let Some(idx) = state.selected()
&& let Some((id, ..)) = entries.get(idx)
{
match self.copy_entry(*id) {
Ok((new_id, contents, mime)) => {
if new_id != id {
tui.dirty = true;
if new_id != *id {
entries[idx] = (
new_id,
entries[idx].1.clone(),
entries[idx].2.clone(),
);
}
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()),
Some(ref m) => MimeType::Specific(m.clone().to_owned()),
None => MimeType::Text,
};
let copy_result = opts
@ -710,7 +328,7 @@ impl SqliteClipboardDb {
.show();
},
Err(e) => {
log::error!("failed to copy entry to clipboard: {e}");
log::error!("Failed to copy entry to clipboard: {e}");
let _ = Notification::new()
.summary("Stash")
.body(&format!("Failed to copy to clipboard: {e}"))
@ -719,26 +337,48 @@ impl SqliteClipboardDb {
}
},
Err(e) => {
log::error!("failed to fetch entry {id}: {e}");
log::error!("Failed to fetch entry {id}: {e}");
let _ = Notification::new()
.summary("Stash")
.body(&format!("Failed to fetch entry: {e}"))
.show();
},
}
tui.copying_entry = None;
}
}
},
(KeyCode::Char('D'), KeyModifiers::SHIFT) => {
if let Some(idx) = state.selected()
&& let Some((id, ..)) = entries.get(idx)
{
// Delete entry from DB
self
.conn
.execute(
"DELETE FROM clipboard WHERE id = ?1",
rusqlite::params![id],
)
.map_err(|e| {
StashError::DeleteEntry(*id, e.to_string().into())
})?;
// Remove from entries and update selection
entries.remove(idx);
let new_len = entries.len();
if new_len == 0 {
state.select(None);
} else if idx >= new_len {
state.select(Some(new_len - 1));
} else {
state.select(Some(idx));
}
// Show notification
let _ = Notification::new()
.summary("Stash")
.body("Deleted entry")
.show();
}
},
_ => {},
}
// Redraw once after processing all accumulated input.
draw_frame(
&mut terminal,
&mut tui,
&mut list_state,
max_id_width,
max_mime_width,
)?;
}
}
Ok(())

View file

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

View file

@ -2,7 +2,6 @@ use std::io::Read;
use crate::db::{ClipboardDb, SqliteClipboardDb};
#[allow(clippy::too_many_arguments)]
pub trait StoreCommand {
fn store(
&self,
@ -11,8 +10,6 @@ pub trait StoreCommand {
max_items: u64,
state: Option<String>,
excluded_apps: &[String],
min_size: Option<usize>,
max_size: usize,
) -> Result<(), crate::db::StashError>;
}
@ -24,24 +21,18 @@ impl StoreCommand for SqliteClipboardDb {
max_items: u64,
state: Option<String>,
excluded_apps: &[String],
min_size: Option<usize>,
max_size: usize,
) -> Result<(), crate::db::StashError> {
if let Some("sensitive" | "clear") = state.as_deref() {
self.delete_last()?;
log::info!("entry deleted");
log::info!("Entry deleted");
} else {
self.store_entry(
input,
max_dedupe_search,
max_items,
Some(excluded_apps),
min_size,
max_size,
None, // no pre-computed hash for CLI store
None, // no mime types for CLI store
)?;
log::info!("entry stored");
log::info!("Entry stored");
}
Ok(())
}

View file

@ -1,4 +1,9 @@
use std::{collections::BinaryHeap, hash::Hasher, io::Read, time::Duration};
use std::{
collections::{BinaryHeap, hash_map::DefaultHasher},
hash::{Hash, Hasher},
io::Read,
time::Duration,
};
use smol::Timer;
use wl_clipboard_rs::{
@ -12,11 +17,7 @@ use wl_clipboard_rs::{
},
};
use crate::{
clipboard::{self, ClipboardData, get_serving_pid},
db::{SqliteClipboardDb, nonblocking::AsyncClipboardDb},
hash::Fnv1aHasher,
};
use crate::db::{ClipboardDb, SqliteClipboardDb};
/// Wrapper to provide [`Ord`] implementation for `f64` by negating values.
/// This allows [`BinaryHeap`], which is a max-heap, to function as a min-heap.
@ -58,7 +59,7 @@ impl std::cmp::Ord for Neg {
}
/// Min-heap for tracking entry expirations with sub-second precision.
/// Uses Neg wrapper to turn `BinaryHeap` (max-heap) into min-heap behavior.
/// Uses Neg wrapper to turn BinaryHeap (max-heap) into min-heap behavior.
#[derive(Debug, Default)]
struct ExpirationQueue {
heap: BinaryHeap<(Neg, i64)>,
@ -96,16 +97,6 @@ impl ExpirationQueue {
}
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.
@ -127,29 +118,21 @@ impl ExpirationQueue {
/// 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)?;
) -> Result<(Box<dyn Read>, String), wl_clipboard_rs::paste::Error> {
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));
return Ok((Box::new(reader) as Box<dyn Read>, mime_str));
}
let offered =
get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified)?;
let chosen = if preference == "image" {
// Pick the first offered image type, fall back to first overall
offered
@ -186,286 +169,235 @@ fn negotiate_mime_type(
Seat::Unspecified,
PasteMimeType::Specific(mime_str),
)?;
Ok((Box::new(reader) as Box<dyn Read>, actual_mime, offered))
Ok((Box::new(reader) as Box<dyn Read>, actual_mime))
},
None => Err(wl_clipboard_rs::paste::Error::NoSeats),
}
}
#[allow(clippy::too_many_arguments)]
pub trait WatchCommand {
async fn watch(
fn watch(
&self,
max_dedupe_search: u64,
max_items: u64,
excluded_apps: &[String],
expire_after: Option<Duration>,
mime_type_preference: &str,
min_size: Option<usize>,
max_size: usize,
persist: bool,
);
}
impl WatchCommand for SqliteClipboardDb {
async fn watch(
fn watch(
&self,
max_dedupe_search: u64,
max_items: u64,
excluded_apps: &[String],
expire_after: Option<Duration>,
mime_type_preference: &str,
min_size: Option<usize>,
max_size: usize,
persist: bool,
) {
let async_db = AsyncClipboardDb::new(self.db_path.clone());
log::info!(
"Starting clipboard watch daemon with MIME type preference: \
{mime_type_preference}"
);
smol::block_on(async {
log::info!(
"Starting clipboard watch daemon with MIME type preference: \
{mime_type_preference}"
);
if persist {
log::info!("clipboard persistence enabled");
}
// Build expiration queue from existing entries
let mut exp_queue = ExpirationQueue::new();
// Load all expirations from database asynchronously
match async_db.load_all_expirations().await {
Ok(expirations) => {
for (expires_at, id) in expirations {
exp_queue.push(expires_at, id);
}
if !exp_queue.is_empty() {
log::info!("loaded {} expirations from database", exp_queue.len());
}
},
Err(e) => {
log::warn!("failed to load expirations: {e}");
},
}
// We use hashes for comparison instead of storing full contents
let mut last_hash: Option<u64> = None;
let mut buf = Vec::with_capacity(4096);
// Helper to hash clipboard contents using FNV-1a (deterministic across
// runs)
let hash_contents = |data: &[u8]| -> u64 {
let mut hasher = Fnv1aHasher::new();
hasher.write(data);
hasher.finish()
};
// Initialize with current clipboard using smart MIME negotiation
if let Ok((mut reader, ..)) = negotiate_mime_type(mime_type_preference) {
buf.clear();
if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
last_hash = Some(hash_contents(&buf));
}
}
let poll_interval = Duration::from_millis(500);
loop {
// Process any pending expirations that are due now
if let Some(next_exp) = exp_queue.peek_next() {
let now = SqliteClipboardDb::now();
if next_exp <= now {
// Expired entries to process
let expired_ids = exp_queue.pop_expired(now);
for id in expired_ids {
// Verify entry still exists and get its content_hash
let expired_hash: Option<i64> =
match async_db.get_content_hash(id).await {
Ok(hash) => hash,
Err(e) => {
log::warn!("failed to get content hash for entry {id}: {e}");
None
},
};
if let Some(stored_hash) = expired_hash {
// Mark as expired
if let Err(e) = async_db.mark_expired(id).await {
log::warn!("failed to mark entry {id} as expired: {e}");
} else {
log::info!("entry {id} marked as expired");
}
// Check if this expired entry is currently in the clipboard
if let Ok((mut reader, ..)) =
negotiate_mime_type(mime_type_preference)
// Build expiration queue from existing entries
let mut exp_queue = ExpirationQueue::new();
if let Ok(Some((expires_at, id))) = self.get_next_expiration() {
exp_queue.push(expires_at, id);
// Load remaining expirations (exclude already-marked expired entries)
let mut stmt = self
.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",
)
.ok();
if let Some(ref mut stmt) = stmt {
let mut rows = stmt.query([]).ok();
if let Some(ref mut rows) = rows {
while let Ok(Some(row)) = rows.next() {
if let (Ok(exp), Ok(row_id)) =
(row.get::<_, f64>(0), row.get::<_, i64>(1))
{
let mut current_buf = Vec::new();
if reader.read_to_end(&mut current_buf).is_ok()
&& !current_buf.is_empty()
// Skip first entry which is already added
if exp_queue
.heap
.iter()
.any(|(_, existing_id)| *existing_id == row_id)
{
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}"
continue;
}
exp_queue.push(exp, row_id);
}
}
}
}
}
// 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
let hash_contents = |data: &[u8]| -> u64 {
let mut hasher = DefaultHasher::new();
data.hash(&mut hasher);
hasher.finish()
};
// Initialize with current clipboard using smart MIME negotiation
if let Ok((mut reader, _)) = negotiate_mime_type(mime_type_preference) {
buf.clear();
if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
last_hash = Some(hash_contents(&buf));
}
}
loop {
// Process any pending expirations
if let Some(next_exp) = exp_queue.peek_next() {
let now = SqliteClipboardDb::now();
if next_exp <= now {
// Expired entries to process
let expired_ids = exp_queue.pop_expired(now);
for id in expired_ids {
// Verify entry still exists and get its content_hash
let expired_hash: Option<i64> = self
.conn
.query_row(
"SELECT content_hash FROM clipboard WHERE id = ?1",
[id],
|row| row.get(0),
)
.ok();
if let Some(stored_hash) = expired_hash {
// Mark as expired
self
.conn
.execute(
"UPDATE clipboard SET is_expired = 1 WHERE id = ?1",
[id],
)
.ok();
log::info!("Entry {id} marked as expired");
// Check if this expired entry is currently in the clipboard
if let Ok((mut reader, _)) =
negotiate_mime_type(mime_type_preference)
{
let mut current_buf = Vec::new();
if reader.read_to_end(&mut current_buf).is_ok()
&& !current_buf.is_empty()
{
let current_hash = hash_contents(&current_buf);
// Compare as i64 (database stores as i64)
if current_hash as i64 == stored_hash {
// 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}"
);
}
}
}
}
}
}
} else {
// Sleep *precisely* until next expiration
let sleep_duration = next_exp - now;
Timer::after(Duration::from_secs_f64(sleep_duration)).await;
continue; // skip normal poll, process expirations first
}
}
}
// 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;
}
// Normal clipboard polling
match negotiate_mime_type(mime_type_preference) {
Ok((mut reader, _mime_type)) => {
buf.clear();
if let Err(e) = reader.read_to_end(&mut buf) {
log::error!("Failed to read clipboard contents: {e}");
Timer::after(Duration::from_millis(500)).await;
continue;
}
// Only store if changed and not empty
if !buf.is_empty() {
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,
// Only store if changed and not empty
if !buf.is_empty() {
let current_hash = hash_contents(&buf);
if last_hash != Some(current_hash) {
match self.store_entry(
&buf[..],
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);
Some(excluded_apps),
) {
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 {
// Set expiration if configured
if let Some(duration) = expire_after {
let expires_at =
SqliteClipboardDb::now() + duration.as_secs_f64();
self.set_expiration(id, expires_at).ok();
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(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}");
}
},
}
},
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;
}
// Normal poll interval (only if no expirations pending)
if exp_queue.peek_next().is_none() {
Timer::after(Duration::from_millis(500)).await;
}
}
});
}
}
/// Given ordered offers and a preference, return the
/// Unit-testable helper: 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)]
@ -568,145 +500,4 @@ mod tests {
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,10 +1,3 @@
mod clipboard;
mod commands;
mod db;
mod hash;
mod mime;
mod multicall;
use std::{
env,
io::{self, IsTerminal},
@ -13,27 +6,24 @@ use std::{
};
use clap::{CommandFactory, Parser, Subcommand};
use color_eyre::eyre;
use humantime::parse_duration;
use inquire::Confirm;
// While the module is named "wayland", the Wayland module is *strictly* for the
// use-toplevel feature as it requires some low-level wayland crates that are
// not required *by default*. The module is named that way because "toplevel"
// sounded too silly. Stash is strictly a Wayland clipboard manager.
mod commands;
pub(crate) mod db;
pub(crate) mod mime;
mod multicall;
#[cfg(feature = "use-toplevel")] mod wayland;
use crate::{
commands::{
decode::DecodeCommand,
delete::DeleteCommand,
import::ImportCommand,
list::ListCommand,
query::QueryCommand,
store::StoreCommand,
watch::WatchCommand,
},
db::{ClipboardDb, DEFAULT_MAX_ENTRY_SIZE},
use crate::commands::{
decode::DecodeCommand,
delete::DeleteCommand,
import::ImportCommand,
list::ListCommand,
query::QueryCommand,
store::StoreCommand,
watch::WatchCommand,
wipe::WipeCommand,
};
#[derive(Parser)]
@ -52,16 +42,6 @@ struct Cli {
#[arg(long, default_value_t = 20)]
max_dedupe_search: u64,
/// Minimum size (in bytes) for clipboard entries. Entries smaller than this
/// will not be stored.
#[arg(long, env = "STASH_MIN_SIZE")]
min_size: Option<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
/// output.
#[arg(long, default_value_t = 100)]
@ -98,10 +78,6 @@ enum Command {
/// Show only expired entries (diagnostic, does not remove them)
#[arg(long)]
expired: bool,
/// Reverse the order of entries (oldest first instead of newest first)
#[arg(long)]
reverse: bool,
},
/// Decode and output clipboard entry by id
@ -123,6 +99,16 @@ enum Command {
ask: bool,
},
/// Wipe all clipboard history
///
/// DEPRECATED: Use `stash db wipe` instead
#[command(hide = true)]
Wipe {
/// Ask for confirmation before wiping
#[arg(long)]
ask: bool,
},
/// Database management operations
Db {
#[command(subcommand)]
@ -149,10 +135,6 @@ enum Command {
/// 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,
},
}
@ -189,27 +171,9 @@ 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
fn main() -> eyre::Result<()> {
color_eyre::install()?;
fn main() -> color_eyre::eyre::Result<()> {
// 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()
@ -235,25 +199,19 @@ fn main() -> eyre::Result<()> {
.filter_level(cli.verbosity.into())
.init();
let db_path = match cli.db_path {
Some(path) => path,
None => {
let cache_dir = dirs::cache_dir().ok_or_else(|| {
eyre::eyre!(
"Could not determine cache directory. Set --db-path or \
$STASH_DB_PATH explicitly."
)
})?;
cache_dir.join("stash").join("db")
},
};
let db_path = cli.db_path.unwrap_or_else(|| {
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("stash")
.join("db")
});
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)?;
}
let conn = rusqlite::Connection::open(&db_path)?;
let db = db::SqliteClipboardDb::new(conn, db_path)?;
let db = db::SqliteClipboardDb::new(conn)?;
match cli.command {
Some(Command::Store) => {
@ -268,26 +226,20 @@ fn main() -> eyre::Result<()> {
&cli.excluded_apps,
#[cfg(not(feature = "use-toplevel"))]
&[],
cli.min_size,
cli.max_size,
),
"failed to store entry",
);
},
Some(Command::List {
format,
expired,
reverse,
}) => {
Some(Command::List { format, expired }) => {
match format.as_deref() {
Some("tsv") => {
report_error(
db.list(io::stdout(), cli.preview_width, expired, reverse),
db.list(io::stdout(), cli.preview_width, expired),
"failed to list entries",
);
},
Some("json") => {
match db.list_json(expired, reverse) {
match db.list_json(expired) {
Ok(json) => {
println!("{json}");
},
@ -302,12 +254,12 @@ fn main() -> eyre::Result<()> {
None => {
if std::io::stdout().is_terminal() {
report_error(
db.list_tui(cli.preview_width, expired, reverse),
db.list_tui(cli.preview_width, expired),
"failed to list entries in TUI",
);
} else {
report_error(
db.list(io::stdout(), cli.preview_width, expired, reverse),
db.list(io::stdout(), cli.preview_width, expired),
"failed to list entries",
);
}
@ -324,7 +276,10 @@ fn main() -> eyre::Result<()> {
let mut should_proceed = true;
if ask {
should_proceed =
confirm("Are you sure you want to delete clipboard entries?");
Confirm::new("Are you sure you want to delete clipboard entries?")
.with_default(false)
.prompt()
.unwrap_or(false);
if !should_proceed {
log::info!("aborted by user.");
@ -375,6 +330,27 @@ fn main() -> eyre::Result<()> {
}
}
},
Some(Command::Wipe { ask }) => {
eprintln!(
"Warning: The 'stash wipe' command is deprecated. Use 'stash db \
wipe' instead."
);
let mut should_proceed = true;
if ask {
should_proceed = Confirm::new(
"Are you sure you want to wipe all clipboard history?",
)
.with_default(false)
.prompt()
.unwrap_or(false);
if !should_proceed {
log::info!("wipe command aborted by user.");
}
}
if should_proceed {
report_error(db.wipe(), "failed to wipe database");
}
},
Some(Command::Db { action }) => {
match action {
@ -386,7 +362,10 @@ fn main() -> eyre::Result<()> {
} else {
"Are you sure you want to wipe ALL clipboard history?"
};
should_proceed = confirm(message);
should_proceed = Confirm::new(message)
.with_default(false)
.prompt()
.unwrap_or(false);
if !should_proceed {
log::info!("db wipe command aborted by user.");
}
@ -395,21 +374,21 @@ fn main() -> eyre::Result<()> {
if expired {
match db.cleanup_expired() {
Ok(count) => {
log::info!("wiped {count} expired entries");
log::info!("Wiped {} expired entries", count);
},
Err(e) => {
log::error!("failed to wipe expired entries: {e}");
},
}
} else {
report_error(db.wipe_db(), "failed to wipe database");
report_error(db.wipe(), "failed to wipe database");
}
}
},
DbAction::Vacuum => {
match db.vacuum() {
Ok(()) => {
log::info!("database optimized successfully");
log::info!("Database optimized successfully");
},
Err(e) => {
log::error!("failed to vacuum database: {e}");
@ -419,7 +398,7 @@ fn main() -> eyre::Result<()> {
DbAction::Stats => {
match db.stats() {
Ok(stats) => {
println!("{stats}");
println!("{}", stats);
},
Err(e) => {
log::error!("failed to get database stats: {e}");
@ -432,10 +411,13 @@ fn main() -> eyre::Result<()> {
Some(Command::Import { r#type, ask }) => {
let mut should_proceed = true;
if ask {
should_proceed = confirm(
should_proceed = Confirm::new(
"Are you sure you want to import clipboard data? This may \
overwrite existing entries.",
);
)
.with_default(false)
.prompt()
.unwrap_or(false);
if !should_proceed {
log::info!("import command aborted by user.");
}
@ -459,7 +441,6 @@ fn main() -> eyre::Result<()> {
Some(Command::Watch {
expire_after,
mime_type,
persist,
}) => {
db.watch(
cli.max_dedupe_search,
@ -470,11 +451,7 @@ fn main() -> eyre::Result<()> {
&[],
expire_after,
&mime_type,
cli.min_size,
cli.max_size,
persist,
)
.await;
);
},
None => {

View file

@ -360,7 +360,7 @@ fn execute_watch_command(
/// 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`).
/// text representations (TEXT, STRING, UTF8_STRING).
fn select_best_mime_type(
types: &std::collections::HashSet<String>,
) -> Option<String> {
@ -421,7 +421,7 @@ fn handle_regular_paste(
let selected_type = available_types.as_ref().and_then(select_best_mime_type);
let mime_type = if let Some(ref best) = selected_type {
log::debug!("auto-selecting MIME type: {best}");
log::debug!("Auto-selecting MIME type: {}", best);
PasteMimeType::Specific(best)
} else {
get_paste_mime_type(args.mime_type.as_deref())
@ -461,14 +461,14 @@ fn handle_regular_paste(
// Only add newline for text content, not binary data
// 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 {
let is_text_content = if !types.is_empty() {
types.starts_with("text/")
|| types == "application/json"
|| types == "application/xml"
|| types == "application/x-sh"
} else {
// If no MIME type, check if content is valid UTF-8
std::str::from_utf8(&buf).is_ok()
};
if !args.no_newline

View file

@ -1,9 +1,8 @@
use std::{
collections::HashMap,
sync::{Arc, LazyLock, Mutex},
sync::{LazyLock, Mutex},
};
use arc_swap::ArcSwapOption;
use log::debug;
use wayland_client::{
Connection as WaylandConnection,
@ -18,7 +17,7 @@ use wayland_protocols_wlr::foreign_toplevel::v1::client::{
zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1},
};
static FOCUSED_APP: ArcSwapOption<String> = ArcSwapOption::const_empty();
static FOCUSED_APP: Mutex<Option<String>> = Mutex::new(None);
static TOPLEVEL_APPS: LazyLock<Mutex<HashMap<ObjectId, String>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
@ -33,11 +32,12 @@ pub fn init_wayland_state() {
/// 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() {
// Try Wayland protocol first
if let Ok(focused) = FOCUSED_APP.lock()
&& let Some(ref app) = *focused
{
debug!("Found focused app via Wayland protocol: {app}");
return Some(app.to_string());
return Some(app.clone());
}
debug!("No focused window detection method worked");
@ -152,11 +152,12 @@ impl Dispatch<ZwlrForeignToplevelHandleV1, ()> for AppState {
}) {
debug!("Toplevel activated");
// Update focused app to the `app_id` of this handle
if let Ok(apps) = TOPLEVEL_APPS.lock()
if let (Ok(apps), Ok(mut focused)) =
(TOPLEVEL_APPS.lock(), FOCUSED_APP.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())));
*focused = Some(app_id.clone());
}
}
},