From 36c183742de82304ba155d8a3d08b6a207d73a97 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Fri, 19 Sep 2025 11:18:57 +0300 Subject: [PATCH] stash: blocking persistent entries by window class Signed-off-by: NotAShelf Change-Id: I6a6a6964061bd97b4ffc4e84d835072331a966c6 --- Cargo.lock | 11 +- Cargo.toml | 11 +- src/commands/store.rs | 9 +- src/commands/watch.rs | 42 ++++++-- src/db/mod.rs | 227 +++++++++++++++++++++++++++++++++++++++++- src/main.rs | 26 ++++- src/wayland/mod.rs | 176 ++++++++++++++++++++++++++++++++ 7 files changed, 483 insertions(+), 19 deletions(-) create mode 100644 src/wayland/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 979e01c..cc4be64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -700,15 +700,15 @@ checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" [[package]] name = "inquire" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95b8b5b4fd6d0ef1235f11c2e8ce9734be5736c21230ff585c3bae2e940abced" +checksum = "2628910d0114e9139056161d8644a2026be7b117f8498943f9437748b04c9e0a" dependencies = [ "bitflags", - "crossterm 0.28.1", + "crossterm 0.29.0", "dyn-clone", "unicode-segmentation", - "unicode-width 0.1.14", + "unicode-width 0.2.0", ] [[package]] @@ -1289,6 +1289,9 @@ dependencies = [ "thiserror", "unicode-segmentation", "unicode-width 0.2.0", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", "wl-clipboard-rs", ] diff --git a/Cargo.toml b/Cargo.toml index 39c8d7c..673f3a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,12 +8,16 @@ readme = true repository = "https://github.com/notashelf/stash" rust-version = "1.85" +[features] +default = ["use-toplevel"] +use-toplevel = ["dep:wayland-client", "dep:wayland-protocols", "dep:wayland-protocols-wlr"] + [dependencies] -clap = { version = "4.5.47", features = ["derive"] } +clap = { version = "4.5.47", features = ["derive", "env"] } clap-verbosity-flag = "3.0.4" dirs = "6.0.0" imagesize = "0.14.0" -inquire = { default-features = false, version = "0.8.0", features = [ +inquire = { default-features = false, version = "0.9.1", features = [ "crossterm", ] } log = "0.4.28" @@ -30,6 +34,9 @@ ratatui = "0.29.0" crossterm = "0.29.0" unicode-segmentation = "1.12.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] lto = true diff --git a/src/commands/store.rs b/src/commands/store.rs index 6ddfb60..9e5a6c6 100644 --- a/src/commands/store.rs +++ b/src/commands/store.rs @@ -9,6 +9,7 @@ pub trait StoreCommand { max_dedupe_search: u64, max_items: u64, state: Option, + excluded_apps: &[String], ) -> Result<(), crate::db::StashError>; } @@ -19,12 +20,18 @@ impl StoreCommand for SqliteClipboardDb { max_dedupe_search: u64, max_items: u64, state: Option, + excluded_apps: &[String], ) -> Result<(), crate::db::StashError> { if let Some("sensitive" | "clear") = state.as_deref() { self.delete_last()?; log::info!("Entry deleted"); } 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"); } Ok(()) diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 01e922e..a3d863d 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -6,11 +6,21 @@ use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents}; use crate::db::{ClipboardDb, Entry, SqliteClipboardDb}; 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 { - 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 { log::info!("Starting clipboard watch daemon"); @@ -46,10 +56,10 @@ impl WatchCommand for SqliteClipboardDb { // Only store if changed and not empty 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 entry = Entry { - contents: last_contents.as_ref().unwrap().clone(), + contents: new_contents.clone(), mime, }; let id = self.next_sequence(); @@ -57,13 +67,27 @@ impl WatchCommand for SqliteClipboardDb { &entry.contents[..], max_dedupe_search, max_items, + Some(excluded_apps), ) { - Ok(_) => log::info!("Stored new clipboard entry (id: {id})"), - Err(e) => log::error!("Failed to store clipboard entry: {e}"), + Ok(_) => { + 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) => { diff --git a/src/db/mod.rs b/src/db/mod.rs index efbbd0c..02984a8 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -8,7 +8,7 @@ use std::{ use base64::{Engine, engine::general_purpose::STANDARD}; use imagesize::{ImageSize, ImageType}; -use log::{error, info, warn}; +use log::{debug, error, info, warn}; use regex::Regex; use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; @@ -24,6 +24,8 @@ pub enum StashError { #[error("Failed to store entry: {0}")] Store(String), + #[error("Entry excluded by app filter: {0}")] + ExcludedByApp(String), #[error("Error reading entry during deduplication: {0}")] DeduplicationRead(String), #[error("Error decoding entry during deduplication: {0}")] @@ -61,6 +63,7 @@ pub trait ClipboardDb { input: impl Read, max_dedupe_search: u64, max_items: u64, + excluded_apps: Option<&[String]>, ) -> Result; fn deduplicate(&self, buf: &[u8], max: u64) -> Result; fn trim_db(&self, max: u64) -> Result<(), StashError>; @@ -110,6 +113,9 @@ impl SqliteClipboardDb { );", ) .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 }) } } @@ -163,6 +169,7 @@ impl ClipboardDb for SqliteClipboardDb { mut input: impl Read, max_dedupe_search: u64, max_items: u64, + excluded_apps: Option<&[String]>, ) -> Result { let mut buf = Vec::new(); 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 @@ -540,3 +555,213 @@ pub fn size_str(size: usize) -> String { } 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 { + // 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 { + 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::() { + 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::(), fields[14].parse::()) + { + 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::() { + 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::(), fields[14].parse::()) + { + 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::() { + 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 +} diff --git a/src/main.rs b/src/main.rs index 0b40450..2c12a80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ use inquire::Confirm; mod commands; mod db; +#[cfg(feature = "use-toplevel")] mod wayland; use crate::commands::{ decode::DecodeCommand, @@ -47,6 +48,11 @@ struct Cli { #[arg(long)] db_path: Option, + /// Application names to exclude from clipboard history + #[cfg(feature = "use-toplevel")] + #[arg(long, value_delimiter = ',', env = "STASH_EXCLUDED_APPS")] + excluded_apps: Vec, + /// Ask for confirmation before destructive operations #[arg(long)] ask: bool, @@ -160,7 +166,16 @@ fn main() { Some(Command::Store) => { let state = env::var("STASH_CLIPBOARD_STATE").ok(); 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", ); }, @@ -313,7 +328,14 @@ fn main() { } }, 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 => { if let Err(e) = Cli::command().print_help() { diff --git a/src/wayland/mod.rs b/src/wayland/mod.rs new file mode 100644 index 0000000..acc449c --- /dev/null +++ b/src/wayland/mod.rs @@ -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> = Mutex::new(None); +static TOPLEVEL_APPS: LazyLock>> = + 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 { + // 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> { + 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 for AppState { + fn event( + _state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _data: &(), + _conn: &WaylandConnection, + qh: &QueueHandle, + ) { + 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, + ) -> std::sync::Arc { + qhandle.make_data::(()) + } +} + +impl Dispatch for AppState { + fn event( + _state: &mut Self, + _manager: &ZwlrForeignToplevelManagerV1, + event: zwlr_foreign_toplevel_manager_v1::Event, + _data: &(), + _conn: &WaylandConnection, + _qh: &QueueHandle, + ) { + 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, + ) -> std::sync::Arc { + qhandle.make_data::(()) + } +} + +impl Dispatch for AppState { + fn event( + _state: &mut Self, + handle: &ZwlrForeignToplevelHandleV1, + event: zwlr_foreign_toplevel_handle_v1::Event, + _data: &(), + _conn: &WaylandConnection, + _qh: &QueueHandle, + ) { + 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 = 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, + ) -> std::sync::Arc { + qhandle.make_data::(()) + } +}