mirror of
https://github.com/NotAShelf/stash.git
synced 2026-04-13 14:33:47 +00:00
Merge pull request #58 from NotAShelf/notashelf/push-oksprvxpxpxt
various: improve robustness of entry tracking in database
This commit is contained in:
commit
4ab9ce4a71
7 changed files with 668 additions and 138 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1712,6 +1712,7 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"smol",
|
"smol",
|
||||||
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-width 0.2.0",
|
"unicode-width 0.2.0",
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,9 @@ wayland-client = { version = "0.31.11", features = ["log"], optional = true }
|
||||||
wayland-protocols-wlr = { version = "0.3.9", default-features = false, optional = true }
|
wayland-protocols-wlr = { version = "0.3.9", default-features = false, optional = true }
|
||||||
notify-rust = { version = "4.11.7", optional = true }
|
notify-rust = { version = "4.11.7", optional = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.18.0"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
clippy,
|
clippy,
|
||||||
taplo,
|
taplo,
|
||||||
rust-analyzer-unwrapped,
|
rust-analyzer-unwrapped,
|
||||||
|
cargo-nextest,
|
||||||
rustPlatform,
|
rustPlatform,
|
||||||
}:
|
}:
|
||||||
mkShell {
|
mkShell {
|
||||||
|
|
@ -20,6 +21,9 @@ mkShell {
|
||||||
cargo
|
cargo
|
||||||
taplo
|
taplo
|
||||||
rust-analyzer-unwrapped
|
rust-analyzer-unwrapped
|
||||||
|
|
||||||
|
# Additional Cargo Tooling
|
||||||
|
cargo-nextest
|
||||||
];
|
];
|
||||||
|
|
||||||
RUST_SRC_PATH = "${rustPlatform.rustLibSrc}";
|
RUST_SRC_PATH = "${rustPlatform.rustLibSrc}";
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,10 @@ impl SqliteClipboardDb {
|
||||||
// Query entries from DB
|
// Query entries from DB
|
||||||
let mut stmt = self
|
let mut stmt = self
|
||||||
.conn
|
.conn
|
||||||
.prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC")
|
.prepare(
|
||||||
|
"SELECT id, contents, mime FROM clipboard ORDER BY last_accessed \
|
||||||
|
DESC, id DESC",
|
||||||
|
)
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||||
let mut rows = stmt
|
let mut rows = stmt
|
||||||
.query([])
|
.query([])
|
||||||
|
|
@ -242,13 +245,15 @@ impl SqliteClipboardDb {
|
||||||
|
|
||||||
if event::poll(std::time::Duration::from_millis(250))
|
if event::poll(std::time::Duration::from_millis(250))
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
|
||||||
{
|
&& let Event::Key(key) = event::read()
|
||||||
if let Event::Key(key) = event::read()
|
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
|
||||||
{
|
{
|
||||||
match (key.code, key.modifiers) {
|
match (key.code, key.modifiers) {
|
||||||
(KeyCode::Char('q') | KeyCode::Esc, _) => break,
|
(KeyCode::Char('q') | KeyCode::Esc, _) => break,
|
||||||
(KeyCode::Down | KeyCode::Char('j'), _) => {
|
(KeyCode::Down | KeyCode::Char('j'), _) => {
|
||||||
|
if entries.is_empty() {
|
||||||
|
state.select(None);
|
||||||
|
} else {
|
||||||
let i = match state.selected() {
|
let i = match state.selected() {
|
||||||
Some(i) => {
|
Some(i) => {
|
||||||
if i >= entries.len() - 1 {
|
if i >= entries.len() - 1 {
|
||||||
|
|
@ -260,8 +265,12 @@ impl SqliteClipboardDb {
|
||||||
None => 0,
|
None => 0,
|
||||||
};
|
};
|
||||||
state.select(Some(i));
|
state.select(Some(i));
|
||||||
},
|
}
|
||||||
(KeyCode::Up | KeyCode::Char('k'), _) => {
|
},
|
||||||
|
(KeyCode::Up | KeyCode::Char('k'), _) => {
|
||||||
|
if entries.is_empty() {
|
||||||
|
state.select(None);
|
||||||
|
} else {
|
||||||
let i = match state.selected() {
|
let i = match state.selected() {
|
||||||
Some(i) => {
|
Some(i) => {
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
|
|
@ -273,27 +282,25 @@ impl SqliteClipboardDb {
|
||||||
None => 0,
|
None => 0,
|
||||||
};
|
};
|
||||||
state.select(Some(i));
|
state.select(Some(i));
|
||||||
},
|
}
|
||||||
(KeyCode::Enter, _) => {
|
},
|
||||||
if let Some(idx) = state.selected() {
|
(KeyCode::Enter, _) => {
|
||||||
if let Some((id, ..)) = entries.get(idx) {
|
if let Some(idx) = state.selected()
|
||||||
// Fetch full contents for the selected entry
|
&& let Some((id, ..)) = entries.get(idx)
|
||||||
let (contents, mime): (Vec<u8>, Option<String>) = self
|
{
|
||||||
.conn
|
match self.copy_entry(*id) {
|
||||||
.query_row(
|
Ok((new_id, contents, mime)) => {
|
||||||
"SELECT contents, mime FROM clipboard WHERE id = ?1",
|
if new_id != *id {
|
||||||
rusqlite::params![id],
|
entries[idx] = (
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
new_id,
|
||||||
)
|
entries[idx].1.clone(),
|
||||||
.map_err(|e| {
|
entries[idx].2.clone(),
|
||||||
StashError::ListDecode(e.to_string().into())
|
);
|
||||||
})?;
|
}
|
||||||
// Copy to clipboard
|
|
||||||
let opts = Options::new();
|
let opts = Options::new();
|
||||||
// Default clipboard is regular, seat is default
|
|
||||||
let mime_type = match mime {
|
let mime_type = match mime {
|
||||||
Some(ref m) if m == "text/plain" => MimeType::Text,
|
Some(ref m) if m == "text/plain" => MimeType::Text,
|
||||||
Some(ref m) => MimeType::Specific(m.clone()),
|
Some(ref m) => MimeType::Specific(m.clone().to_owned()),
|
||||||
None => MimeType::Text,
|
None => MimeType::Text,
|
||||||
};
|
};
|
||||||
let copy_result = opts
|
let copy_result = opts
|
||||||
|
|
@ -313,42 +320,49 @@ impl SqliteClipboardDb {
|
||||||
.show();
|
.show();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
Err(e) => {
|
||||||
},
|
log::error!("Failed to fetch entry {id}: {e}");
|
||||||
(KeyCode::Char('D'), KeyModifiers::SHIFT) => {
|
|
||||||
if let Some(idx) = state.selected() {
|
|
||||||
if let Some((id, ..)) = entries.get(idx) {
|
|
||||||
// Delete entry from DB
|
|
||||||
self
|
|
||||||
.conn
|
|
||||||
.execute(
|
|
||||||
"DELETE FROM clipboard WHERE id = ?1",
|
|
||||||
rusqlite::params![id],
|
|
||||||
)
|
|
||||||
.map_err(|e| {
|
|
||||||
StashError::DeleteEntry(*id, e.to_string().into())
|
|
||||||
})?;
|
|
||||||
// Remove from entries and update selection
|
|
||||||
entries.remove(idx);
|
|
||||||
let new_len = entries.len();
|
|
||||||
if new_len == 0 {
|
|
||||||
state.select(None);
|
|
||||||
} else if idx >= new_len {
|
|
||||||
state.select(Some(new_len - 1));
|
|
||||||
} else {
|
|
||||||
state.select(Some(idx));
|
|
||||||
}
|
|
||||||
// Show notification
|
|
||||||
let _ = Notification::new()
|
let _ = Notification::new()
|
||||||
.summary("Stash")
|
.summary("Stash")
|
||||||
.body("Deleted entry")
|
.body(&format!("Failed to fetch entry: {e}"))
|
||||||
.show();
|
.show();
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
_ => {},
|
},
|
||||||
}
|
(KeyCode::Char('D'), KeyModifiers::SHIFT) => {
|
||||||
|
if let Some(idx) = state.selected()
|
||||||
|
&& let Some((id, ..)) = entries.get(idx)
|
||||||
|
{
|
||||||
|
// Delete entry from DB
|
||||||
|
self
|
||||||
|
.conn
|
||||||
|
.execute(
|
||||||
|
"DELETE FROM clipboard WHERE id = ?1",
|
||||||
|
rusqlite::params![id],
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
StashError::DeleteEntry(*id, e.to_string().into())
|
||||||
|
})?;
|
||||||
|
// Remove from entries and update selection
|
||||||
|
entries.remove(idx);
|
||||||
|
let new_len = entries.len();
|
||||||
|
if new_len == 0 {
|
||||||
|
state.select(None);
|
||||||
|
} else if idx >= new_len {
|
||||||
|
state.select(Some(new_len - 1));
|
||||||
|
} else {
|
||||||
|
state.select(Some(idx));
|
||||||
|
}
|
||||||
|
// Show notification
|
||||||
|
let _ = Notification::new()
|
||||||
|
.summary("Stash")
|
||||||
|
.body("Deleted entry")
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
638
src/db/mod.rs
638
src/db/mod.rs
|
|
@ -89,6 +89,10 @@ pub trait ClipboardDb {
|
||||||
) -> Result<(), StashError>;
|
) -> Result<(), StashError>;
|
||||||
fn delete_query(&self, query: &str) -> Result<usize, StashError>;
|
fn delete_query(&self, query: &str) -> Result<usize, StashError>;
|
||||||
fn delete_entries(&self, input: impl Read) -> Result<usize, StashError>;
|
fn delete_entries(&self, input: impl Read) -> Result<usize, StashError>;
|
||||||
|
fn copy_entry(
|
||||||
|
&self,
|
||||||
|
id: i64,
|
||||||
|
) -> Result<(i64, Vec<u8>, Option<String>), StashError>;
|
||||||
fn next_sequence(&self) -> i64;
|
fn next_sequence(&self) -> i64;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,7 +114,7 @@ pub struct SqliteClipboardDb {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SqliteClipboardDb {
|
impl SqliteClipboardDb {
|
||||||
pub fn new(conn: Connection) -> Result<Self, StashError> {
|
pub fn new(mut conn: Connection) -> Result<Self, StashError> {
|
||||||
conn
|
conn
|
||||||
.pragma_update(None, "synchronous", "OFF")
|
.pragma_update(None, "synchronous", "OFF")
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
|
|
@ -139,26 +143,124 @@ impl SqliteClipboardDb {
|
||||||
conn.pragma_update(None, "page_size", "512") // small(er) pages
|
conn.pragma_update(None, "page_size", "512") // small(er) pages
|
||||||
.map_err(|e| StashError::Store(format!("Failed to set page_size pragma: {e}").into()))?;
|
.map_err(|e| StashError::Store(format!("Failed to set page_size pragma: {e}").into()))?;
|
||||||
|
|
||||||
conn
|
let tx = conn.transaction().map_err(|e| {
|
||||||
.execute_batch(
|
StashError::Store(
|
||||||
|
format!("Failed to begin migration transaction: {e}").into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let schema_version: i64 = tx
|
||||||
|
.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||||
|
.map_err(|e| {
|
||||||
|
StashError::Store(format!("Failed to read schema version: {e}").into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if schema_version == 0 {
|
||||||
|
tx.execute_batch(
|
||||||
"CREATE TABLE IF NOT EXISTS clipboard (
|
"CREATE TABLE IF NOT EXISTS clipboard (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
contents BLOB NOT NULL,
|
contents BLOB NOT NULL,
|
||||||
mime TEXT
|
mime TEXT
|
||||||
);",
|
);",
|
||||||
)
|
)
|
||||||
.map_err(|e| StashError::Store(e.to_string().into()))?;
|
.map_err(|e| {
|
||||||
|
StashError::Store(
|
||||||
|
format!("Failed to create clipboard table: {e}").into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tx.execute("PRAGMA user_version = 1", []).map_err(|e| {
|
||||||
|
StashError::Store(format!("Failed to set schema version: {e}").into())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
// Add content_hash column if it doesn't exist
|
// Add content_hash column if it doesn't exist
|
||||||
// Migration MUST be done to avoid breaking existing installations.
|
// Migration MUST be done to avoid breaking existing installations.
|
||||||
let _ =
|
if schema_version < 2 {
|
||||||
conn.execute("ALTER TABLE clipboard ADD COLUMN content_hash INTEGER", []);
|
let has_content_hash: 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("content_hash"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
// Create index for content_hash if it doesn't exist
|
if !has_content_hash {
|
||||||
let _ = conn.execute(
|
tx.execute("ALTER TABLE clipboard ADD COLUMN content_hash INTEGER", [])
|
||||||
"CREATE INDEX IF NOT EXISTS idx_content_hash ON clipboard(content_hash)",
|
.map_err(|e| {
|
||||||
[],
|
StashError::Store(
|
||||||
);
|
format!("Failed to add content_hash column: {e}").into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create index for content_hash if it doesn't exist
|
||||||
|
tx.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_content_hash ON \
|
||||||
|
clipboard(content_hash)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
StashError::Store(
|
||||||
|
format!("Failed to create content_hash index: {e}").into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tx.execute("PRAGMA user_version = 2", []).map_err(|e| {
|
||||||
|
StashError::Store(format!("Failed to set schema version: {e}").into())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add last_accessed column if it doesn't exist
|
||||||
|
if schema_version < 3 {
|
||||||
|
let has_last_accessed: 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("last_accessed"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !has_last_accessed {
|
||||||
|
tx.execute("ALTER TABLE clipboard ADD COLUMN last_accessed INTEGER", [
|
||||||
|
])
|
||||||
|
.map_err(|e| {
|
||||||
|
StashError::Store(
|
||||||
|
format!("Failed to add last_accessed column: {e}").into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create index for last_accessed if it doesn't exist
|
||||||
|
tx.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_last_accessed ON \
|
||||||
|
clipboard(last_accessed)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
StashError::Store(
|
||||||
|
format!("Failed to create last_accessed index: {e}").into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tx.execute("PRAGMA user_version = 3", []).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(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
// Initialize Wayland state in background thread. This will be used to track
|
// Initialize Wayland state in background thread. This will be used to track
|
||||||
// focused window state.
|
// focused window state.
|
||||||
|
|
@ -172,7 +274,10 @@ impl SqliteClipboardDb {
|
||||||
pub fn list_json(&self) -> Result<String, StashError> {
|
pub fn list_json(&self) -> Result<String, StashError> {
|
||||||
let mut stmt = self
|
let mut stmt = self
|
||||||
.conn
|
.conn
|
||||||
.prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC")
|
.prepare(
|
||||||
|
"SELECT id, contents, mime FROM clipboard ORDER BY \
|
||||||
|
COALESCE(last_accessed, 0) DESC, id DESC",
|
||||||
|
)
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||||
let mut rows = stmt
|
let mut rows = stmt
|
||||||
.query([])
|
.query([])
|
||||||
|
|
@ -243,11 +348,11 @@ impl ClipboardDb for SqliteClipboardDb {
|
||||||
let regex = load_sensitive_regex();
|
let regex = load_sensitive_regex();
|
||||||
if let Some(re) = regex {
|
if let Some(re) = regex {
|
||||||
// Only check text data
|
// Only check text data
|
||||||
if let Ok(s) = std::str::from_utf8(&buf) {
|
if let Ok(s) = std::str::from_utf8(&buf)
|
||||||
if re.is_match(s) {
|
&& re.is_match(s)
|
||||||
warn!("Clipboard entry matches sensitive regex, skipping store.");
|
{
|
||||||
return Err(StashError::Store("Filtered by sensitive regex".into()));
|
warn!("Clipboard entry matches sensitive regex, skipping store.");
|
||||||
}
|
return Err(StashError::Store("Filtered by sensitive regex".into()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -264,14 +369,27 @@ impl ClipboardDb for SqliteClipboardDb {
|
||||||
self
|
self
|
||||||
.conn
|
.conn
|
||||||
.execute(
|
.execute(
|
||||||
"INSERT INTO clipboard (contents, mime, content_hash) VALUES (?1, ?2, \
|
"INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \
|
||||||
?3)",
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
params![buf, mime, content_hash],
|
params![
|
||||||
|
buf,
|
||||||
|
mime,
|
||||||
|
content_hash,
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.expect("Time went backwards")
|
||||||
|
.as_secs() as i64
|
||||||
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| StashError::Store(e.to_string().into()))?;
|
.map_err(|e| StashError::Store(e.to_string().into()))?;
|
||||||
|
|
||||||
|
let id = self
|
||||||
|
.conn
|
||||||
|
.query_row("SELECT last_insert_rowid()", [], |row| row.get(0))
|
||||||
|
.map_err(|e| StashError::Store(e.to_string().into()))?;
|
||||||
|
|
||||||
self.trim_db(max_items)?;
|
self.trim_db(max_items)?;
|
||||||
Ok(self.next_sequence())
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn deduplicate_by_hash(
|
fn deduplicate_by_hash(
|
||||||
|
|
@ -317,6 +435,8 @@ impl ClipboardDb for SqliteClipboardDb {
|
||||||
let max_i64 = i64::try_from(max).unwrap_or(i64::MAX);
|
let max_i64 = i64::try_from(max).unwrap_or(i64::MAX);
|
||||||
if count > max_i64 {
|
if count > max_i64 {
|
||||||
let to_delete = count - max_i64;
|
let to_delete = count - max_i64;
|
||||||
|
|
||||||
|
#[allow(clippy::useless_conversion)]
|
||||||
self
|
self
|
||||||
.conn
|
.conn
|
||||||
.execute(
|
.execute(
|
||||||
|
|
@ -369,7 +489,10 @@ impl ClipboardDb for SqliteClipboardDb {
|
||||||
) -> Result<usize, StashError> {
|
) -> Result<usize, StashError> {
|
||||||
let mut stmt = self
|
let mut stmt = self
|
||||||
.conn
|
.conn
|
||||||
.prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC")
|
.prepare(
|
||||||
|
"SELECT id, contents, mime FROM clipboard ORDER BY \
|
||||||
|
COALESCE(last_accessed, 0) DESC, id DESC",
|
||||||
|
)
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||||
let mut rows = stmt
|
let mut rows = stmt
|
||||||
.query([])
|
.query([])
|
||||||
|
|
@ -476,6 +599,48 @@ impl ClipboardDb for SqliteClipboardDb {
|
||||||
Ok(deleted)
|
Ok(deleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn copy_entry(
|
||||||
|
&self,
|
||||||
|
id: i64,
|
||||||
|
) -> Result<(i64, Vec<u8>, Option<String>), StashError> {
|
||||||
|
let (contents, mime, content_hash): (Vec<u8>, Option<String>, Option<i64>) =
|
||||||
|
self
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT contents, mime, content_hash FROM clipboard WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||||
|
)
|
||||||
|
.map_err(|e| StashError::DecodeGet(e.to_string().into()))?;
|
||||||
|
|
||||||
|
if let Some(hash) = content_hash {
|
||||||
|
let most_recent_id: Option<i64> = self
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT id FROM clipboard WHERE content_hash = ?1 AND last_accessed \
|
||||||
|
= (SELECT MAX(last_accessed) FROM clipboard WHERE content_hash = \
|
||||||
|
?1)",
|
||||||
|
params![hash],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| StashError::DecodeGet(e.to_string().into()))?;
|
||||||
|
|
||||||
|
if most_recent_id != Some(id) {
|
||||||
|
self
|
||||||
|
.conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE clipboard SET last_accessed = CAST(strftime('%s', 'now') \
|
||||||
|
AS INTEGER) WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
)
|
||||||
|
.map_err(|e| StashError::Store(e.to_string().into()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((id, contents, mime))
|
||||||
|
}
|
||||||
|
|
||||||
fn next_sequence(&self) -> i64 {
|
fn next_sequence(&self) -> i64 {
|
||||||
match self
|
match self
|
||||||
.conn
|
.conn
|
||||||
|
|
@ -560,6 +725,7 @@ pub fn detect_mime(data: &[u8]) -> Option<String> {
|
||||||
ImageType::Qoi => "image/qoi",
|
ImageType::Qoi => "image/qoi",
|
||||||
ImageType::Tga => "image/x-tga",
|
ImageType::Tga => "image/x-tga",
|
||||||
ImageType::Vtf => "image/x-vtf",
|
ImageType::Vtf => "image/x-vtf",
|
||||||
|
ImageType::Heif(imagesize::Compression::Hevc) => "image/heic",
|
||||||
ImageType::Heif(_) => "image/heif",
|
ImageType::Heif(_) => "image/heif",
|
||||||
_ => "application/octet-stream",
|
_ => "application/octet-stream",
|
||||||
};
|
};
|
||||||
|
|
@ -693,11 +859,11 @@ fn get_focused_window_app() -> Option<String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Check WAYLAND_CLIENT_NAME environment variable
|
// Fallback: Check WAYLAND_CLIENT_NAME environment variable
|
||||||
if let Ok(client) = env::var("WAYLAND_CLIENT_NAME") {
|
if let Ok(client) = env::var("WAYLAND_CLIENT_NAME")
|
||||||
if !client.is_empty() {
|
&& !client.is_empty()
|
||||||
debug!("Found WAYLAND_CLIENT_NAME: {client}");
|
{
|
||||||
return Some(client);
|
debug!("Found WAYLAND_CLIENT_NAME: {client}");
|
||||||
}
|
return Some(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!("No focused window detection method worked");
|
debug!("No focused window detection method worked");
|
||||||
|
|
@ -717,19 +883,17 @@ fn get_recently_active_excluded_app(
|
||||||
|
|
||||||
if let Ok(entries) = std::fs::read_dir(proc_dir) {
|
if let Ok(entries) = std::fs::read_dir(proc_dir) {
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
if let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() {
|
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 Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm"))
|
||||||
let process_name = comm.trim();
|
{
|
||||||
|
let process_name = comm.trim();
|
||||||
|
|
||||||
// Check process name against exclusion list
|
// Check process name against exclusion list
|
||||||
if app_matches_exclusion(process_name, excluded_apps)
|
if app_matches_exclusion(process_name, excluded_apps)
|
||||||
&& has_recent_activity(pid)
|
&& has_recent_activity(pid)
|
||||||
{
|
{
|
||||||
candidates.push((
|
candidates
|
||||||
process_name.to_string(),
|
.push((process_name.to_string(), get_process_activity_score(pid)));
|
||||||
get_process_activity_score(pid),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -763,15 +927,13 @@ fn has_recent_activity(pid: u32) -> bool {
|
||||||
// Check /proc/PID/io for recent I/O activity
|
// Check /proc/PID/io for recent I/O activity
|
||||||
if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) {
|
if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) {
|
||||||
for line in io_stats.lines() {
|
for line in io_stats.lines() {
|
||||||
if line.starts_with("write_bytes:") || line.starts_with("read_bytes:") {
|
if (line.starts_with("write_bytes:") || line.starts_with("read_bytes:"))
|
||||||
if let Some(value_str) = line.split(':').nth(1) {
|
&& let Some(value_str) = line.split(':').nth(1)
|
||||||
if let Ok(value) = value_str.trim().parse::<u64>() {
|
&& let Ok(value) = value_str.trim().parse::<u64>()
|
||||||
if value > 1024 * 1024 {
|
&& value > 1024 * 1024
|
||||||
// 1MB threshold
|
{
|
||||||
return true;
|
// 1MB threshold
|
||||||
}
|
return true;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -786,24 +948,22 @@ fn get_process_activity_score(pid: u32) -> u64 {
|
||||||
// Add CPU time to score
|
// Add CPU time to score
|
||||||
if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
|
if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
|
||||||
let fields: Vec<&str> = stat.split_whitespace().collect();
|
let fields: Vec<&str> = stat.split_whitespace().collect();
|
||||||
if fields.len() > 14 {
|
if fields.len() > 14
|
||||||
if let (Ok(utime), Ok(stime)) =
|
&& let (Ok(utime), Ok(stime)) =
|
||||||
(fields[13].parse::<u64>(), fields[14].parse::<u64>())
|
(fields[13].parse::<u64>(), fields[14].parse::<u64>())
|
||||||
{
|
{
|
||||||
score += utime + stime;
|
score += utime + stime;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add I/O activity to score
|
// Add I/O activity to score
|
||||||
if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) {
|
if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) {
|
||||||
for line in io_stats.lines() {
|
for line in io_stats.lines() {
|
||||||
if line.starts_with("write_bytes:") || line.starts_with("read_bytes:") {
|
if (line.starts_with("write_bytes:") || line.starts_with("read_bytes:"))
|
||||||
if let Some(value_str) = line.split(':').nth(1) {
|
&& let Some(value_str) = line.split(':').nth(1)
|
||||||
if let Ok(value) = value_str.trim().parse::<u64>() {
|
&& let Ok(value) = value_str.trim().parse::<u64>()
|
||||||
score += value / 1024; // convert to KB
|
{
|
||||||
}
|
score += value / 1024; // convert to KB
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -834,11 +994,11 @@ fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool {
|
||||||
} else if excluded.contains('*') {
|
} else if excluded.contains('*') {
|
||||||
// Simple wildcard matching
|
// Simple wildcard matching
|
||||||
let pattern = excluded.replace('*', ".*");
|
let pattern = excluded.replace('*', ".*");
|
||||||
if let Ok(regex) = regex::Regex::new(&pattern) {
|
if let Ok(regex) = regex::Regex::new(&pattern)
|
||||||
if regex.is_match(app_name) {
|
&& regex.is_match(app_name)
|
||||||
debug!("Matched wildcard pattern: {app_name} matches {excluded}");
|
{
|
||||||
return true;
|
debug!("Matched wildcard pattern: {app_name} matches {excluded}");
|
||||||
}
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -846,3 +1006,353 @@ fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool {
|
||||||
debug!("No match found for '{app_name}'");
|
debug!("No match found for '{app_name}'");
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use rusqlite::Connection;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn get_schema_version(conn: &Connection) -> rusqlite::Result<i64> {
|
||||||
|
conn.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn table_column_exists(conn: &Connection, table: &str, column: &str) -> bool {
|
||||||
|
let query = format!(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='{}'",
|
||||||
|
table
|
||||||
|
);
|
||||||
|
match conn.query_row(&query, [], |row| row.get::<_, String>(0)) {
|
||||||
|
Ok(sql) => sql.contains(column),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_exists(conn: &Connection, index: &str) -> bool {
|
||||||
|
let query = "SELECT name FROM sqlite_master WHERE type='index' AND name=?1";
|
||||||
|
conn
|
||||||
|
.query_row(query, [index], |row| row.get::<_, String>(0))
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fresh_database_v3_schema() {
|
||||||
|
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
|
||||||
|
let db_path = temp_dir.path().join("test_fresh.db");
|
||||||
|
let conn = Connection::open(&db_path).expect("Failed to open database");
|
||||||
|
|
||||||
|
let db = SqliteClipboardDb::new(conn).expect("Failed to create database");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
get_schema_version(&db.conn).expect("Failed to get schema version"),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(table_column_exists(&db.conn, "clipboard", "content_hash"));
|
||||||
|
assert!(table_column_exists(&db.conn, "clipboard", "last_accessed"));
|
||||||
|
|
||||||
|
assert!(index_exists(&db.conn, "idx_content_hash"));
|
||||||
|
assert!(index_exists(&db.conn, "idx_last_accessed"));
|
||||||
|
|
||||||
|
db.conn
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \
|
||||||
|
VALUES (x'010203', 'text/plain', 12345, 1704067200)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.expect("Failed to insert test data");
|
||||||
|
|
||||||
|
let count: i64 = db
|
||||||
|
.conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0))
|
||||||
|
.expect("Failed to count");
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_from_v0() {
|
||||||
|
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
|
||||||
|
let db_path = temp_dir.path().join("test_v0.db");
|
||||||
|
let conn = Connection::open(&db_path).expect("Failed to open database");
|
||||||
|
|
||||||
|
conn
|
||||||
|
.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \
|
||||||
|
AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT);",
|
||||||
|
)
|
||||||
|
.expect("Failed to create table");
|
||||||
|
|
||||||
|
conn
|
||||||
|
.execute_batch(
|
||||||
|
"INSERT INTO clipboard (contents, mime) VALUES (x'010203', \
|
||||||
|
'text/plain')",
|
||||||
|
)
|
||||||
|
.expect("Failed to insert data");
|
||||||
|
|
||||||
|
assert_eq!(get_schema_version(&conn).expect("Failed to get version"), 0);
|
||||||
|
|
||||||
|
let db = SqliteClipboardDb::new(conn).expect("Failed to create database");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
get_schema_version(&db.conn)
|
||||||
|
.expect("Failed to get version after migration"),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(table_column_exists(&db.conn, "clipboard", "content_hash"));
|
||||||
|
assert!(table_column_exists(&db.conn, "clipboard", "last_accessed"));
|
||||||
|
|
||||||
|
let count: i64 = db
|
||||||
|
.conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0))
|
||||||
|
.expect("Failed to count");
|
||||||
|
assert_eq!(count, 1, "Existing data should be preserved");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_from_v1() {
|
||||||
|
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
|
||||||
|
let db_path = temp_dir.path().join("test_v1.db");
|
||||||
|
let conn = Connection::open(&db_path).expect("Failed to open database");
|
||||||
|
|
||||||
|
conn
|
||||||
|
.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \
|
||||||
|
AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT);",
|
||||||
|
)
|
||||||
|
.expect("Failed to create table");
|
||||||
|
|
||||||
|
conn
|
||||||
|
.pragma_update(None, "user_version", 1i64)
|
||||||
|
.expect("Failed to set version");
|
||||||
|
|
||||||
|
conn
|
||||||
|
.execute_batch(
|
||||||
|
"INSERT INTO clipboard (contents, mime) VALUES (x'010203', \
|
||||||
|
'text/plain')",
|
||||||
|
)
|
||||||
|
.expect("Failed to insert data");
|
||||||
|
|
||||||
|
let db = SqliteClipboardDb::new(conn).expect("Failed to create database");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
get_schema_version(&db.conn)
|
||||||
|
.expect("Failed to get version after migration"),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(table_column_exists(&db.conn, "clipboard", "content_hash"));
|
||||||
|
assert!(table_column_exists(&db.conn, "clipboard", "last_accessed"));
|
||||||
|
|
||||||
|
let count: i64 = db
|
||||||
|
.conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0))
|
||||||
|
.expect("Failed to count");
|
||||||
|
assert_eq!(count, 1, "Existing data should be preserved");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_from_v2() {
|
||||||
|
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
|
||||||
|
let db_path = temp_dir.path().join("test_v2.db");
|
||||||
|
let conn = Connection::open(&db_path).expect("Failed to open database");
|
||||||
|
|
||||||
|
conn
|
||||||
|
.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \
|
||||||
|
AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT, content_hash \
|
||||||
|
INTEGER);",
|
||||||
|
)
|
||||||
|
.expect("Failed to create table");
|
||||||
|
|
||||||
|
conn
|
||||||
|
.pragma_update(None, "user_version", 2i64)
|
||||||
|
.expect("Failed to set version");
|
||||||
|
|
||||||
|
conn
|
||||||
|
.execute_batch(
|
||||||
|
"INSERT INTO clipboard (contents, mime, content_hash) VALUES \
|
||||||
|
(x'010203', 'text/plain', 12345)",
|
||||||
|
)
|
||||||
|
.expect("Failed to insert data");
|
||||||
|
|
||||||
|
let db = SqliteClipboardDb::new(conn).expect("Failed to create database");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
get_schema_version(&db.conn)
|
||||||
|
.expect("Failed to get version after migration"),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(table_column_exists(&db.conn, "clipboard", "last_accessed"));
|
||||||
|
assert!(index_exists(&db.conn, "idx_last_accessed"));
|
||||||
|
|
||||||
|
let count: i64 = db
|
||||||
|
.conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0))
|
||||||
|
.expect("Failed to count");
|
||||||
|
assert_eq!(count, 1, "Existing data should be preserved");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_idempotent_migration() {
|
||||||
|
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
|
||||||
|
let db_path = temp_dir.path().join("test_idempotent.db");
|
||||||
|
let conn = Connection::open(&db_path).expect("Failed to open database");
|
||||||
|
|
||||||
|
conn
|
||||||
|
.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \
|
||||||
|
AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT);",
|
||||||
|
)
|
||||||
|
.expect("Failed to create table");
|
||||||
|
|
||||||
|
let db = SqliteClipboardDb::new(conn).expect("Failed to create database");
|
||||||
|
let version_after_first =
|
||||||
|
get_schema_version(&db.conn).expect("Failed to get version");
|
||||||
|
|
||||||
|
let db2 =
|
||||||
|
SqliteClipboardDb::new(db.conn).expect("Failed to create database again");
|
||||||
|
let version_after_second =
|
||||||
|
get_schema_version(&db2.conn).expect("Failed to get version");
|
||||||
|
|
||||||
|
assert_eq!(version_after_first, version_after_second);
|
||||||
|
assert_eq!(version_after_first, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_store_and_retrieve_with_new_columns() {
|
||||||
|
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
|
||||||
|
let db_path = temp_dir.path().join("test_store.db");
|
||||||
|
let conn = Connection::open(&db_path).expect("Failed to open database");
|
||||||
|
let db = SqliteClipboardDb::new(conn).expect("Failed to create database");
|
||||||
|
|
||||||
|
let test_data = b"Hello, World!";
|
||||||
|
let cursor = std::io::Cursor::new(test_data.to_vec());
|
||||||
|
|
||||||
|
let id = db
|
||||||
|
.store_entry(cursor, 100, 1000, None)
|
||||||
|
.expect("Failed to store entry");
|
||||||
|
|
||||||
|
let content_hash: Option<i64> = db
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT content_hash FROM clipboard WHERE id = ?1",
|
||||||
|
[id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.expect("Failed to get content_hash");
|
||||||
|
|
||||||
|
let last_accessed: Option<i64> = db
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT last_accessed FROM clipboard WHERE id = ?1",
|
||||||
|
[id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.expect("Failed to get last_accessed");
|
||||||
|
|
||||||
|
assert!(content_hash.is_some(), "content_hash should be set");
|
||||||
|
assert!(last_accessed.is_some(), "last_accessed should be set");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_last_accessed_updated_on_copy() {
|
||||||
|
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
|
||||||
|
let db_path = temp_dir.path().join("test_copy.db");
|
||||||
|
let conn = Connection::open(&db_path).expect("Failed to open database");
|
||||||
|
let db = SqliteClipboardDb::new(conn).expect("Failed to create database");
|
||||||
|
|
||||||
|
let test_data = b"Test content for copy";
|
||||||
|
let cursor = std::io::Cursor::new(test_data.to_vec());
|
||||||
|
let id_a = db
|
||||||
|
.store_entry(cursor, 100, 1000, None)
|
||||||
|
.expect("Failed to store entry A");
|
||||||
|
|
||||||
|
let original_last_accessed: i64 = db
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT last_accessed FROM clipboard WHERE id = ?1",
|
||||||
|
[id_a],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.expect("Failed to get last_accessed");
|
||||||
|
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||||
|
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
test_data.hash(&mut hasher);
|
||||||
|
let content_hash = hasher.finish() as i64;
|
||||||
|
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.expect("Time went backwards")
|
||||||
|
.as_secs() as i64;
|
||||||
|
|
||||||
|
db.conn
|
||||||
|
.execute(
|
||||||
|
"INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \
|
||||||
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
params![test_data as &[u8], "text/plain", content_hash, now],
|
||||||
|
)
|
||||||
|
.expect("Failed to insert entry B directly");
|
||||||
|
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||||
|
|
||||||
|
let (..) = db.copy_entry(id_a).expect("Failed to copy entry");
|
||||||
|
|
||||||
|
let new_last_accessed: i64 = db
|
||||||
|
.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT last_accessed FROM clipboard WHERE id = ?1",
|
||||||
|
[id_a],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.expect("Failed to get updated last_accessed");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
new_last_accessed > original_last_accessed,
|
||||||
|
"last_accessed should be updated when copying an entry that is not the \
|
||||||
|
most recent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_migration_with_existing_columns_but_v0() {
|
||||||
|
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
|
||||||
|
let db_path = temp_dir.path().join("test_v0_with_cols.db");
|
||||||
|
let conn = Connection::open(&db_path).expect("Failed to open database");
|
||||||
|
|
||||||
|
conn
|
||||||
|
.execute_batch(
|
||||||
|
"CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \
|
||||||
|
AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT, content_hash \
|
||||||
|
INTEGER, last_accessed INTEGER);",
|
||||||
|
)
|
||||||
|
.expect("Failed to create table with all columns");
|
||||||
|
|
||||||
|
conn
|
||||||
|
.pragma_update(None, "user_version", 0i64)
|
||||||
|
.expect("Failed to set version to 0");
|
||||||
|
|
||||||
|
conn
|
||||||
|
.execute_batch(
|
||||||
|
"INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \
|
||||||
|
VALUES (x'010203', 'text/plain', 12345, 1704067200)",
|
||||||
|
)
|
||||||
|
.expect("Failed to insert data");
|
||||||
|
|
||||||
|
let db = SqliteClipboardDb::new(conn).expect("Failed to create database");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
get_schema_version(&db.conn).expect("Failed to get version"),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
|
||||||
|
let count: i64 = db
|
||||||
|
.conn
|
||||||
|
.query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0))
|
||||||
|
.expect("Failed to count");
|
||||||
|
assert_eq!(count, 1, "Existing data should be preserved");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use clap::{CommandFactory, Parser, Subcommand};
|
||||||
use inquire::Confirm;
|
use inquire::Confirm;
|
||||||
|
|
||||||
mod commands;
|
mod commands;
|
||||||
mod db;
|
pub(crate) mod db;
|
||||||
mod multicall;
|
mod multicall;
|
||||||
#[cfg(feature = "use-toplevel")] mod wayland;
|
#[cfg(feature = "use-toplevel")] mod wayland;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,11 @@ pub fn init_wayland_state() {
|
||||||
/// Get the currently focused window application name using Wayland protocols
|
/// Get the currently focused window application name using Wayland protocols
|
||||||
pub fn get_focused_window_app() -> Option<String> {
|
pub fn get_focused_window_app() -> Option<String> {
|
||||||
// Try Wayland protocol first
|
// Try Wayland protocol first
|
||||||
if let Ok(focused) = FOCUSED_APP.lock() {
|
if let Ok(focused) = FOCUSED_APP.lock()
|
||||||
if let Some(ref app) = *focused {
|
&& let Some(ref app) = *focused
|
||||||
debug!("Found focused app via Wayland protocol: {app}");
|
{
|
||||||
return Some(app.clone());
|
debug!("Found focused app via Wayland protocol: {app}");
|
||||||
}
|
return Some(app.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!("No focused window detection method worked");
|
debug!("No focused window detection method worked");
|
||||||
|
|
@ -81,11 +81,10 @@ impl Dispatch<wl_registry::WlRegistry, ()> for AppState {
|
||||||
interface,
|
interface,
|
||||||
version: _,
|
version: _,
|
||||||
} = event
|
} = event
|
||||||
|
&& interface == "zwlr_foreign_toplevel_manager_v1"
|
||||||
{
|
{
|
||||||
if interface == "zwlr_foreign_toplevel_manager_v1" {
|
let _manager: ZwlrForeignToplevelManagerV1 =
|
||||||
let _manager: ZwlrForeignToplevelManagerV1 =
|
registry.bind(name, 1, qh, ());
|
||||||
registry.bind(name, 1, qh, ());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,11 +154,10 @@ impl Dispatch<ZwlrForeignToplevelHandleV1, ()> for AppState {
|
||||||
// Update focused app to the `app_id` of this handle
|
// Update focused app to the `app_id` of this handle
|
||||||
if let (Ok(apps), Ok(mut focused)) =
|
if let (Ok(apps), Ok(mut focused)) =
|
||||||
(TOPLEVEL_APPS.lock(), FOCUSED_APP.lock())
|
(TOPLEVEL_APPS.lock(), FOCUSED_APP.lock())
|
||||||
|
&& let Some(app_id) = apps.get(&handle_id)
|
||||||
{
|
{
|
||||||
if let Some(app_id) = apps.get(&handle_id) {
|
debug!("Setting focused app to: {app_id}");
|
||||||
debug!("Setting focused app to: {app_id}");
|
*focused = Some(app_id.clone());
|
||||||
*focused = Some(app_id.clone());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue