mirror of
https://github.com/NotAShelf/stash.git
synced 2026-04-12 22:17:41 +00:00
Merge pull request #60 from NotAShelf/notashelf/push-wvyzsrrzyrum
add auto-expiry mechanism to `stash watch`
This commit is contained in:
commit
ded38723d4
7 changed files with 699 additions and 62 deletions
35
Cargo.lock
generated
35
Cargo.lock
generated
|
|
@ -1011,6 +1011,12 @@ version = "0.4.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "humantime"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
|
|
@ -1121,9 +1127,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.85"
|
||||
version = "0.3.83"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
|
||||
checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
|
|
@ -2128,6 +2134,7 @@ dependencies = [
|
|||
"ctrlc",
|
||||
"dirs",
|
||||
"env_logger",
|
||||
"humantime",
|
||||
"imagesize",
|
||||
"inquire",
|
||||
"libc",
|
||||
|
|
@ -2561,18 +2568,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
|||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.2+wasi-0.2.9"
|
||||
version = "1.0.1+wasi-0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.108"
|
||||
version = "0.2.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
|
||||
checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
|
|
@ -2583,9 +2590,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.108"
|
||||
version = "0.2.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
|
||||
checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
|
|
@ -2593,9 +2600,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.108"
|
||||
version = "0.2.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
|
||||
checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
|
|
@ -2606,9 +2613,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.108"
|
||||
version = "0.2.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
|
||||
checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
|
@ -2924,9 +2931,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
version = "0.46.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
|
||||
|
||||
[[package]]
|
||||
name = "wl-clipboard-rs"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ repository = "https://github.com/notashelf/stash"
|
|||
rust-version = "1.90"
|
||||
|
||||
[[bin]]
|
||||
name = "stash" # actual binary name for Nix, Cargo, etc.
|
||||
name = "stash" # actual binary name for Nix, Cargo, etc.
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
|
|
@ -22,6 +22,7 @@ crossterm = "0.29.0"
|
|||
ctrlc = "3.5.1"
|
||||
dirs = "6.0.0"
|
||||
env_logger = "0.11.8"
|
||||
humantime = "2.3.0"
|
||||
imagesize = "0.14.0"
|
||||
inquire = { version = "0.9.2", default-features = false, features = [
|
||||
"crossterm",
|
||||
|
|
|
|||
132
README.md
132
README.md
|
|
@ -45,8 +45,9 @@ with many features such as but not necessarily limited to:
|
|||
- Import clipboard history from TSV (e.g., from `cliphist list`)
|
||||
- Image preview (shows dimensions and format)
|
||||
- Text previews with customizable width
|
||||
- Deduplication and entry limit control
|
||||
- De-duplication, whitespace prevention and entry limit control
|
||||
- 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)
|
||||
|
|
@ -141,7 +142,7 @@ Commands:
|
|||
list List clipboard history
|
||||
decode Decode and output clipboard entry by id
|
||||
delete Delete clipboard entry by id (if numeric), or entries matching a query (if not). Numeric arguments are treated as ids. Use --type to specify explicitly
|
||||
wipe Wipe all clipboard history
|
||||
db Database management operations
|
||||
import Import clipboard data from stdin (default: TSV format)
|
||||
watch Start a process to watch clipboard for changes and store automatically
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
|
@ -154,7 +155,7 @@ Options:
|
|||
--preview-width <PREVIEW_WIDTH>
|
||||
Maximum width (in characters) for clipboard entry previews in list output [default: 100]
|
||||
--db-path <DB_PATH>
|
||||
Path to the `SQLite` clipboard database file
|
||||
Path to the `SQLite` clipboard database file [env: STASH_DB_PATH=]
|
||||
--excluded-apps <EXCLUDED_APPS>
|
||||
Application names to exclude from clipboard history [env: STASH_EXCLUDED_APPS=]
|
||||
--ask
|
||||
|
|
@ -188,6 +189,11 @@ and copying/deleting entries. This behaviour is EXCLUSIVE TO TTYs and Stash will
|
|||
display entries in Cliphist-compatible TSV format in Bash scripts. You may also
|
||||
enforce the output format with `stash list --format <tsv | json>`.
|
||||
|
||||
You may also view your clipboard _with the addition of expired entries_, i.e.,
|
||||
entries that have reached their TTL and are marked as expired, using the
|
||||
`--expired` flag as `stash list --expired`. Expired entries are not cleaned up
|
||||
when using this flag, allowing you to inspect them before running cleanup.
|
||||
|
||||
### Decode an entry by ID
|
||||
|
||||
```bash
|
||||
|
|
@ -219,10 +225,33 @@ stash delete --type id < ids.txt
|
|||
|
||||
### Wipe all entries
|
||||
|
||||
> [!WARNING]
|
||||
> This command is deprecated, and will be removed in v0.4.0. Use `stash db wipe`
|
||||
> instead.
|
||||
|
||||
```bash
|
||||
stash wipe
|
||||
```
|
||||
|
||||
### Database management
|
||||
|
||||
Stash provides a `db` subcommand for database maintenance operations:
|
||||
|
||||
```bash
|
||||
stash db wipe [--expired] [--ask]
|
||||
stash db vacuum
|
||||
stash db stats
|
||||
```
|
||||
|
||||
- `stash db wipe`: Remove all entries from the database. Use `--expired` to only
|
||||
wipe expired entries instead of all entries. Requires `--ask` confirmation by
|
||||
default.
|
||||
- `stash db vacuum`: Optimize the database using SQLite's VACUUM command,
|
||||
reclaiming space and improving performance.
|
||||
- `stash db stats`: Display database statistics including total/active/expired
|
||||
entry counts, storage size, and page information. This is provided purely for
|
||||
convenience and the rule of the cool.
|
||||
|
||||
### Watch clipboard for changes and store automatically
|
||||
|
||||
```bash
|
||||
|
|
@ -235,13 +264,16 @@ automatically. This is designed as an alternative to shelling out to
|
|||
premade Systemd service in `contrib/`. Packagers are encouraged to vendor the
|
||||
service unless adding their own.
|
||||
|
||||
> [!TIP]
|
||||
> Stash provides `wl-copy` and `wl-paste` binaries for backwards compatibility
|
||||
> with the `wl-clipboard` tools. If _must_ depend on those binaries by name, you
|
||||
> may simply use the `wl-copy` and `wl-paste` provided as `wl-clipboard-rs`
|
||||
> wrappers on your system. In other words, you can use
|
||||
> `wl-paste --watch stash store` as an alternative to `stash watch` if
|
||||
> preferred.
|
||||
#### Automatic Clipboard Clearing on Expiration
|
||||
|
||||
When `stash watch` is running and a clipboard entry expires, Stash will detect
|
||||
if the current clipboard still contains that expired content and automatically
|
||||
clear it. This prevents stale data from remaining in your clipboard after an
|
||||
entry has expired from history.
|
||||
|
||||
> [!NOTE]
|
||||
> This behavior only applies when the watch daemon is actively running. Manual
|
||||
> expiration or deletion of entries will not clear the clipboard.
|
||||
|
||||
### Options
|
||||
|
||||
|
|
@ -406,6 +438,86 @@ figured out something new, e.g. a neat shell trick, feel free to add it here!
|
|||
the packagers. While building from source, you may link
|
||||
`target/release/stash` manually.
|
||||
|
||||
### Entry Expiration
|
||||
|
||||
Stash supports time-to-live (TTL) for clipboard entries. When an entry's
|
||||
expiration time is reached, it is marked as expired rather than immediately
|
||||
deleted. This allows for inspection of expired entries and automatic clipboard
|
||||
cleanup.
|
||||
|
||||
#### How Expiration Works
|
||||
|
||||
When `stash watch` is running with `--expire-after`, it monitors the clipboard
|
||||
and processes expired entries periodically. Upon expiration:
|
||||
|
||||
1. The entry's `is_expired` flag is set to `1` in the database
|
||||
2. If the current clipboard content matches the expired entry, Stash clears the
|
||||
clipboard to prevent pasting stale data
|
||||
3. Expired entries are excluded from normal list operations unless `--expired`
|
||||
is specified
|
||||
|
||||
> [!NOTE]
|
||||
> By default, entries do not expire. Use `stash watch --expire-after DURATION`
|
||||
> to enable expiration (e.g., `--expire-after 24h` for 24-hour TTL).
|
||||
|
||||
#### Viewing Expired Entries
|
||||
|
||||
Use `stash list --expired` to include expired entries in the output. This is
|
||||
useful for:
|
||||
|
||||
- Inspecting what has expired from your clipboard history
|
||||
- Verifying that sensitive data has been properly expired
|
||||
- Debugging expiration behavior
|
||||
|
||||
```bash
|
||||
# View all entries including expired ones
|
||||
stash list --expired
|
||||
|
||||
# View expired entries in JSON format
|
||||
stash list --expired --format json
|
||||
```
|
||||
|
||||
#### Cleaning Up Expired Entries
|
||||
|
||||
The watch daemon automatically cleans up expired entries when it processes them.
|
||||
For manual cleanup, use:
|
||||
|
||||
```bash
|
||||
# Remove all expired entries from the database
|
||||
stash db wipe --expired
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> If you have a large number of expired entries, consider running
|
||||
> `stash db vacuum` afterward to reclaim disk space.
|
||||
|
||||
#### Automatic Clipboard Clearing
|
||||
|
||||
When `stash watch` is running and an entry expires, Stash checks if the current
|
||||
clipboard still contains that expired content. If it matches, Stash clears the
|
||||
clipboard automatically. This prevents accidentally pasting outdated content.
|
||||
|
||||
> [!TIP]
|
||||
> This behavior only applies when the watch daemon is actively running. Manual
|
||||
> expiration or deletion of entries will not clear the clipboard.
|
||||
|
||||
#### Database Maintenance
|
||||
|
||||
Stash uses SQLite for persistent storage. Over time, deleted entries and
|
||||
fragmentation can affect performance. Use the `stash db` command to maintain
|
||||
your database:
|
||||
|
||||
- **Check statistics**: `stash db stats` shows entry counts and storage usage.
|
||||
Use this to monitor growth and decide when to clean up.
|
||||
- **Remove expired entries**: `stash db wipe --expired` removes entries that
|
||||
have reached their TTL. The daemon normally handles this, but this is useful
|
||||
for manual cleanup.
|
||||
- **Optimize storage**: `stash db vacuum` runs SQLite's VACUUM command to
|
||||
reclaim space and defragment the database. This is safe to run periodically.
|
||||
|
||||
It is recommended to run `stash db vacuum` occasionally (e.g., monthly) to keep
|
||||
the database compact, especially after deleting many entries.
|
||||
|
||||
## Attributions
|
||||
|
||||
My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the
|
||||
|
|
|
|||
|
|
@ -6,8 +6,12 @@ use unicode_width::UnicodeWidthStr;
|
|||
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
|
||||
|
||||
pub trait ListCommand {
|
||||
fn list(&self, out: impl Write, preview_width: u32)
|
||||
-> Result<(), StashError>;
|
||||
fn list(
|
||||
&self,
|
||||
out: impl Write,
|
||||
preview_width: u32,
|
||||
include_expired: bool,
|
||||
) -> Result<(), StashError>;
|
||||
}
|
||||
|
||||
impl ListCommand for SqliteClipboardDb {
|
||||
|
|
@ -15,14 +19,21 @@ impl ListCommand for SqliteClipboardDb {
|
|||
&self,
|
||||
out: impl Write,
|
||||
preview_width: u32,
|
||||
include_expired: bool,
|
||||
) -> Result<(), StashError> {
|
||||
self.list_entries(out, preview_width).map(|_| ())
|
||||
self
|
||||
.list_entries(out, preview_width, include_expired)
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
impl SqliteClipboardDb {
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn list_tui(&self, preview_width: u32) -> Result<(), StashError> {
|
||||
pub fn list_tui(
|
||||
&self,
|
||||
preview_width: u32,
|
||||
include_expired: bool,
|
||||
) -> Result<(), StashError> {
|
||||
use std::io::stdout;
|
||||
|
||||
use crossterm::{
|
||||
|
|
@ -53,12 +64,16 @@ impl SqliteClipboardDb {
|
|||
use wl_clipboard_rs::copy::{MimeType, Options, Source};
|
||||
|
||||
// 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(
|
||||
"SELECT id, contents, mime FROM clipboard ORDER BY last_accessed \
|
||||
DESC, id DESC",
|
||||
)
|
||||
.prepare(query)
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
let mut rows = stmt
|
||||
.query([])
|
||||
|
|
|
|||
|
|
@ -1,21 +1,105 @@
|
|||
use std::{
|
||||
collections::hash_map::DefaultHasher,
|
||||
collections::{BinaryHeap, hash_map::DefaultHasher},
|
||||
hash::{Hash, Hasher},
|
||||
io::Read,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use smol::Timer;
|
||||
use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents};
|
||||
use wl_clipboard_rs::{
|
||||
copy::{MimeType as CopyMimeType, Options, Source},
|
||||
paste::{ClipboardType, Seat, get_contents},
|
||||
};
|
||||
|
||||
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.
|
||||
/// Also see:
|
||||
/// - <https://doc.rust-lang.org/std/cmp/struct.Reverse.html>
|
||||
/// - <https://doc.rust-lang.org/std/primitive.f64.html#method.total_cmp>
|
||||
/// - <https://docs.rs/ordered-float/latest/ordered_float/>
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Neg(f64);
|
||||
|
||||
impl Neg {
|
||||
fn inner(&self) -> f64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::cmp::PartialEq for Neg {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.0 == other.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::cmp::Eq for Neg {}
|
||||
|
||||
impl std::cmp::PartialOrd for Neg {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::cmp::Ord for Neg {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
// Reverse ordering for min-heap behavior
|
||||
other
|
||||
.0
|
||||
.partial_cmp(&self.0)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
}
|
||||
}
|
||||
|
||||
/// Min-heap for tracking entry expirations with sub-second precision.
|
||||
/// Uses Neg wrapper to turn BinaryHeap (max-heap) into min-heap behavior.
|
||||
#[derive(Debug, Default)]
|
||||
struct ExpirationQueue {
|
||||
heap: BinaryHeap<(Neg, i64)>,
|
||||
}
|
||||
|
||||
impl ExpirationQueue {
|
||||
/// Create a new empty expiration queue
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
heap: BinaryHeap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a new expiration into the queue
|
||||
fn push(&mut self, expires_at: f64, id: i64) {
|
||||
self.heap.push((Neg(expires_at), id));
|
||||
}
|
||||
|
||||
/// Peek at the next expiration timestamp without removing it
|
||||
fn peek_next(&self) -> Option<f64> {
|
||||
self.heap.peek().map(|(neg, _)| neg.inner())
|
||||
}
|
||||
|
||||
/// Remove and return all entries that have expired by `now`
|
||||
fn pop_expired(&mut self, now: f64) -> Vec<i64> {
|
||||
let mut expired = Vec::new();
|
||||
while let Some((neg_exp, id)) = self.heap.peek() {
|
||||
let expires_at = neg_exp.inner();
|
||||
if expires_at <= now {
|
||||
expired.push(*id);
|
||||
self.heap.pop();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
expired
|
||||
}
|
||||
}
|
||||
|
||||
pub trait WatchCommand {
|
||||
fn watch(
|
||||
&self,
|
||||
max_dedupe_search: u64,
|
||||
max_items: u64,
|
||||
excluded_apps: &[String],
|
||||
expire_after: Option<Duration>,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -25,10 +109,46 @@ impl WatchCommand for SqliteClipboardDb {
|
|||
max_dedupe_search: u64,
|
||||
max_items: u64,
|
||||
excluded_apps: &[String],
|
||||
expire_after: Option<Duration>,
|
||||
) {
|
||||
smol::block_on(async {
|
||||
log::info!("Starting clipboard watch daemon");
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We use hashes for comparison instead of storing full contents
|
||||
let mut last_hash: Option<u64> = None;
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
|
|
@ -53,6 +173,83 @@ impl WatchCommand for SqliteClipboardDb {
|
|||
}
|
||||
|
||||
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, _)) = get_contents(
|
||||
ClipboardType::Regular,
|
||||
Seat::Unspecified,
|
||||
wl_clipboard_rs::paste::MimeType::Any,
|
||||
) {
|
||||
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,
|
||||
);
|
||||
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 get_contents(
|
||||
ClipboardType::Regular,
|
||||
Seat::Unspecified,
|
||||
|
|
@ -70,16 +267,23 @@ impl WatchCommand for SqliteClipboardDb {
|
|||
if !buf.is_empty() {
|
||||
let current_hash = hash_contents(&buf);
|
||||
if last_hash != Some(current_hash) {
|
||||
let id = self.next_sequence();
|
||||
match self.store_entry(
|
||||
&buf[..],
|
||||
max_dedupe_search,
|
||||
max_items,
|
||||
Some(excluded_apps),
|
||||
) {
|
||||
Ok(_) => {
|
||||
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();
|
||||
exp_queue.push(expires_at, id);
|
||||
}
|
||||
},
|
||||
Err(crate::db::StashError::ExcludedByApp(_)) => {
|
||||
log::info!("Clipboard entry excluded by app filter");
|
||||
|
|
@ -106,7 +310,11 @@ impl WatchCommand for SqliteClipboardDb {
|
|||
}
|
||||
},
|
||||
}
|
||||
Timer::after(Duration::from_millis(500)).await;
|
||||
|
||||
// Normal poll interval (only if no expirations pending)
|
||||
if exp_queue.peek_next().is_none() {
|
||||
Timer::after(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
228
src/db/mod.rs
228
src/db/mod.rs
|
|
@ -80,6 +80,7 @@ pub trait ClipboardDb {
|
|||
&self,
|
||||
out: impl Write,
|
||||
preview_width: u32,
|
||||
include_expired: bool,
|
||||
) -> Result<usize, StashError>;
|
||||
fn decode_entry(
|
||||
&self,
|
||||
|
|
@ -93,7 +94,6 @@ pub trait ClipboardDb {
|
|||
&self,
|
||||
id: i64,
|
||||
) -> Result<(i64, Vec<u8>, Option<String>), StashError>;
|
||||
fn next_sequence(&self) -> i64;
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
|
|
@ -256,6 +256,89 @@ impl SqliteClipboardDb {
|
|||
})?;
|
||||
}
|
||||
|
||||
// Add expires_at column if it doesn't exist (v4)
|
||||
if schema_version < 4 {
|
||||
let has_expires_at: bool = tx
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND \
|
||||
name='clipboard'",
|
||||
[],
|
||||
|row| {
|
||||
let sql: String = row.get(0)?;
|
||||
Ok(sql.to_lowercase().contains("expires_at"))
|
||||
},
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_expires_at {
|
||||
tx.execute("ALTER TABLE clipboard ADD COLUMN expires_at REAL", [])
|
||||
.map_err(|e| {
|
||||
StashError::Store(
|
||||
format!("Failed to add expires_at column: {e}").into(),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
// Create partial index for expires_at (only index non-NULL values)
|
||||
tx.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_expires_at ON clipboard(expires_at) \
|
||||
WHERE expires_at IS NOT NULL",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| {
|
||||
StashError::Store(
|
||||
format!("Failed to create expires_at index: {e}").into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
tx.execute("PRAGMA user_version = 4", []).map_err(|e| {
|
||||
StashError::Store(format!("Failed to set schema version: {e}").into())
|
||||
})?;
|
||||
}
|
||||
|
||||
// Add is_expired column if it doesn't exist (v5)
|
||||
if schema_version < 5 {
|
||||
let has_is_expired: bool = tx
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND \
|
||||
name='clipboard'",
|
||||
[],
|
||||
|row| {
|
||||
let sql: String = row.get(0)?;
|
||||
Ok(sql.to_lowercase().contains("is_expired"))
|
||||
},
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_is_expired {
|
||||
tx.execute(
|
||||
"ALTER TABLE clipboard ADD COLUMN is_expired INTEGER DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| {
|
||||
StashError::Store(
|
||||
format!("Failed to add is_expired column: {e}").into(),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
// Create index for is_expired (for filtering)
|
||||
tx.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_is_expired ON clipboard(is_expired) \
|
||||
WHERE is_expired = 1",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| {
|
||||
StashError::Store(
|
||||
format!("Failed to create is_expired index: {e}").into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
tx.execute("PRAGMA user_version = 5", []).map_err(|e| {
|
||||
StashError::Store(format!("Failed to set schema version: {e}").into())
|
||||
})?;
|
||||
}
|
||||
|
||||
tx.commit().map_err(|e| {
|
||||
StashError::Store(
|
||||
format!("Failed to commit migration transaction: {e}").into(),
|
||||
|
|
@ -271,13 +354,17 @@ impl SqliteClipboardDb {
|
|||
}
|
||||
|
||||
impl SqliteClipboardDb {
|
||||
pub fn list_json(&self) -> Result<String, StashError> {
|
||||
pub fn list_json(&self, include_expired: bool) -> Result<String, StashError> {
|
||||
let query = if include_expired {
|
||||
"SELECT id, contents, mime FROM clipboard ORDER BY \
|
||||
COALESCE(last_accessed, 0) DESC, id DESC"
|
||||
} else {
|
||||
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \
|
||||
is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC"
|
||||
};
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT id, contents, mime FROM clipboard ORDER BY \
|
||||
COALESCE(last_accessed, 0) DESC, id DESC",
|
||||
)
|
||||
.prepare(query)
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
let mut rows = stmt
|
||||
.query([])
|
||||
|
|
@ -486,13 +573,18 @@ impl ClipboardDb for SqliteClipboardDb {
|
|||
&self,
|
||||
mut out: impl Write,
|
||||
preview_width: u32,
|
||||
include_expired: bool,
|
||||
) -> Result<usize, StashError> {
|
||||
let query = if include_expired {
|
||||
"SELECT id, contents, mime FROM clipboard ORDER BY \
|
||||
COALESCE(last_accessed, 0) DESC, id DESC"
|
||||
} else {
|
||||
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \
|
||||
is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC"
|
||||
};
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT id, contents, mime FROM clipboard ORDER BY \
|
||||
COALESCE(last_accessed, 0) DESC, id DESC",
|
||||
)
|
||||
.prepare(query)
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
let mut rows = stmt
|
||||
.query([])
|
||||
|
|
@ -640,17 +732,119 @@ impl ClipboardDb for SqliteClipboardDb {
|
|||
|
||||
Ok((id, contents, mime))
|
||||
}
|
||||
}
|
||||
|
||||
fn next_sequence(&self) -> i64 {
|
||||
match self
|
||||
impl SqliteClipboardDb {
|
||||
/// Get current Unix timestamp with sub-second precision
|
||||
pub fn now() -> f64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs_f64()
|
||||
}
|
||||
|
||||
/// Clean up all expired entries. Returns count deleted.
|
||||
pub fn cleanup_expired(&self) -> Result<usize, StashError> {
|
||||
let now = Self::now();
|
||||
self
|
||||
.conn
|
||||
.query_row("SELECT MAX(id) FROM clipboard", [], |row| {
|
||||
row.get::<_, Option<i64>>(0)
|
||||
}) {
|
||||
Ok(Some(max_id)) => max_id + 1,
|
||||
Ok(None) | Err(_) => 1,
|
||||
.execute(
|
||||
"DELETE FROM clipboard WHERE expires_at IS NOT NULL AND expires_at <= \
|
||||
?1",
|
||||
[now],
|
||||
)
|
||||
.map_err(|e| StashError::Trim(e.to_string().into()))
|
||||
}
|
||||
|
||||
/// Get the earliest expiration (timestamp, id) for heap initialization
|
||||
pub fn get_next_expiration(&self) -> Result<Option<(f64, i64)>, StashError> {
|
||||
match self.conn.query_row(
|
||||
"SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT NULL \
|
||||
ORDER BY expires_at ASC LIMIT 1",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
) {
|
||||
Ok(result) => Ok(Some(result)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(StashError::Store(e.to_string().into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set expiration timestamp for an entry
|
||||
pub fn set_expiration(
|
||||
&self,
|
||||
id: i64,
|
||||
expires_at: f64,
|
||||
) -> Result<(), StashError> {
|
||||
self
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE clipboard SET expires_at = ?2 WHERE id = ?1",
|
||||
params![id, expires_at],
|
||||
)
|
||||
.map_err(|e| StashError::Store(e.to_string().into()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Optimize database using VACUUM
|
||||
pub fn vacuum(&self) -> Result<(), StashError> {
|
||||
self
|
||||
.conn
|
||||
.execute("VACUUM", [])
|
||||
.map_err(|e| StashError::Store(e.to_string().into()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get database statistics
|
||||
pub fn stats(&self) -> Result<String, StashError> {
|
||||
let total: i64 = self
|
||||
.conn
|
||||
.query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0))
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
|
||||
let expired: i64 = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM clipboard WHERE is_expired = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
|
||||
let active = total - expired;
|
||||
|
||||
let with_expiration: i64 = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM clipboard WHERE expires_at IS NOT NULL AND \
|
||||
(is_expired IS NULL OR is_expired = 0)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
|
||||
// Get database file size
|
||||
let page_count: i64 = self
|
||||
.conn
|
||||
.query_row("PRAGMA page_count", [], |row| row.get(0))
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
|
||||
let page_size: i64 = self
|
||||
.conn
|
||||
.query_row("PRAGMA page_size", [], |row| row.get(0))
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
|
||||
let size_bytes = page_count * page_size;
|
||||
let size_mb = size_bytes as f64 / 1024.0 / 1024.0;
|
||||
|
||||
Ok(format!(
|
||||
"Database Statistics:\n\nEntries:\nTotal: {total}\nActive: \
|
||||
{active}\nExpired: {expired}\nWith TTL: \
|
||||
{with_expiration}\n\nStorage:\nSize: {size_mb:.2} MB \
|
||||
({size_bytes} bytes)\nPages: {page_count}\nPage size: \
|
||||
{page_size} bytes"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to load a sensitive regex from systemd credential or env.
|
||||
|
|
|
|||
114
src/main.rs
114
src/main.rs
|
|
@ -2,9 +2,11 @@ use std::{
|
|||
env,
|
||||
io::{self, IsTerminal},
|
||||
path::PathBuf,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use clap::{CommandFactory, Parser, Subcommand};
|
||||
use humantime::parse_duration;
|
||||
use inquire::Confirm;
|
||||
|
||||
mod commands;
|
||||
|
|
@ -71,6 +73,10 @@ enum Command {
|
|||
/// Output format: "tsv" (default) or "json"
|
||||
#[arg(long, value_parser = ["tsv", "json"])]
|
||||
format: Option<String>,
|
||||
|
||||
/// Show only expired entries (diagnostic, does not remove them)
|
||||
#[arg(long)]
|
||||
expired: bool,
|
||||
},
|
||||
|
||||
/// Decode and output clipboard entry by id
|
||||
|
|
@ -93,12 +99,21 @@ enum Command {
|
|||
},
|
||||
|
||||
/// 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)]
|
||||
action: DbAction,
|
||||
},
|
||||
|
||||
/// Import clipboard data from stdin (default: TSV format)
|
||||
Import {
|
||||
/// Explicitly specify format: "tsv" (default)
|
||||
|
|
@ -111,7 +126,31 @@ enum Command {
|
|||
},
|
||||
|
||||
/// Start a process to watch clipboard for changes and store automatically.
|
||||
Watch,
|
||||
Watch {
|
||||
/// Expire new entries after duration (e.g., "3s", "500ms", "1h30m").
|
||||
#[arg(long, value_parser = parse_duration)]
|
||||
expire_after: Option<Duration>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum DbAction {
|
||||
/// Wipe database entries
|
||||
Wipe {
|
||||
/// Only wipe expired entries instead of all entries
|
||||
#[arg(long)]
|
||||
expired: bool,
|
||||
|
||||
/// Ask for confirmation before wiping
|
||||
#[arg(long)]
|
||||
ask: bool,
|
||||
},
|
||||
|
||||
/// Optimize database using VACUUM
|
||||
Vacuum,
|
||||
|
||||
/// Show database statistics
|
||||
Stats,
|
||||
}
|
||||
|
||||
fn report_error<T>(
|
||||
|
|
@ -186,16 +225,16 @@ fn main() -> color_eyre::eyre::Result<()> {
|
|||
"failed to store entry",
|
||||
);
|
||||
},
|
||||
Some(Command::List { format }) => {
|
||||
Some(Command::List { format, expired }) => {
|
||||
match format.as_deref() {
|
||||
Some("tsv") => {
|
||||
report_error(
|
||||
db.list(io::stdout(), cli.preview_width),
|
||||
db.list(io::stdout(), cli.preview_width, expired),
|
||||
"failed to list entries",
|
||||
);
|
||||
},
|
||||
Some("json") => {
|
||||
match db.list_json() {
|
||||
match db.list_json(expired) {
|
||||
Ok(json) => {
|
||||
println!("{json}");
|
||||
},
|
||||
|
|
@ -210,12 +249,12 @@ fn main() -> color_eyre::eyre::Result<()> {
|
|||
None => {
|
||||
if std::io::stdout().is_terminal() {
|
||||
report_error(
|
||||
db.list_tui(cli.preview_width),
|
||||
db.list_tui(cli.preview_width, expired),
|
||||
"failed to list entries in TUI",
|
||||
);
|
||||
} else {
|
||||
report_error(
|
||||
db.list(io::stdout(), cli.preview_width),
|
||||
db.list(io::stdout(), cli.preview_width, expired),
|
||||
"failed to list entries",
|
||||
);
|
||||
}
|
||||
|
|
@ -287,6 +326,10 @@ 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(
|
||||
|
|
@ -304,6 +347,62 @@ fn main() -> color_eyre::eyre::Result<()> {
|
|||
}
|
||||
},
|
||||
|
||||
Some(Command::Db { action }) => {
|
||||
match action {
|
||||
DbAction::Wipe { expired, ask } => {
|
||||
let mut should_proceed = true;
|
||||
if ask {
|
||||
let message = if expired {
|
||||
"Are you sure you want to wipe all expired clipboard entries?"
|
||||
} else {
|
||||
"Are you sure you want to wipe ALL clipboard history?"
|
||||
};
|
||||
should_proceed = Confirm::new(message)
|
||||
.with_default(false)
|
||||
.prompt()
|
||||
.unwrap_or(false);
|
||||
if !should_proceed {
|
||||
log::info!("db wipe command aborted by user.");
|
||||
}
|
||||
}
|
||||
if should_proceed {
|
||||
if expired {
|
||||
match db.cleanup_expired() {
|
||||
Ok(count) => {
|
||||
log::info!("Wiped {} expired entries", count);
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("failed to wipe expired entries: {e}");
|
||||
},
|
||||
}
|
||||
} else {
|
||||
report_error(db.wipe(), "failed to wipe database");
|
||||
}
|
||||
}
|
||||
},
|
||||
DbAction::Vacuum => {
|
||||
match db.vacuum() {
|
||||
Ok(()) => {
|
||||
log::info!("Database optimized successfully");
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("failed to vacuum database: {e}");
|
||||
},
|
||||
}
|
||||
},
|
||||
DbAction::Stats => {
|
||||
match db.stats() {
|
||||
Ok(stats) => {
|
||||
println!("{}", stats);
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("failed to get database stats: {e}");
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
Some(Command::Import { r#type, ask }) => {
|
||||
let mut should_proceed = true;
|
||||
if ask {
|
||||
|
|
@ -334,7 +433,7 @@ fn main() -> color_eyre::eyre::Result<()> {
|
|||
}
|
||||
}
|
||||
},
|
||||
Some(Command::Watch) => {
|
||||
Some(Command::Watch { expire_after }) => {
|
||||
db.watch(
|
||||
cli.max_dedupe_search,
|
||||
cli.max_items,
|
||||
|
|
@ -342,6 +441,7 @@ fn main() -> color_eyre::eyre::Result<()> {
|
|||
&cli.excluded_apps,
|
||||
#[cfg(not(feature = "use-toplevel"))]
|
||||
&[],
|
||||
expire_after,
|
||||
);
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue