stash: blocking persistent entries by window class

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964061bd97b4ffc4e84d835072331a966c6
This commit is contained in:
raf 2025-09-19 11:18:57 +03:00
commit 36c183742d
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
7 changed files with 483 additions and 19 deletions

11
Cargo.lock generated
View file

@ -700,15 +700,15 @@ checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
[[package]] [[package]]
name = "inquire" name = "inquire"
version = "0.8.0" version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95b8b5b4fd6d0ef1235f11c2e8ce9734be5736c21230ff585c3bae2e940abced" checksum = "2628910d0114e9139056161d8644a2026be7b117f8498943f9437748b04c9e0a"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"crossterm 0.28.1", "crossterm 0.29.0",
"dyn-clone", "dyn-clone",
"unicode-segmentation", "unicode-segmentation",
"unicode-width 0.1.14", "unicode-width 0.2.0",
] ]
[[package]] [[package]]
@ -1289,6 +1289,9 @@ dependencies = [
"thiserror", "thiserror",
"unicode-segmentation", "unicode-segmentation",
"unicode-width 0.2.0", "unicode-width 0.2.0",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
"wl-clipboard-rs", "wl-clipboard-rs",
] ]

View file

@ -8,12 +8,16 @@ readme = true
repository = "https://github.com/notashelf/stash" repository = "https://github.com/notashelf/stash"
rust-version = "1.85" rust-version = "1.85"
[features]
default = ["use-toplevel"]
use-toplevel = ["dep:wayland-client", "dep:wayland-protocols", "dep:wayland-protocols-wlr"]
[dependencies] [dependencies]
clap = { version = "4.5.47", features = ["derive"] } clap = { version = "4.5.47", features = ["derive", "env"] }
clap-verbosity-flag = "3.0.4" clap-verbosity-flag = "3.0.4"
dirs = "6.0.0" dirs = "6.0.0"
imagesize = "0.14.0" imagesize = "0.14.0"
inquire = { default-features = false, version = "0.8.0", features = [ inquire = { default-features = false, version = "0.9.1", features = [
"crossterm", "crossterm",
] } ] }
log = "0.4.28" log = "0.4.28"
@ -30,6 +34,9 @@ ratatui = "0.29.0"
crossterm = "0.29.0" crossterm = "0.29.0"
unicode-segmentation = "1.12.0" unicode-segmentation = "1.12.0"
unicode-width = "0.2.0" unicode-width = "0.2.0"
wayland-client = { version = "0.31.11", optional = true }
wayland-protocols = { version = "0.32.0", optional = true }
wayland-protocols-wlr = { version = "0.3.9", optional = true }
[profile.release] [profile.release]
lto = true lto = true

View file

@ -9,6 +9,7 @@ pub trait StoreCommand {
max_dedupe_search: u64, max_dedupe_search: u64,
max_items: u64, max_items: u64,
state: Option<String>, state: Option<String>,
excluded_apps: &[String],
) -> Result<(), crate::db::StashError>; ) -> Result<(), crate::db::StashError>;
} }
@ -19,12 +20,18 @@ impl StoreCommand for SqliteClipboardDb {
max_dedupe_search: u64, max_dedupe_search: u64,
max_items: u64, max_items: u64,
state: Option<String>, state: Option<String>,
excluded_apps: &[String],
) -> Result<(), crate::db::StashError> { ) -> Result<(), crate::db::StashError> {
if let Some("sensitive" | "clear") = state.as_deref() { if let Some("sensitive" | "clear") = state.as_deref() {
self.delete_last()?; self.delete_last()?;
log::info!("Entry deleted"); log::info!("Entry deleted");
} else { } else {
self.store_entry(input, max_dedupe_search, max_items)?; self.store_entry(
input,
max_dedupe_search,
max_items,
Some(excluded_apps),
)?;
log::info!("Entry stored"); log::info!("Entry stored");
} }
Ok(()) Ok(())

View file

@ -6,11 +6,21 @@ use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents};
use crate::db::{ClipboardDb, Entry, SqliteClipboardDb}; use crate::db::{ClipboardDb, Entry, SqliteClipboardDb};
pub trait WatchCommand { pub trait WatchCommand {
fn watch(&self, max_dedupe_search: u64, max_items: u64); fn watch(
&self,
max_dedupe_search: u64,
max_items: u64,
excluded_apps: &[String],
);
} }
impl WatchCommand for SqliteClipboardDb { impl WatchCommand for SqliteClipboardDb {
fn watch(&self, max_dedupe_search: u64, max_items: u64) { fn watch(
&self,
max_dedupe_search: u64,
max_items: u64,
excluded_apps: &[String],
) {
smol::block_on(async { smol::block_on(async {
log::info!("Starting clipboard watch daemon"); log::info!("Starting clipboard watch daemon");
@ -46,10 +56,10 @@ impl WatchCommand for SqliteClipboardDb {
// Only store if changed and not empty // Only store if changed and not empty
if !buf.is_empty() && (last_contents.as_ref() != Some(&buf)) { if !buf.is_empty() && (last_contents.as_ref() != Some(&buf)) {
last_contents = Some(std::mem::take(&mut buf)); let new_contents = std::mem::take(&mut buf);
let mime = Some(mime_type.to_string()); let mime = Some(mime_type.to_string());
let entry = Entry { let entry = Entry {
contents: last_contents.as_ref().unwrap().clone(), contents: new_contents.clone(),
mime, mime,
}; };
let id = self.next_sequence(); let id = self.next_sequence();
@ -57,13 +67,27 @@ impl WatchCommand for SqliteClipboardDb {
&entry.contents[..], &entry.contents[..],
max_dedupe_search, max_dedupe_search,
max_items, max_items,
Some(excluded_apps),
) { ) {
Ok(_) => log::info!("Stored new clipboard entry (id: {id})"), Ok(_) => {
Err(e) => log::error!("Failed to store clipboard entry: {e}"), log::info!("Stored new clipboard entry (id: {id})");
last_contents = Some(new_contents);
},
Err(crate::db::StashError::ExcludedByApp(_)) => {
log::info!("Clipboard entry excluded by app filter");
last_contents = Some(new_contents);
},
Err(crate::db::StashError::Store(ref msg))
if msg.contains("Excluded by app filter") =>
{
log::info!("Clipboard entry excluded by app filter");
last_contents = Some(new_contents);
},
Err(e) => {
log::error!("Failed to store clipboard entry: {e}");
last_contents = Some(new_contents);
},
} }
// Drop clipboard contents after storing
last_contents = None;
} }
}, },
Err(e) => { Err(e) => {

View file

@ -8,7 +8,7 @@ use std::{
use base64::{Engine, engine::general_purpose::STANDARD}; use base64::{Engine, engine::general_purpose::STANDARD};
use imagesize::{ImageSize, ImageType}; use imagesize::{ImageSize, ImageType};
use log::{error, info, warn}; use log::{debug, error, info, warn};
use regex::Regex; use regex::Regex;
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -24,6 +24,8 @@ pub enum StashError {
#[error("Failed to store entry: {0}")] #[error("Failed to store entry: {0}")]
Store(String), Store(String),
#[error("Entry excluded by app filter: {0}")]
ExcludedByApp(String),
#[error("Error reading entry during deduplication: {0}")] #[error("Error reading entry during deduplication: {0}")]
DeduplicationRead(String), DeduplicationRead(String),
#[error("Error decoding entry during deduplication: {0}")] #[error("Error decoding entry during deduplication: {0}")]
@ -61,6 +63,7 @@ pub trait ClipboardDb {
input: impl Read, input: impl Read,
max_dedupe_search: u64, max_dedupe_search: u64,
max_items: u64, max_items: u64,
excluded_apps: Option<&[String]>,
) -> Result<u64, StashError>; ) -> Result<u64, StashError>;
fn deduplicate(&self, buf: &[u8], max: u64) -> Result<usize, StashError>; fn deduplicate(&self, buf: &[u8], max: u64) -> Result<usize, StashError>;
fn trim_db(&self, max: u64) -> Result<(), StashError>; fn trim_db(&self, max: u64) -> Result<(), StashError>;
@ -110,6 +113,9 @@ impl SqliteClipboardDb {
);", );",
) )
.map_err(|e| StashError::Store(e.to_string()))?; .map_err(|e| StashError::Store(e.to_string()))?;
// Initialize Wayland state in background thread
#[cfg(feature = "use-toplevel")]
crate::wayland::init_wayland_state();
Ok(Self { conn }) Ok(Self { conn })
} }
} }
@ -163,6 +169,7 @@ impl ClipboardDb for SqliteClipboardDb {
mut input: impl Read, mut input: impl Read,
max_dedupe_search: u64, max_dedupe_search: u64,
max_items: u64, max_items: u64,
excluded_apps: Option<&[String]>,
) -> Result<u64, StashError> { ) -> Result<u64, StashError> {
let mut buf = Vec::new(); let mut buf = Vec::new();
if input.read_to_end(&mut buf).is_err() if input.read_to_end(&mut buf).is_err()
@ -201,6 +208,14 @@ impl ClipboardDb for SqliteClipboardDb {
} }
} }
// Check if clipboard should be excluded based on running apps
if should_exclude_by_app(excluded_apps) {
warn!("Clipboard entry excluded by app filter");
return Err(StashError::ExcludedByApp(
"Clipboard entry from excluded app".to_string(),
));
}
self.deduplicate(&buf, max_dedupe_search)?; self.deduplicate(&buf, max_dedupe_search)?;
self self
@ -540,3 +555,213 @@ pub fn size_str(size: usize) -> String {
} }
format!("{:.0} {}", fsize, units[i]) format!("{:.0} {}", fsize, units[i])
} }
/// Check if clipboard should be excluded based on excluded apps configuration.
/// Uses timing correlation and focused window detection to identify source app.
fn should_exclude_by_app(excluded_apps: Option<&[String]>) -> bool {
let excluded = match excluded_apps {
Some(apps) if !apps.is_empty() => apps,
_ => return false,
};
// Try multiple detection strategies
if detect_excluded_app_activity(excluded) {
return true;
}
false
}
/// Detect if clipboard likely came from an excluded app using multiple
/// strategies.
fn detect_excluded_app_activity(excluded_apps: &[String]) -> bool {
debug!("Checking clipboard exclusion against: {excluded_apps:?}");
// Strategy 1: Check focused window (compositor-dependent)
if let Some(focused_app) = get_focused_window_app() {
debug!("Focused window detected: {focused_app}");
if app_matches_exclusion(&focused_app, excluded_apps) {
debug!("Clipboard excluded: focused window matches {focused_app}");
return true;
}
} else {
debug!("No focused window detected");
}
// Strategy 2: Check recently active processes (timing correlation)
if let Some(active_app) = get_recently_active_excluded_app(excluded_apps) {
debug!("Clipboard excluded: recent activity from {active_app}");
return true;
}
debug!("No recently active excluded apps found");
debug!("Clipboard not excluded");
false
}
/// Try to get the currently focused window application name.
fn get_focused_window_app() -> Option<String> {
// Try Wayland protocol first
#[cfg(feature = "use-toplevel")]
if let Some(app) = crate::wayland::get_focused_window_app() {
return Some(app);
}
// Fallback: Check WAYLAND_CLIENT_NAME environment variable
if let Ok(client) = env::var("WAYLAND_CLIENT_NAME") {
if !client.is_empty() {
debug!("Found WAYLAND_CLIENT_NAME: {client}");
return Some(client);
}
}
debug!("No focused window detection method worked");
None
}
/// Check for recently active excluded apps using CPU and I/O activity.
fn get_recently_active_excluded_app(
excluded_apps: &[String],
) -> Option<String> {
let proc_dir = std::path::Path::new("/proc");
if !proc_dir.exists() {
return None;
}
let mut candidates = Vec::new();
if let Ok(entries) = fs::read_dir(proc_dir) {
for entry in entries.flatten() {
if let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() {
if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) {
let process_name = comm.trim();
// Check process name against exclusion list
if app_matches_exclusion(process_name, excluded_apps)
&& has_recent_activity(pid)
{
candidates.push((
process_name.to_string(),
get_process_activity_score(pid),
));
}
}
}
}
}
// Return the most active excluded app
candidates
.into_iter()
.max_by_key(|(_, score)| *score)
.map(|(name, _)| name)
}
/// Check if a process has had recent activity (simple heuristic).
fn has_recent_activity(pid: u32) -> bool {
// Check /proc/PID/stat for recent CPU usage
if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
let fields: Vec<&str> = stat.split_whitespace().collect();
if fields.len() > 14 {
// Fields 14 and 15 are utime and stime
if let (Ok(utime), Ok(stime)) =
(fields[13].parse::<u64>(), fields[14].parse::<u64>())
{
let total_time = utime + stime;
// Simple heuristic: if process has any significant CPU time, consider
// it active
return total_time > 100; // arbitrary threshold
}
}
}
// Check /proc/PID/io for recent I/O activity
if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) {
for line in io_stats.lines() {
if line.starts_with("write_bytes:") || line.starts_with("read_bytes:") {
if let Some(value_str) = line.split(':').nth(1) {
if let Ok(value) = value_str.trim().parse::<u64>() {
if value > 1024 * 1024 {
// 1MB threshold
return true;
}
}
}
}
}
}
false
}
/// Get a simple activity score for process prioritization.
fn get_process_activity_score(pid: u32) -> u64 {
let mut score = 0;
// Add CPU time to score
if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
let fields: Vec<&str> = stat.split_whitespace().collect();
if fields.len() > 14 {
if let (Ok(utime), Ok(stime)) =
(fields[13].parse::<u64>(), fields[14].parse::<u64>())
{
score += utime + stime;
}
}
}
// Add I/O activity to score
if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) {
for line in io_stats.lines() {
if line.starts_with("write_bytes:") || line.starts_with("read_bytes:") {
if let Some(value_str) = line.split(':').nth(1) {
if let Ok(value) = value_str.trim().parse::<u64>() {
score += value / 1024; // convert to KB
}
}
}
}
}
score
}
/// Check if an app name matches any in the exclusion list.
/// Supports basic string matching and simple regex patterns.
fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool {
debug!(
"Checking if '{app_name}' matches exclusion list: {excluded_apps:?}"
);
for excluded in excluded_apps {
// Basic string matching (case-insensitive)
if app_name.to_lowercase() == excluded.to_lowercase() {
debug!("Matched exact string: {app_name} == {excluded}");
return true;
}
// Simple pattern matching for common cases
if excluded.starts_with('^') && excluded.ends_with('$') {
// Exact match pattern like ^AppName$
let pattern = &excluded[1..excluded.len() - 1];
if app_name == pattern {
debug!("Matched exact pattern: {app_name} == {pattern}");
return true;
}
} else if excluded.contains('*') {
// Simple wildcard matching
let pattern = excluded.replace('*', ".*");
if let Ok(regex) = regex::Regex::new(&pattern) {
if regex.is_match(app_name) {
debug!(
"Matched wildcard pattern: {app_name} matches {excluded}"
);
return true;
}
}
}
}
debug!("No match found for '{app_name}'");
false
}

View file

@ -10,6 +10,7 @@ use inquire::Confirm;
mod commands; mod commands;
mod db; mod db;
#[cfg(feature = "use-toplevel")] mod wayland;
use crate::commands::{ use crate::commands::{
decode::DecodeCommand, decode::DecodeCommand,
@ -47,6 +48,11 @@ struct Cli {
#[arg(long)] #[arg(long)]
db_path: Option<PathBuf>, db_path: Option<PathBuf>,
/// Application names to exclude from clipboard history
#[cfg(feature = "use-toplevel")]
#[arg(long, value_delimiter = ',', env = "STASH_EXCLUDED_APPS")]
excluded_apps: Vec<String>,
/// Ask for confirmation before destructive operations /// Ask for confirmation before destructive operations
#[arg(long)] #[arg(long)]
ask: bool, ask: bool,
@ -160,7 +166,16 @@ fn main() {
Some(Command::Store) => { Some(Command::Store) => {
let state = env::var("STASH_CLIPBOARD_STATE").ok(); let state = env::var("STASH_CLIPBOARD_STATE").ok();
report_error( report_error(
db.store(io::stdin(), cli.max_dedupe_search, cli.max_items, state), db.store(
io::stdin(),
cli.max_dedupe_search,
cli.max_items,
state,
#[cfg(feature = "use-toplevel")]
&cli.excluded_apps,
#[cfg(not(feature = "use-toplevel"))]
&[],
),
"Failed to store entry", "Failed to store entry",
); );
}, },
@ -313,7 +328,14 @@ fn main() {
} }
}, },
Some(Command::Watch) => { Some(Command::Watch) => {
db.watch(cli.max_dedupe_search, cli.max_items); db.watch(
cli.max_dedupe_search,
cli.max_items,
#[cfg(feature = "use-toplevel")]
&cli.excluded_apps,
#[cfg(not(feature = "use-toplevel"))]
&[],
);
}, },
None => { None => {
if let Err(e) = Cli::command().print_help() { if let Err(e) = Cli::command().print_help() {

176
src/wayland/mod.rs Normal file
View file

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