mirror of
https://github.com/NotAShelf/stash.git
synced 2026-04-25 10:59:59 +00:00
Compare commits
43 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
01939c2136 |
|||
|
|
0ebf62fa5d |
||
|
4d3c99368f |
|||
|
|
7498d688c9 |
||
|
3c61cc19f6 |
|||
|
|
cd692ba002 |
||
|
ac7fbe293b |
|||
|
84cf1b46ad |
|||
|
81683ded03 |
|||
|
20504a6e8b |
|||
|
f139bda7b2 |
|||
|
|
32cf1936b6 | ||
|
b0ee7f59a3 |
|||
|
75ca501e29 |
|||
|
5cb6c84f08 |
|||
|
da9bf5ea3e |
|||
|
9702e67599 |
|||
| 77ac70f0d3 | |||
| d643376cd7 | |||
|
a2a609f07d |
|||
|
d9bee33aba |
|||
|
030be21ea5 |
|||
|
fe86356399 |
|||
|
0c57f9b4bd |
|||
|
aabf40ac6e |
|||
|
|
909bb53afa |
||
|
208359dc0c |
|||
|
|
3faadd709f |
||
|
8754921106 |
|||
|
be6cde092a |
|||
|
b1f43bdf7f |
|||
|
373affabee |
|||
|
0865a1f139 |
|||
|
cf5b1e8205 |
|||
|
95bf1766ce |
|||
|
7184c8b682 |
|||
|
ffdc13e8f5 |
|||
|
|
5e0599dc71 |
||
|
181edcefb1 |
|||
|
ebf46de99d |
|||
|
ba2e29d5b7 |
|||
|
3a14860ae1 |
|||
|
02ba05dc95 |
25 changed files with 2856 additions and 962 deletions
22
.github/dependabot.yaml
vendored
22
.github/dependabot.yaml
vendored
|
|
@ -1,13 +1,23 @@
|
|||
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
|
||||
|
|
|
|||
2
.github/workflows/nix-cache.yaml
vendored
2
.github/workflows/nix-cache.yaml
vendored
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- uses: cachix/cachix-action@v16
|
||||
- uses: cachix/cachix-action@v17
|
||||
with:
|
||||
name: nyx
|
||||
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
|
||||
|
|
|
|||
6
.github/workflows/release.yaml
vendored
6
.github/workflows/release.yaml
vendored
|
|
@ -40,7 +40,7 @@ jobs:
|
|||
steps:
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
|
@ -98,7 +98,7 @@ jobs:
|
|||
cp target/${{ matrix.target }}/release/stash ${{ matrix.name }}
|
||||
|
||||
- name: Upload Release Asset
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
files: ${{ matrix.name }}
|
||||
|
||||
|
|
@ -120,7 +120,7 @@ jobs:
|
|||
sha256sum stash-* > SHA256SUMS
|
||||
|
||||
- name: Upload Checksums
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
files: SHA256SUMS
|
||||
|
|
|
|||
999
Cargo.lock
generated
999
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
26
Cargo.toml
26
Cargo.toml
|
|
@ -14,40 +14,44 @@ 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"
|
||||
clap = { version = "4.5.60", features = [ "derive", "env" ] }
|
||||
blocking = "1.6.2"
|
||||
clap = { version = "4.6.0", features = [ "derive", "env" ] }
|
||||
clap-verbosity-flag = "3.0.4"
|
||||
color-eyre = "0.6.5"
|
||||
crossterm = "0.29.0"
|
||||
ctrlc = "3.5.1"
|
||||
ctrlc = "3.5.2"
|
||||
dirs = "6.0.0"
|
||||
env_logger = "0.11.8"
|
||||
env_logger = "0.11.10"
|
||||
humantime = "2.3.0"
|
||||
imagesize = "0.14.0"
|
||||
inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] }
|
||||
libc = "0.2.182"
|
||||
libc = "0.2.185"
|
||||
log = "0.4.29"
|
||||
notify-rust = { version = "4.12.0", optional = true }
|
||||
mime-sniffer = "0.1.3"
|
||||
notify-rust = { version = "4.14.0", optional = true }
|
||||
ratatui = "0.30.0"
|
||||
regex = "1.12.3"
|
||||
rusqlite = { version = "0.38.0", features = [ "bundled" ] }
|
||||
rusqlite = { version = "0.39.0", features = [ "bundled" ] }
|
||||
serde = { version = "1.0.228", features = [ "derive" ] }
|
||||
serde_json = "1.0.149"
|
||||
smol = "2.0.2"
|
||||
thiserror = "2.0.18"
|
||||
unicode-segmentation = "1.12.0"
|
||||
unicode-segmentation = "1.13.2"
|
||||
unicode-width = "0.2.2"
|
||||
wayland-client = { version = "0.31.12", features = [ "log" ], optional = true }
|
||||
wayland-protocols-wlr = { version = "0.3.10", default-features = false, optional = true }
|
||||
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]
|
||||
tempfile = "3.26.0"
|
||||
futures = "0.3.32"
|
||||
tempfile = "3.27.0"
|
||||
|
||||
[features]
|
||||
default = [ "notifications", "use-toplevel" ]
|
||||
notifications = [ "dep:notify-rust" ]
|
||||
use-toplevel = [ "dep:wayland-client", "dep:wayland-protocols-wlr" ]
|
||||
use-toplevel = [ "dep:arc-swap", "dep:wayland-client", "dep:wayland-protocols-wlr" ]
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
|
|
|
|||
67
README.md
67
README.md
|
|
@ -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>
|
||||
<a href="#installation">Installation</a> | <a href="#usage">Usage</a> | <a href="#usage">Motivation</a></br>
|
||||
<a href="#tips--tricks">Tips and Tricks</a>
|
||||
<br/>
|
||||
</div>
|
||||
|
|
@ -46,21 +46,34 @@ 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`
|
||||
- Automatic clipboard monitoring with
|
||||
[`stash watch`](#watch-clipboard-for-changes-and-store-automatically)
|
||||
- Configurable auto-expiry of old entries in watch mode as a safety buffer
|
||||
- Drop-in replacement for `wl-clipboard` tools (`wl-copy` and `wl-paste`)
|
||||
- Sensitive clipboard filtering via regex (see below)
|
||||
- Sensitive clipboard filtering by application (see below)
|
||||
|
||||
See [usage section](#usage) for more details.
|
||||
on top of the existing features of Cliphist, which are as follows:
|
||||
|
||||
- Write clipboard changes to a history file.
|
||||
- Recall history with dmenu, rofi, wofi (or whatever other picker you like).
|
||||
- Both text and images are supported.
|
||||
- Clipboard is preserved byte-for-byte.
|
||||
- Leading/trailing whitespace, no whitespace, or newlines are preserved.
|
||||
- Won’t break fancy editor selections like Vim wordwise, linewise, or block
|
||||
mode.
|
||||
|
||||
Most of Stash's usage is documented in the [usage section](#usage) for more
|
||||
details. Refer to the [Tips & Tricks section](#tips--tricks) for more "advanced"
|
||||
features, or conveniences provided by Stash.
|
||||
|
||||
## Installation
|
||||
|
||||
### With Nix
|
||||
|
||||
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 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
|
||||
{
|
||||
|
|
@ -91,7 +104,8 @@ If you want to give Stash a try before you switch to it, you may also run it one
|
|||
time with `nix run`.
|
||||
|
||||
```sh
|
||||
nix run github:NotAShelf/stash -- watch # start the watch daemon
|
||||
# Run directly from the git repository; will be garbage collected
|
||||
$ nix run github:NotAShelf/stash -- watch # start the watch daemon
|
||||
```
|
||||
|
||||
### Without Nix
|
||||
|
|
@ -110,16 +124,23 @@ releases are made when a version gets tagged, and are available under
|
|||
- Build and install from source with Cargo:
|
||||
|
||||
```bash
|
||||
cargo install --git https://github.com/notashelf/stash
|
||||
cargo install stash --locked
|
||||
```
|
||||
|
||||
Additionally, you may get Stash from source via `cargo install` using
|
||||
`cargo install --git https://github.com/notashelf/stash --locked` or you may
|
||||
check out to the repository, and use Cargo to build it. You'll need Rust 1.91.0
|
||||
or above. Most distributions should package this version already. You may, of
|
||||
course, prefer to package the built releases if you'd like.
|
||||
|
||||
## Usage
|
||||
|
||||
> [!NOTE]
|
||||
> [!IMPORTANT]
|
||||
> It is not a priority to provide 1:1 backwards compatibility with Cliphist.
|
||||
> While the interface is _almost_ identical, Stash chooses to build upon
|
||||
> While the interface is generally similar, Stash chooses to build upon
|
||||
> Cliphist's design and extend existing design choices. See
|
||||
> [Migrating from Cliphist](#migrating-from-cliphist) for more details.
|
||||
> [Migrating from Cliphist](#migrating-from-cliphist) for more details. Refer to
|
||||
> help text if confused.
|
||||
|
||||
The command interface of Stash is _only slightly_ different from Cliphist. In
|
||||
most cases, you may simply replace `cliphist` with `stash` and your commands,
|
||||
|
|
@ -275,7 +296,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
|
||||
|
|
@ -299,6 +320,25 @@ 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
|
||||
|
|
@ -554,7 +594,8 @@ 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.
|
||||
the database compact, especially after deleting many entries. You can, of
|
||||
course, wipe the database entirely if it has grown too large.
|
||||
|
||||
## Attributions
|
||||
|
||||
|
|
|
|||
65
build.rs
65
build.rs
|
|
@ -1,65 +0,0 @@
|
|||
use std::{env, fs, path::Path};
|
||||
|
||||
/// List of multicall symlinks to create (name, target)
|
||||
const MULTICALL_LINKS: &[&str] =
|
||||
&["stash-copy", "stash-paste", "wl-copy", "wl-paste"];
|
||||
|
||||
/// Wayland-specific symlinks that can be disabled separately
|
||||
const WAYLAND_LINKS: &[&str] = &["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");
|
||||
|
||||
// Check for environment variables to disable symlinking
|
||||
let disable_all_symlinks = env::var("STASH_NO_SYMLINKS").is_ok();
|
||||
let disable_wayland_symlinks = env::var("STASH_NO_WL_SYMLINKS").is_ok();
|
||||
|
||||
// Create symlinks for each multicall binary
|
||||
for link in MULTICALL_LINKS {
|
||||
if disable_all_symlinks {
|
||||
println!("cargo:warning=Skipping symlink {link} (all symlinks disabled)");
|
||||
continue;
|
||||
}
|
||||
|
||||
if disable_wayland_symlinks && WAYLAND_LINKS.contains(link) {
|
||||
println!(
|
||||
"cargo:warning=Skipping symlink {link} (wayland symlinks disabled)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let link_path = bin_dir.join(link);
|
||||
// Remove existing symlink or file if present
|
||||
let _ = fs::remove_file(&link_path);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::symlink;
|
||||
match symlink(&stash_bin, &link_path) {
|
||||
Ok(()) => {
|
||||
println!(
|
||||
"cargo:warning=Created symlink: {} -> {}",
|
||||
link_path.display(),
|
||||
stash_bin.display()
|
||||
);
|
||||
},
|
||||
Err(e) => {
|
||||
println!(
|
||||
"cargo:warning=Failed to create symlink {} -> {}: {}",
|
||||
link_path.display(),
|
||||
stash_bin.display(),
|
||||
e
|
||||
);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
flake.lock
generated
12
flake.lock
generated
|
|
@ -2,11 +2,11 @@
|
|||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1766194365,
|
||||
"narHash": "sha256-4AFsUZ0kl6MXSm4BaQgItD0VGlEKR3iq7gIaL7TjBvc=",
|
||||
"lastModified": 1776635034,
|
||||
"narHash": "sha256-OEOJrT3ZfwbChzODfIH4GzlNTtOFuZFWPtW7jIeR8xU=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "7d8ec2c71771937ab99790b45e6d9b93d15d9379",
|
||||
"rev": "dc7496d8ea6e526b1254b55d09b966e94673750f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -17,11 +17,11 @@
|
|||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1766309749,
|
||||
"narHash": "sha256-3xY8CZ4rSnQ0NqGhMKAy5vgC+2IVK0NoVEzDoOh4DA4=",
|
||||
"lastModified": 1775710090,
|
||||
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "a6531044f6d0bef691ea18d4d4ce44d0daa6e816",
|
||||
"rev": "4c1018dae018162ec878d42fec712642d214fdfa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
stdenv,
|
||||
mold,
|
||||
versionCheckHook,
|
||||
useMold ? stdenv.isLinux,
|
||||
createSymlinks ? true,
|
||||
}: let
|
||||
pname = "stash";
|
||||
|
|
@ -18,7 +19,6 @@
|
|||
(fs.fileFilter (file: builtins.any file.hasExt ["rs"]) (s + /src))
|
||||
(s + /Cargo.lock)
|
||||
(s + /Cargo.toml)
|
||||
(s + /build.rs)
|
||||
];
|
||||
};
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ in
|
|||
done
|
||||
'';
|
||||
|
||||
env = lib.optionalAttrs (stdenv.isLinux && !stdenv.hostPlatform.isAarch) {
|
||||
env = lib.optionalAttrs useMold {
|
||||
CARGO_LINKER = "clang";
|
||||
CARGO_RUSTFLAGS = "-Clink-arg=-fuse-ld=${mold}/bin/mold";
|
||||
};
|
||||
|
|
|
|||
3
src/clipboard/mod.rs
Normal file
3
src/clipboard/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod persist;
|
||||
|
||||
pub use persist::{ClipboardData, get_serving_pid, persist_clipboard};
|
||||
262
src/clipboard/persist.rs
Normal file
262
src/clipboard/persist.rs
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
use std::{
|
||||
process::exit,
|
||||
sync::atomic::{AtomicI32, Ordering},
|
||||
};
|
||||
|
||||
use wl_clipboard_rs::copy::{
|
||||
ClipboardType,
|
||||
MimeType as CopyMimeType,
|
||||
Options,
|
||||
PreparedCopy,
|
||||
ServeRequests,
|
||||
Source,
|
||||
};
|
||||
|
||||
/// Maximum number of paste requests to serve before exiting. This (hopefully)
|
||||
/// prevents runaway processes while still providing persistence.
|
||||
const MAX_SERVE_REQUESTS: usize = 1000;
|
||||
|
||||
/// PID of the current clipboard persistence child process. Used to detect when
|
||||
/// clipboard content is from our own serve process.
|
||||
static SERVING_PID: AtomicI32 = AtomicI32::new(0);
|
||||
|
||||
/// Get the current serving PID if any. Used by the watch loop to avoid
|
||||
/// duplicate persistence processes.
|
||||
pub fn get_serving_pid() -> Option<i32> {
|
||||
let pid = SERVING_PID.load(Ordering::SeqCst);
|
||||
if pid != 0 { Some(pid) } else { None }
|
||||
}
|
||||
|
||||
/// Result type for persistence operations.
|
||||
pub type PersistenceResult<T> = Result<T, PersistenceError>;
|
||||
|
||||
/// Errors that can occur during clipboard persistence.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PersistenceError {
|
||||
#[error("Failed to prepare copy: {0}")]
|
||||
PrepareFailed(String),
|
||||
|
||||
#[error("Failed to fork: {0}")]
|
||||
ForkFailed(String),
|
||||
|
||||
#[error("Clipboard data too large: {0} bytes")]
|
||||
DataTooLarge(usize),
|
||||
|
||||
#[error("Clipboard content is empty")]
|
||||
EmptyContent,
|
||||
|
||||
#[error("No MIME types to offer")]
|
||||
NoMimeTypes,
|
||||
}
|
||||
|
||||
/// Clipboard data with all MIME types for persistence.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClipboardData {
|
||||
/// The actual clipboard content.
|
||||
pub content: Vec<u8>,
|
||||
|
||||
/// All MIME types offered by the source. Preserves order.
|
||||
pub mime_types: Vec<String>,
|
||||
|
||||
/// The MIME type that was selected for storage.
|
||||
pub selected_mime: String,
|
||||
}
|
||||
|
||||
impl ClipboardData {
|
||||
/// Create new clipboard data.
|
||||
pub fn new(
|
||||
content: Vec<u8>,
|
||||
mime_types: Vec<String>,
|
||||
selected_mime: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
content,
|
||||
mime_types,
|
||||
selected_mime,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if data is valid for persistence.
|
||||
pub fn is_valid(&self) -> Result<(), PersistenceError> {
|
||||
const MAX_SIZE: usize = 100 * 1024 * 1024; // 100MB
|
||||
|
||||
if self.content.is_empty() {
|
||||
return Err(PersistenceError::EmptyContent);
|
||||
}
|
||||
|
||||
if self.content.len() > MAX_SIZE {
|
||||
return Err(PersistenceError::DataTooLarge(self.content.len()));
|
||||
}
|
||||
|
||||
if self.mime_types.is_empty() {
|
||||
return Err(PersistenceError::NoMimeTypes);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist clipboard data by forking a background process that serves it.
|
||||
///
|
||||
/// 1. Prepares a clipboard copy operation with all MIME types
|
||||
/// 2. Forks a child process
|
||||
/// 3. The child serves clipboard data indefinitely (until MAX_SERVE_REQUESTS)
|
||||
/// 4. The parent returns immediately
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// This function uses `libc::fork()` which is unsafe. The child process
|
||||
/// must not modify any shared state or file descriptors.
|
||||
pub unsafe fn persist_clipboard(data: ClipboardData) -> PersistenceResult<()> {
|
||||
// Validate data
|
||||
data.is_valid()?;
|
||||
|
||||
// Prepare the copy operation
|
||||
let prepared = prepare_clipboard_copy(&data)?;
|
||||
|
||||
// Fork and serve
|
||||
unsafe { fork_and_serve(prepared) }
|
||||
}
|
||||
|
||||
/// Prepare a clipboard copy operation with all MIME types.
|
||||
fn prepare_clipboard_copy(
|
||||
data: &ClipboardData,
|
||||
) -> PersistenceResult<PreparedCopy> {
|
||||
let mut opts = Options::new();
|
||||
opts.clipboard(ClipboardType::Regular);
|
||||
opts.serve_requests(ServeRequests::Only(MAX_SERVE_REQUESTS));
|
||||
opts.foreground(true); // we'll fork manually for better control
|
||||
|
||||
// Determine MIME type for the primary offer
|
||||
let mime_type = if data.selected_mime.starts_with("text/") {
|
||||
CopyMimeType::Text
|
||||
} else {
|
||||
CopyMimeType::Specific(data.selected_mime.clone())
|
||||
};
|
||||
|
||||
// Prepare the copy
|
||||
let prepared = opts
|
||||
.prepare_copy(Source::Bytes(data.content.clone().into()), mime_type)
|
||||
.map_err(|e| PersistenceError::PrepareFailed(e.to_string()))?;
|
||||
|
||||
Ok(prepared)
|
||||
}
|
||||
|
||||
/// Fork a child process to serve clipboard data.
|
||||
///
|
||||
/// The child process will:
|
||||
///
|
||||
/// 1. Register its process ID with the self-detection module
|
||||
/// 2. Serve clipboard requests until MAX_SERVE_REQUESTS
|
||||
/// 3. Exit cleanly
|
||||
///
|
||||
/// The parent stores the child `PID` in `SERVING_PID` and returns immediately.
|
||||
unsafe fn fork_and_serve(prepared: PreparedCopy) -> PersistenceResult<()> {
|
||||
// Enable automatic child reaping to prevent zombie processes
|
||||
unsafe {
|
||||
libc::signal(libc::SIGCHLD, libc::SIG_IGN);
|
||||
}
|
||||
|
||||
match unsafe { libc::fork() } {
|
||||
0 => {
|
||||
// Child process - clear serving PID
|
||||
// Look at me. I'm the server now.
|
||||
SERVING_PID.store(0, Ordering::SeqCst);
|
||||
serve_clipboard_child(prepared);
|
||||
exit(0);
|
||||
},
|
||||
|
||||
-1 => {
|
||||
// Oops.
|
||||
Err(PersistenceError::ForkFailed(
|
||||
"libc::fork() returned -1".to_string(),
|
||||
))
|
||||
},
|
||||
|
||||
pid => {
|
||||
// Parent process, store child PID for loop detection
|
||||
log::debug!("forked clipboard persistence process (pid: {pid})");
|
||||
SERVING_PID.store(pid, Ordering::SeqCst);
|
||||
Ok(())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Child process entry point for serving clipboard data.
|
||||
fn serve_clipboard_child(prepared: PreparedCopy) {
|
||||
let pid = std::process::id() as i32;
|
||||
log::debug!("clipboard persistence child process started (pid: {pid})");
|
||||
|
||||
// Serve clipboard requests. The PreparedCopy::serve() method blocks and
|
||||
// handles all the Wayland protocol interactions internally via
|
||||
// wl-clipboard-rs
|
||||
match prepared.serve() {
|
||||
Ok(()) => {
|
||||
log::debug!("clipboard persistence: serve completed normally");
|
||||
},
|
||||
|
||||
Err(e) => {
|
||||
log::error!("clipboard persistence: serve failed: {e}");
|
||||
exit(1);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_data_validation() {
|
||||
// Valid data
|
||||
let valid = ClipboardData::new(
|
||||
b"hello".to_vec(),
|
||||
vec!["text/plain".to_string()],
|
||||
"text/plain".to_string(),
|
||||
);
|
||||
assert!(valid.is_valid().is_ok());
|
||||
|
||||
// Empty content
|
||||
let empty = ClipboardData::new(
|
||||
vec![],
|
||||
vec!["text/plain".to_string()],
|
||||
"text/plain".to_string(),
|
||||
);
|
||||
assert!(matches!(
|
||||
empty.is_valid(),
|
||||
Err(PersistenceError::EmptyContent)
|
||||
));
|
||||
|
||||
// No MIME types
|
||||
let no_mimes =
|
||||
ClipboardData::new(b"hello".to_vec(), vec![], "text/plain".to_string());
|
||||
assert!(matches!(
|
||||
no_mimes.is_valid(),
|
||||
Err(PersistenceError::NoMimeTypes)
|
||||
));
|
||||
|
||||
// Too large
|
||||
let huge = ClipboardData::new(
|
||||
vec![0u8; 101 * 1024 * 1024], // 101MB
|
||||
vec!["text/plain".to_string()],
|
||||
"text/plain".to_string(),
|
||||
);
|
||||
assert!(matches!(
|
||||
huge.is_valid(),
|
||||
Err(PersistenceError::DataTooLarge(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clipboard_data_creation() {
|
||||
let data = ClipboardData::new(
|
||||
b"test content".to_vec(),
|
||||
vec!["text/plain".to_string(), "text/html".to_string()],
|
||||
"text/plain".to_string(),
|
||||
);
|
||||
|
||||
assert_eq!(data.content, b"test content");
|
||||
assert_eq!(data.mime_types.len(), 2);
|
||||
assert_eq!(data.selected_mime, "text/plain");
|
||||
}
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ impl DecodeCommand for SqliteClipboardDb {
|
|||
|
||||
// If input is empty or whitespace, treat as error and trigger fallback
|
||||
if input_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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ pub trait ListCommand {
|
|||
out: impl Write,
|
||||
preview_width: u32,
|
||||
include_expired: bool,
|
||||
reverse: bool,
|
||||
) -> Result<(), StashError>;
|
||||
}
|
||||
|
||||
|
|
@ -20,9 +21,10 @@ 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)
|
||||
.list_entries(out, preview_width, include_expired, reverse)
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
|
@ -52,6 +54,12 @@ struct TuiState {
|
|||
|
||||
/// 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 {
|
||||
|
|
@ -61,6 +69,7 @@ impl TuiState {
|
|||
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 {
|
||||
|
|
@ -70,6 +79,7 @@ impl TuiState {
|
|||
window_size,
|
||||
preview_width,
|
||||
None,
|
||||
reverse,
|
||||
)?
|
||||
} else {
|
||||
Vec::new()
|
||||
|
|
@ -83,6 +93,8 @@ impl TuiState {
|
|||
dirty: false,
|
||||
search_query: String::new(),
|
||||
search_mode: false,
|
||||
reverse,
|
||||
copying_entry: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -228,6 +240,7 @@ impl TuiState {
|
|||
self.window_size,
|
||||
preview_width,
|
||||
search,
|
||||
self.reverse,
|
||||
)?
|
||||
} else {
|
||||
Vec::new()
|
||||
|
|
@ -266,6 +279,7 @@ impl SqliteClipboardDb {
|
|||
&self,
|
||||
preview_width: u32,
|
||||
include_expired: bool,
|
||||
reverse: bool,
|
||||
) -> Result<(), StashError> {
|
||||
use std::io::stdout;
|
||||
|
||||
|
|
@ -316,8 +330,13 @@ impl SqliteClipboardDb {
|
|||
.unwrap_or(24);
|
||||
let initial_height = initial_height.max(1);
|
||||
|
||||
let mut tui =
|
||||
TuiState::new(self, include_expired, initial_height, preview_width)?;
|
||||
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();
|
||||
|
|
@ -393,7 +412,7 @@ impl SqliteClipboardDb {
|
|||
},
|
||||
(KeyCode::Enter, _) => actions.copy = true,
|
||||
(KeyCode::Char('D'), KeyModifiers::SHIFT) => {
|
||||
actions.delete = true
|
||||
actions.delete = true;
|
||||
},
|
||||
(KeyCode::Char('/'), _) => actions.toggle_search = true,
|
||||
_ => {},
|
||||
|
|
@ -663,42 +682,51 @@ impl SqliteClipboardDb {
|
|||
if actions.copy
|
||||
&& let Some(&(id, ..)) = tui.selected_entry()
|
||||
{
|
||||
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().to_owned()),
|
||||
None => MimeType::Text,
|
||||
};
|
||||
let copy_result = opts
|
||||
.copy(Source::Bytes(contents.clone().into()), mime_type);
|
||||
match copy_result {
|
||||
Ok(()) => {
|
||||
let _ = Notification::new()
|
||||
.summary("Stash")
|
||||
.body("Copied entry to clipboard")
|
||||
.show();
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to copy entry to clipboard: {e}");
|
||||
let _ = Notification::new()
|
||||
.summary("Stash")
|
||||
.body(&format!("Failed to copy to clipboard: {e}"))
|
||||
.show();
|
||||
},
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to fetch entry {id}: {e}");
|
||||
let _ = Notification::new()
|
||||
.summary("Stash")
|
||||
.body(&format!("Failed to fetch entry: {e}"))
|
||||
.show();
|
||||
},
|
||||
if tui.copying_entry == Some(id) {
|
||||
log::debug!(
|
||||
"Skipping duplicate copy for entry {id} (already in \
|
||||
progress)"
|
||||
);
|
||||
} else {
|
||||
tui.copying_entry = Some(id);
|
||||
match self.copy_entry(id) {
|
||||
Ok((new_id, contents, mime)) => {
|
||||
if new_id != id {
|
||||
tui.dirty = true;
|
||||
}
|
||||
let opts = Options::new();
|
||||
let mime_type = match mime {
|
||||
Some(ref m) if m == "text/plain" => MimeType::Text,
|
||||
Some(ref m) => MimeType::Specific(m.clone().clone()),
|
||||
None => MimeType::Text,
|
||||
};
|
||||
let copy_result = opts
|
||||
.copy(Source::Bytes(contents.clone().into()), mime_type);
|
||||
match copy_result {
|
||||
Ok(()) => {
|
||||
let _ = Notification::new()
|
||||
.summary("Stash")
|
||||
.body("Copied entry to clipboard")
|
||||
.show();
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("failed to copy entry to clipboard: {e}");
|
||||
let _ = Notification::new()
|
||||
.summary("Stash")
|
||||
.body(&format!("Failed to copy to clipboard: {e}"))
|
||||
.show();
|
||||
},
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("failed to fetch entry {id}: {e}");
|
||||
let _ = Notification::new()
|
||||
.summary("Stash")
|
||||
.body(&format!("Failed to fetch entry: {e}"))
|
||||
.show();
|
||||
},
|
||||
}
|
||||
tui.copying_entry = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,4 +5,3 @@ pub mod list;
|
|||
pub mod query;
|
||||
pub mod store;
|
||||
pub mod watch;
|
||||
pub mod wipe;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use std::io::Read;
|
|||
|
||||
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub trait StoreCommand {
|
||||
fn store(
|
||||
&self,
|
||||
|
|
@ -10,6 +11,8 @@ pub trait StoreCommand {
|
|||
max_items: u64,
|
||||
state: Option<String>,
|
||||
excluded_apps: &[String],
|
||||
min_size: Option<usize>,
|
||||
max_size: usize,
|
||||
) -> Result<(), crate::db::StashError>;
|
||||
}
|
||||
|
||||
|
|
@ -21,18 +24,24 @@ 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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,4 @@
|
|||
use std::{
|
||||
collections::{BinaryHeap, hash_map::DefaultHasher},
|
||||
hash::{Hash, Hasher},
|
||||
io::Read,
|
||||
time::Duration,
|
||||
};
|
||||
use std::{collections::BinaryHeap, hash::Hasher, io::Read, time::Duration};
|
||||
|
||||
use smol::Timer;
|
||||
use wl_clipboard_rs::{
|
||||
|
|
@ -17,7 +12,11 @@ use wl_clipboard_rs::{
|
|||
},
|
||||
};
|
||||
|
||||
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
||||
use crate::{
|
||||
clipboard::{self, ClipboardData, get_serving_pid},
|
||||
db::{SqliteClipboardDb, nonblocking::AsyncClipboardDb},
|
||||
hash::Fnv1aHasher,
|
||||
};
|
||||
|
||||
/// Wrapper to provide [`Ord`] implementation for `f64` by negating values.
|
||||
/// This allows [`BinaryHeap`], which is a max-heap, to function as a min-heap.
|
||||
|
|
@ -59,7 +58,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)>,
|
||||
|
|
@ -97,6 +96,16 @@ 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.
|
||||
|
|
@ -118,21 +127,29 @@ 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), wl_clipboard_rs::paste::Error> {
|
||||
) -> 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));
|
||||
return Ok((Box::new(reader) as Box<dyn Read>, mime_str, offered));
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -169,235 +186,286 @@ fn negotiate_mime_type(
|
|||
Seat::Unspecified,
|
||||
PasteMimeType::Specific(mime_str),
|
||||
)?;
|
||||
Ok((Box::new(reader) as Box<dyn Read>, actual_mime))
|
||||
|
||||
Ok((Box::new(reader) as Box<dyn Read>, actual_mime, offered))
|
||||
},
|
||||
None => Err(wl_clipboard_rs::paste::Error::NoSeats),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub trait WatchCommand {
|
||||
fn watch(
|
||||
async fn watch(
|
||||
&self,
|
||||
max_dedupe_search: u64,
|
||||
max_items: u64,
|
||||
excluded_apps: &[String],
|
||||
expire_after: Option<Duration>,
|
||||
mime_type_preference: &str,
|
||||
min_size: Option<usize>,
|
||||
max_size: usize,
|
||||
persist: bool,
|
||||
);
|
||||
}
|
||||
|
||||
impl WatchCommand for SqliteClipboardDb {
|
||||
fn watch(
|
||||
async fn watch(
|
||||
&self,
|
||||
max_dedupe_search: u64,
|
||||
max_items: u64,
|
||||
excluded_apps: &[String],
|
||||
expire_after: Option<Duration>,
|
||||
mime_type_preference: &str,
|
||||
min_size: Option<usize>,
|
||||
max_size: usize,
|
||||
persist: bool,
|
||||
) {
|
||||
smol::block_on(async {
|
||||
log::info!(
|
||||
"Starting clipboard watch daemon with MIME type preference: \
|
||||
{mime_type_preference}"
|
||||
);
|
||||
let async_db = AsyncClipboardDb::new(self.db_path.clone());
|
||||
log::info!(
|
||||
"Starting clipboard watch daemon with MIME type preference: \
|
||||
{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))
|
||||
{
|
||||
// Skip first entry which is already added
|
||||
if exp_queue
|
||||
.heap
|
||||
.iter()
|
||||
.any(|(_, existing_id)| *existing_id == row_id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
exp_queue.push(exp, row_id);
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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 mut current_buf = Vec::new();
|
||||
if reader.read_to_end(&mut current_buf).is_ok()
|
||||
&& !current_buf.is_empty()
|
||||
{
|
||||
let current_hash = hash_contents(¤t_buf);
|
||||
// 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,
|
||||
let current_hash = hash_contents(¤t_buf);
|
||||
// Convert stored i64 to u64 for comparison (preserves bit
|
||||
// pattern)
|
||||
if current_hash == stored_hash as u64 {
|
||||
// Clear the clipboard since expired content is still
|
||||
// there
|
||||
let mut opts = Options::new();
|
||||
opts
|
||||
.clipboard(wl_clipboard_rs::copy::ClipboardType::Regular);
|
||||
if opts
|
||||
.copy(
|
||||
Source::Bytes(Vec::new().into()),
|
||||
CopyMimeType::Autodetect,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
log::info!(
|
||||
"cleared clipboard containing expired entry {id}"
|
||||
);
|
||||
last_hash = None; // reset tracked hash
|
||||
} else {
|
||||
log::warn!(
|
||||
"failed to clear clipboard for expired entry {id}"
|
||||
);
|
||||
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
|
||||
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;
|
||||
}
|
||||
// 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) {
|
||||
match self.store_entry(
|
||||
&buf[..],
|
||||
// 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),
|
||||
) {
|
||||
Ok(id) => {
|
||||
log::info!("Stored new clipboard entry (id: {id})");
|
||||
last_hash = Some(current_hash);
|
||||
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);
|
||||
|
||||
// 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();
|
||||
// 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(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}");
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Normal poll interval (only if no expirations pending)
|
||||
if exp_queue.peek_next().is_none() {
|
||||
Timer::after(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
},
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unit-testable helper: given ordered offers and a preference, return the
|
||||
/// 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)]
|
||||
|
|
@ -500,4 +568,145 @@ 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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
|
||||
|
||||
pub trait WipeCommand {
|
||||
fn wipe(&self) -> Result<(), StashError>;
|
||||
}
|
||||
|
||||
impl WipeCommand for SqliteClipboardDb {
|
||||
fn wipe(&self) -> Result<(), StashError> {
|
||||
self.wipe_db()?;
|
||||
log::info!("Database wiped");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
952
src/db/mod.rs
952
src/db/mod.rs
File diff suppressed because it is too large
Load diff
375
src/db/nonblocking.rs
Normal file
375
src/db/nonblocking.rs
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use rusqlite::OptionalExtension;
|
||||
|
||||
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
|
||||
|
||||
/// Async wrapper for database operations that runs blocking operations
|
||||
/// on a thread pool to avoid blocking the async runtime. Since
|
||||
/// [`rusqlite::Connection`] is not Send, we store the database path and open a
|
||||
/// new connection for each operation.
|
||||
pub struct AsyncClipboardDb {
|
||||
db_path: PathBuf,
|
||||
}
|
||||
|
||||
impl AsyncClipboardDb {
|
||||
pub fn new(db_path: PathBuf) -> Self {
|
||||
Self { db_path }
|
||||
}
|
||||
|
||||
#[expect(clippy::too_many_arguments)]
|
||||
pub async fn store_entry(
|
||||
&self,
|
||||
data: Vec<u8>,
|
||||
max_dedupe_search: u64,
|
||||
max_items: u64,
|
||||
excluded_apps: Option<Vec<String>>,
|
||||
min_size: Option<usize>,
|
||||
max_size: usize,
|
||||
content_hash: Option<i64>,
|
||||
mime_types: Option<Vec<String>>,
|
||||
) -> Result<i64, StashError> {
|
||||
let path = self.db_path.clone();
|
||||
blocking::unblock(move || {
|
||||
let db = Self::open_db_internal(&path)?;
|
||||
db.store_entry(
|
||||
std::io::Cursor::new(data),
|
||||
max_dedupe_search,
|
||||
max_items,
|
||||
excluded_apps.as_deref(),
|
||||
min_size,
|
||||
max_size,
|
||||
content_hash,
|
||||
mime_types.as_deref(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_expiration(
|
||||
&self,
|
||||
id: i64,
|
||||
expires_at: f64,
|
||||
) -> Result<(), StashError> {
|
||||
let path = self.db_path.clone();
|
||||
blocking::unblock(move || {
|
||||
let db = Self::open_db_internal(&path)?;
|
||||
db.set_expiration(id, expires_at)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn load_all_expirations(
|
||||
&self,
|
||||
) -> Result<Vec<(f64, i64)>, StashError> {
|
||||
let path = self.db_path.clone();
|
||||
blocking::unblock(move || {
|
||||
let db = Self::open_db_internal(&path)?;
|
||||
let mut stmt = db
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT NULL \
|
||||
AND (is_expired IS NULL OR is_expired = 0) ORDER BY expires_at ASC",
|
||||
)
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
|
||||
let mut rows = stmt
|
||||
.query([])
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
let mut expirations = Vec::new();
|
||||
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
|
||||
{
|
||||
let exp = row
|
||||
.get::<_, f64>(0)
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
let id = row
|
||||
.get::<_, i64>(1)
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
expirations.push((exp, id));
|
||||
}
|
||||
Ok(expirations)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_content_hash(
|
||||
&self,
|
||||
id: i64,
|
||||
) -> Result<Option<i64>, StashError> {
|
||||
let path = self.db_path.clone();
|
||||
blocking::unblock(move || {
|
||||
let db = Self::open_db_internal(&path)?;
|
||||
let result: Option<i64> = db
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT content_hash FROM clipboard WHERE id = ?1",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn mark_expired(&self, id: i64) -> Result<(), StashError> {
|
||||
let path = self.db_path.clone();
|
||||
blocking::unblock(move || {
|
||||
let db = Self::open_db_internal(&path)?;
|
||||
db.conn
|
||||
.execute("UPDATE clipboard SET is_expired = 1 WHERE id = ?1", [id])
|
||||
.map_err(|e| StashError::Store(e.to_string().into()))?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn open_db_internal(path: &PathBuf) -> Result<SqliteClipboardDb, StashError> {
|
||||
let conn = rusqlite::Connection::open(path).map_err(|e| {
|
||||
StashError::Store(format!("Failed to open database: {e}").into())
|
||||
})?;
|
||||
SqliteClipboardDb::new(conn, path.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for AsyncClipboardDb {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
db_path: self.db_path.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{collections::HashSet, hash::Hasher};
|
||||
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::*;
|
||||
use crate::hash::Fnv1aHasher;
|
||||
|
||||
fn setup_test_db() -> (AsyncClipboardDb, tempfile::TempDir) {
|
||||
let temp_dir = tempdir().expect("Failed to create temp dir");
|
||||
let db_path = temp_dir.path().join("test.db");
|
||||
|
||||
// Create initial database
|
||||
{
|
||||
let conn =
|
||||
rusqlite::Connection::open(&db_path).expect("Failed to open database");
|
||||
crate::db::SqliteClipboardDb::new(conn, db_path.clone())
|
||||
.expect("Failed to create database");
|
||||
}
|
||||
|
||||
let async_db = AsyncClipboardDb::new(db_path);
|
||||
(async_db, temp_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_async_store_entry() {
|
||||
smol::block_on(async {
|
||||
let (async_db, _temp_dir) = setup_test_db();
|
||||
let data = b"async test data";
|
||||
|
||||
let id = async_db
|
||||
.store_entry(
|
||||
data.to_vec(),
|
||||
100,
|
||||
1000,
|
||||
None,
|
||||
None,
|
||||
5_000_000,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to store entry");
|
||||
|
||||
assert!(id > 0, "Should return positive id");
|
||||
|
||||
// Verify it was stored by checking content hash
|
||||
let hash = async_db
|
||||
.get_content_hash(id)
|
||||
.await
|
||||
.expect("Failed to get hash")
|
||||
.expect("Hash should exist");
|
||||
|
||||
// Calculate expected hash
|
||||
let mut hasher = Fnv1aHasher::new();
|
||||
hasher.write(data);
|
||||
let expected_hash = hasher.finish() as i64;
|
||||
|
||||
assert_eq!(hash, expected_hash, "Stored hash should match");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_async_set_expiration_and_load() {
|
||||
smol::block_on(async {
|
||||
let (async_db, _temp_dir) = setup_test_db();
|
||||
let data = b"expiring entry";
|
||||
|
||||
let id = async_db
|
||||
.store_entry(
|
||||
data.to_vec(),
|
||||
100,
|
||||
1000,
|
||||
None,
|
||||
None,
|
||||
5_000_000,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to store entry");
|
||||
|
||||
let expires_at = 1234567890.5;
|
||||
async_db
|
||||
.set_expiration(id, expires_at)
|
||||
.await
|
||||
.expect("Failed to set expiration");
|
||||
|
||||
// Load all expirations
|
||||
let expirations = async_db
|
||||
.load_all_expirations()
|
||||
.await
|
||||
.expect("Failed to load expirations");
|
||||
|
||||
assert_eq!(expirations.len(), 1, "Should have one expiration");
|
||||
assert!(
|
||||
(expirations[0].0 - expires_at).abs() < 0.001,
|
||||
"Expiration time should match"
|
||||
);
|
||||
assert_eq!(expirations[0].1, id, "Expiration id should match");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_async_mark_expired() {
|
||||
smol::block_on(async {
|
||||
let (async_db, _temp_dir) = setup_test_db();
|
||||
let data = b"entry to expire";
|
||||
|
||||
let id = async_db
|
||||
.store_entry(
|
||||
data.to_vec(),
|
||||
100,
|
||||
1000,
|
||||
None,
|
||||
None,
|
||||
5_000_000,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("Failed to store entry");
|
||||
|
||||
async_db
|
||||
.mark_expired(id)
|
||||
.await
|
||||
.expect("Failed to mark as expired");
|
||||
|
||||
// Load expirations, this should be empty since entry is now marked
|
||||
// expired
|
||||
let expirations = async_db
|
||||
.load_all_expirations()
|
||||
.await
|
||||
.expect("Failed to load expirations");
|
||||
|
||||
assert!(
|
||||
expirations.is_empty(),
|
||||
"Expired entries should not be loaded"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_async_get_content_hash_not_found() {
|
||||
smol::block_on(async {
|
||||
let (async_db, _temp_dir) = setup_test_db();
|
||||
|
||||
let hash = async_db
|
||||
.get_content_hash(999999)
|
||||
.await
|
||||
.expect("Should not fail on non-existent entry");
|
||||
|
||||
assert!(hash.is_none(), "Hash should be None for non-existent entry");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_async_clone() {
|
||||
let (async_db, _temp_dir) = setup_test_db();
|
||||
let cloned = async_db.clone();
|
||||
|
||||
smol::block_on(async {
|
||||
// Both should work independently
|
||||
let data = b"clone test";
|
||||
|
||||
let id1 = async_db
|
||||
.store_entry(
|
||||
data.to_vec(),
|
||||
100,
|
||||
1000,
|
||||
None,
|
||||
None,
|
||||
5_000_000,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("Failed with original");
|
||||
|
||||
let id2 = cloned
|
||||
.store_entry(
|
||||
data.to_vec(),
|
||||
100,
|
||||
1000,
|
||||
None,
|
||||
None,
|
||||
5_000_000,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("Failed with clone");
|
||||
|
||||
assert_ne!(id1, id2, "Should store as separate entries");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_async_concurrent_operations() {
|
||||
smol::block_on(async {
|
||||
let (async_db, _temp_dir) = setup_test_db();
|
||||
|
||||
// Spawn multiple concurrent store operations
|
||||
let futures: Vec<_> = (0..5)
|
||||
.map(|i| {
|
||||
let db = async_db.clone();
|
||||
let data = format!("concurrent test {}", i).into_bytes();
|
||||
smol::spawn(async move {
|
||||
db.store_entry(data, 100, 1000, None, None, 5_000_000, None, None)
|
||||
.await
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results: Result<Vec<_>, _> = futures::future::join_all(futures)
|
||||
.await
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let ids = results.expect("All stores should succeed");
|
||||
assert_eq!(ids.len(), 5, "Should have 5 entries");
|
||||
|
||||
// All IDs should be unique
|
||||
let unique_ids: HashSet<_> = ids.iter().collect();
|
||||
assert_eq!(unique_ids.len(), 5, "All IDs should be unique");
|
||||
});
|
||||
}
|
||||
}
|
||||
101
src/hash.rs
Normal file
101
src/hash.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/// FNV-1a hasher for deterministic hashing across process runs.
|
||||
///
|
||||
/// Unlike `std::collections::hash_map::DefaultHasher` (which uses SipHash
|
||||
/// with a random seed), this produces stable hashes suitable for persistent
|
||||
/// storage and cross-process comparison.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use std::hash::Hasher;
|
||||
///
|
||||
/// use stash::hash::Fnv1aHasher;
|
||||
///
|
||||
/// let mut hasher = Fnv1aHasher::new();
|
||||
/// hasher.write(b"hello");
|
||||
/// let hash = hasher.finish();
|
||||
/// ```
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Fnv1aHasher {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl Fnv1aHasher {
|
||||
const FNV_OFFSET: u64 = 0xCBF29CE484222325;
|
||||
const FNV_PRIME: u64 = 0x100000001B3;
|
||||
|
||||
/// Creates a new hasher initialized with the FNV-1a offset basis.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Self::FNV_OFFSET,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Fnv1aHasher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::hash::Hasher for Fnv1aHasher {
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
for byte in bytes {
|
||||
self.state ^= u64::from(*byte);
|
||||
self.state = self.state.wrapping_mul(Self::FNV_PRIME);
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&self) -> u64 {
|
||||
self.state
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::hash::Hasher;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_fnv1a_basic() {
|
||||
let mut hasher = Fnv1aHasher::new();
|
||||
hasher.write(b"hello");
|
||||
// FNV-1a hash for "hello" (little-endian u64)
|
||||
assert_eq!(hasher.finish(), 0xA430D84680AABD0B);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fnv1a_empty() {
|
||||
let hasher = Fnv1aHasher::new();
|
||||
// Empty input should return offset basis
|
||||
assert_eq!(hasher.finish(), Fnv1aHasher::FNV_OFFSET);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fnv1a_deterministic() {
|
||||
// Same input must produce same hash
|
||||
let mut h1 = Fnv1aHasher::new();
|
||||
let mut h2 = Fnv1aHasher::new();
|
||||
h1.write(b"test data");
|
||||
h2.write(b"test data");
|
||||
assert_eq!(h1.finish(), h2.finish());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_trait() {
|
||||
let h1 = Fnv1aHasher::new();
|
||||
let h2 = Fnv1aHasher::default();
|
||||
assert_eq!(h1.finish(), h2.finish());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_copy_trait() {
|
||||
let mut hasher = Fnv1aHasher::new();
|
||||
hasher.write(b"data");
|
||||
let copied = hasher;
|
||||
// Both should have same state after copy
|
||||
assert_eq!(hasher.finish(), copied.finish());
|
||||
}
|
||||
}
|
||||
173
src/main.rs
173
src/main.rs
|
|
@ -1,3 +1,10 @@
|
|||
mod clipboard;
|
||||
mod commands;
|
||||
mod db;
|
||||
mod hash;
|
||||
mod mime;
|
||||
mod multicall;
|
||||
|
||||
use std::{
|
||||
env,
|
||||
io::{self, IsTerminal},
|
||||
|
|
@ -6,24 +13,27 @@ use std::{
|
|||
};
|
||||
|
||||
use clap::{CommandFactory, Parser, Subcommand};
|
||||
use color_eyre::eyre;
|
||||
use humantime::parse_duration;
|
||||
use inquire::Confirm;
|
||||
|
||||
mod commands;
|
||||
pub(crate) mod db;
|
||||
pub(crate) mod mime;
|
||||
mod multicall;
|
||||
// While the module is named "wayland", the Wayland module is *strictly* for the
|
||||
// use-toplevel feature as it requires some low-level wayland crates that are
|
||||
// not required *by default*. The module is named that way because "toplevel"
|
||||
// sounded too silly. Stash is strictly a Wayland clipboard manager.
|
||||
#[cfg(feature = "use-toplevel")] mod wayland;
|
||||
|
||||
use crate::commands::{
|
||||
decode::DecodeCommand,
|
||||
delete::DeleteCommand,
|
||||
import::ImportCommand,
|
||||
list::ListCommand,
|
||||
query::QueryCommand,
|
||||
store::StoreCommand,
|
||||
watch::WatchCommand,
|
||||
wipe::WipeCommand,
|
||||
use crate::{
|
||||
commands::{
|
||||
decode::DecodeCommand,
|
||||
delete::DeleteCommand,
|
||||
import::ImportCommand,
|
||||
list::ListCommand,
|
||||
query::QueryCommand,
|
||||
store::StoreCommand,
|
||||
watch::WatchCommand,
|
||||
},
|
||||
db::{ClipboardDb, DEFAULT_MAX_ENTRY_SIZE},
|
||||
};
|
||||
|
||||
#[derive(Parser)]
|
||||
|
|
@ -42,6 +52,16 @@ 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)]
|
||||
|
|
@ -78,6 +98,10 @@ 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
|
||||
|
|
@ -99,16 +123,6 @@ 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)]
|
||||
|
|
@ -135,6 +149,10 @@ 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,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -171,9 +189,27 @@ fn report_error<T>(
|
|||
}
|
||||
}
|
||||
|
||||
fn confirm(prompt: &str) -> bool {
|
||||
Confirm::new(prompt)
|
||||
.with_default(false)
|
||||
.prompt()
|
||||
.unwrap_or_else(|e| {
|
||||
log::error!("confirmation prompt failed: {e}");
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)] // whatever
|
||||
fn main() -> color_eyre::eyre::Result<()> {
|
||||
fn main() -> eyre::Result<()> {
|
||||
color_eyre::install()?;
|
||||
|
||||
// Check if we're being called as a multicall binary
|
||||
//
|
||||
// NOTE: We cannot use clap's multicall here because it requires the main
|
||||
// command to have no arguments (only subcommands), but our Cli has global
|
||||
// arguments like --max-items, --db-path, etc. Instead, we manually detect
|
||||
// the invocation name and route appropriately. While this is ugly, it's
|
||||
// seemingly the only option.
|
||||
let program_name = env::args().next().map(|s| {
|
||||
PathBuf::from(s)
|
||||
.file_name()
|
||||
|
|
@ -199,19 +235,25 @@ fn main() -> color_eyre::eyre::Result<()> {
|
|||
.filter_level(cli.verbosity.into())
|
||||
.init();
|
||||
|
||||
let db_path = cli.db_path.unwrap_or_else(|| {
|
||||
dirs::cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join("stash")
|
||||
.join("db")
|
||||
});
|
||||
let db_path = match cli.db_path {
|
||||
Some(path) => path,
|
||||
None => {
|
||||
let cache_dir = dirs::cache_dir().ok_or_else(|| {
|
||||
eyre::eyre!(
|
||||
"Could not determine cache directory. Set --db-path or \
|
||||
$STASH_DB_PATH explicitly."
|
||||
)
|
||||
})?;
|
||||
cache_dir.join("stash").join("db")
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(parent) = db_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let conn = rusqlite::Connection::open(&db_path)?;
|
||||
let db = db::SqliteClipboardDb::new(conn)?;
|
||||
let db = db::SqliteClipboardDb::new(conn, db_path)?;
|
||||
|
||||
match cli.command {
|
||||
Some(Command::Store) => {
|
||||
|
|
@ -226,20 +268,26 @@ fn main() -> color_eyre::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 }) => {
|
||||
Some(Command::List {
|
||||
format,
|
||||
expired,
|
||||
reverse,
|
||||
}) => {
|
||||
match format.as_deref() {
|
||||
Some("tsv") => {
|
||||
report_error(
|
||||
db.list(io::stdout(), cli.preview_width, expired),
|
||||
db.list(io::stdout(), cli.preview_width, expired, reverse),
|
||||
"failed to list entries",
|
||||
);
|
||||
},
|
||||
Some("json") => {
|
||||
match db.list_json(expired) {
|
||||
match db.list_json(expired, reverse) {
|
||||
Ok(json) => {
|
||||
println!("{json}");
|
||||
},
|
||||
|
|
@ -254,12 +302,12 @@ fn main() -> color_eyre::eyre::Result<()> {
|
|||
None => {
|
||||
if std::io::stdout().is_terminal() {
|
||||
report_error(
|
||||
db.list_tui(cli.preview_width, expired),
|
||||
db.list_tui(cli.preview_width, expired, reverse),
|
||||
"failed to list entries in TUI",
|
||||
);
|
||||
} else {
|
||||
report_error(
|
||||
db.list(io::stdout(), cli.preview_width, expired),
|
||||
db.list(io::stdout(), cli.preview_width, expired, reverse),
|
||||
"failed to list entries",
|
||||
);
|
||||
}
|
||||
|
|
@ -276,10 +324,7 @@ fn main() -> color_eyre::eyre::Result<()> {
|
|||
let mut should_proceed = true;
|
||||
if ask {
|
||||
should_proceed =
|
||||
Confirm::new("Are you sure you want to delete clipboard entries?")
|
||||
.with_default(false)
|
||||
.prompt()
|
||||
.unwrap_or(false);
|
||||
confirm("Are you sure you want to delete clipboard entries?");
|
||||
|
||||
if !should_proceed {
|
||||
log::info!("aborted by user.");
|
||||
|
|
@ -330,27 +375,6 @@ fn main() -> color_eyre::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 {
|
||||
|
|
@ -362,10 +386,7 @@ fn main() -> color_eyre::eyre::Result<()> {
|
|||
} else {
|
||||
"Are you sure you want to wipe ALL clipboard history?"
|
||||
};
|
||||
should_proceed = Confirm::new(message)
|
||||
.with_default(false)
|
||||
.prompt()
|
||||
.unwrap_or(false);
|
||||
should_proceed = confirm(message);
|
||||
if !should_proceed {
|
||||
log::info!("db wipe command aborted by user.");
|
||||
}
|
||||
|
|
@ -374,21 +395,21 @@ fn main() -> color_eyre::eyre::Result<()> {
|
|||
if expired {
|
||||
match db.cleanup_expired() {
|
||||
Ok(count) => {
|
||||
log::info!("Wiped {} expired entries", count);
|
||||
log::info!("wiped {count} expired entries");
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("failed to wipe expired entries: {e}");
|
||||
},
|
||||
}
|
||||
} else {
|
||||
report_error(db.wipe(), "failed to wipe database");
|
||||
report_error(db.wipe_db(), "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}");
|
||||
|
|
@ -398,7 +419,7 @@ fn main() -> color_eyre::eyre::Result<()> {
|
|||
DbAction::Stats => {
|
||||
match db.stats() {
|
||||
Ok(stats) => {
|
||||
println!("{}", stats);
|
||||
println!("{stats}");
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("failed to get database stats: {e}");
|
||||
|
|
@ -411,13 +432,10 @@ fn main() -> color_eyre::eyre::Result<()> {
|
|||
Some(Command::Import { r#type, ask }) => {
|
||||
let mut should_proceed = true;
|
||||
if ask {
|
||||
should_proceed = Confirm::new(
|
||||
should_proceed = confirm(
|
||||
"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.");
|
||||
}
|
||||
|
|
@ -441,6 +459,7 @@ fn main() -> color_eyre::eyre::Result<()> {
|
|||
Some(Command::Watch {
|
||||
expire_after,
|
||||
mime_type,
|
||||
persist,
|
||||
}) => {
|
||||
db.watch(
|
||||
cli.max_dedupe_search,
|
||||
|
|
@ -451,7 +470,11 @@ fn main() -> color_eyre::eyre::Result<()> {
|
|||
&[],
|
||||
expire_after,
|
||||
&mime_type,
|
||||
);
|
||||
cli.min_size,
|
||||
cli.max_size,
|
||||
persist,
|
||||
)
|
||||
.await;
|
||||
},
|
||||
|
||||
None => {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
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"
|
||||
} else {
|
||||
// If no MIME type, check if content is valid UTF-8
|
||||
std::str::from_utf8(&buf).is_ok()
|
||||
};
|
||||
|
||||
if !args.no_newline
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{LazyLock, Mutex},
|
||||
sync::{Arc, LazyLock, Mutex},
|
||||
};
|
||||
|
||||
use arc_swap::ArcSwapOption;
|
||||
use log::debug;
|
||||
use wayland_client::{
|
||||
Connection as WaylandConnection,
|
||||
|
|
@ -17,7 +18,7 @@ use wayland_protocols_wlr::foreign_toplevel::v1::client::{
|
|||
zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1},
|
||||
};
|
||||
|
||||
static FOCUSED_APP: Mutex<Option<String>> = Mutex::new(None);
|
||||
static FOCUSED_APP: ArcSwapOption<String> = ArcSwapOption::const_empty();
|
||||
static TOPLEVEL_APPS: LazyLock<Mutex<HashMap<ObjectId, String>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
|
|
@ -32,12 +33,11 @@ pub fn init_wayland_state() {
|
|||
|
||||
/// Get the currently focused window application name using Wayland protocols
|
||||
pub fn get_focused_window_app() -> Option<String> {
|
||||
// Try Wayland protocol first
|
||||
if let Ok(focused) = FOCUSED_APP.lock()
|
||||
&& let Some(ref app) = *focused
|
||||
{
|
||||
// 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.clone());
|
||||
return Some(app.to_string());
|
||||
}
|
||||
|
||||
debug!("No focused window detection method worked");
|
||||
|
|
@ -152,12 +152,11 @@ impl Dispatch<ZwlrForeignToplevelHandleV1, ()> for AppState {
|
|||
}) {
|
||||
debug!("Toplevel activated");
|
||||
// Update focused app to the `app_id` of this handle
|
||||
if let (Ok(apps), Ok(mut focused)) =
|
||||
(TOPLEVEL_APPS.lock(), FOCUSED_APP.lock())
|
||||
if let Ok(apps) = TOPLEVEL_APPS.lock()
|
||||
&& let Some(app_id) = apps.get(&handle_id)
|
||||
{
|
||||
debug!("Setting focused app to: {app_id}");
|
||||
*focused = Some(app_id.clone());
|
||||
FOCUSED_APP.store(Some(Arc::new(app_id.clone())));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue