mirror of
https://github.com/NotAShelf/stash.git
synced 2026-04-13 06:23:47 +00:00
stash: blocking persistent entries by window class
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I6a6a6964061bd97b4ffc4e84d835072331a966c6
This commit is contained in:
parent
e5204c4a3a
commit
36c183742d
7 changed files with 483 additions and 19 deletions
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
11
Cargo.toml
11
Cargo.toml
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
227
src/db/mod.rs
227
src/db/mod.rs
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
26
src/main.rs
26
src/main.rs
|
|
@ -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
176
src/wayland/mod.rs
Normal 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, ()>(())
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue