mirror of
https://github.com/NotAShelf/stash.git
synced 2026-05-07 07:55:24 +00:00
Compare commits
1 commit
main
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
056401c2a5 |
10 changed files with 254 additions and 332 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -1608,9 +1608,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notify-rust"
|
name = "notify-rust"
|
||||||
version = "4.14.0"
|
version = "4.16.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1b2c9bc1689653cfbc04400b8719f2562638ff9c545bbd48cc58c657a14526df"
|
checksum = "5e551a9f0db223eaf3eb156906f99f46897fd951ee66dd1cb0be14db4d36d2fa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
"log",
|
"log",
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ inquire = { version = "0.9.4", default-features = false, features
|
||||||
libc = "0.2.185"
|
libc = "0.2.185"
|
||||||
log = "0.4.29"
|
log = "0.4.29"
|
||||||
mime-sniffer = "0.1.3"
|
mime-sniffer = "0.1.3"
|
||||||
notify-rust = { version = "4.14.0", optional = true }
|
notify-rust = { version = "4.16.0", optional = true }
|
||||||
ratatui = "0.30.0"
|
ratatui = "0.30.0"
|
||||||
regex = "1.12.3"
|
regex = "1.12.3"
|
||||||
rusqlite = { version = "0.39.0", features = [ "bundled" ] }
|
rusqlite = { version = "0.39.0", features = [ "bundled" ] }
|
||||||
|
|
|
||||||
12
flake.lock
generated
12
flake.lock
generated
|
|
@ -2,11 +2,11 @@
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"crane": {
|
"crane": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777830388,
|
"lastModified": 1776635034,
|
||||||
"narHash": "sha256-2uoQAqUk2H0ijQtGiWAyNeQYGYc6yfAcRRLlJAz4Gp8=",
|
"narHash": "sha256-OEOJrT3ZfwbChzODfIH4GzlNTtOFuZFWPtW7jIeR8xU=",
|
||||||
"owner": "ipetkov",
|
"owner": "ipetkov",
|
||||||
"repo": "crane",
|
"repo": "crane",
|
||||||
"rev": "d459c1350e96ce1a7e3859c513ef5e9869d67d6f",
|
"rev": "dc7496d8ea6e526b1254b55d09b966e94673750f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -17,11 +17,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1777954456,
|
"lastModified": 1775710090,
|
||||||
"narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=",
|
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1",
|
"rev": "4c1018dae018162ec878d42fec712642d214fdfa",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,8 @@ fn serve_clipboard_child(prepared: PreparedCopy) {
|
||||||
},
|
},
|
||||||
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::debug!("clipboard persistence: serve ended: {e}");
|
log::error!("clipboard persistence: serve failed: {e}");
|
||||||
|
exit(1);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -698,7 +698,7 @@ impl SqliteClipboardDb {
|
||||||
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().clone()),
|
Some(ref m) => MimeType::Specific(m.clone().clone()),
|
||||||
None => MimeType::Autodetect,
|
None => MimeType::Text,
|
||||||
};
|
};
|
||||||
let copy_result = opts
|
let copy_result = opts
|
||||||
.copy(Source::Bytes(contents.clone().into()), mime_type);
|
.copy(Source::Bytes(contents.clone().into()), mime_type);
|
||||||
|
|
|
||||||
|
|
@ -435,14 +435,6 @@ impl WatchCommand for SqliteClipboardDb {
|
||||||
log::info!("clipboard entry excluded by app filter");
|
log::info!("clipboard entry excluded by app filter");
|
||||||
last_hash = Some(current_hash);
|
last_hash = Some(current_hash);
|
||||||
},
|
},
|
||||||
Err(crate::db::StashError::AllWhitespace) => {
|
|
||||||
log::debug!("clipboard entry is all whitespace, skipping");
|
|
||||||
last_hash = Some(current_hash);
|
|
||||||
},
|
|
||||||
Err(crate::db::StashError::TooSmall(_)) => {
|
|
||||||
log::debug!("clipboard entry below minimum size, skipping");
|
|
||||||
last_hash = Some(current_hash);
|
|
||||||
},
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("failed to store clipboard entry: {e}");
|
log::error!("failed to store clipboard entry: {e}");
|
||||||
last_hash = Some(current_hash);
|
last_hash = Some(current_hash);
|
||||||
|
|
@ -526,8 +518,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pick_image_preference_falls_back() {
|
fn test_pick_image_preference_falls_back() {
|
||||||
// No image types in offer set; first type is used as fallback.
|
|
||||||
let offered = vec!["text/html".to_string(), "text/plain".to_string()];
|
let offered = vec!["text/html".to_string(), "text/plain".to_string()];
|
||||||
|
// No image types offered — falls back to first
|
||||||
assert_eq!(pick_mime(&offered, "image").unwrap(), "text/html");
|
assert_eq!(pick_mime(&offered, "image").unwrap(), "text/html");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -558,14 +550,14 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pick_html_fallback_when_only_html() {
|
fn test_pick_html_fallback_when_only_html() {
|
||||||
// text/html is used when it is the only offered type.
|
// When text/html is the only type, pick it
|
||||||
let offered = vec!["text/html".to_string()];
|
let offered = vec!["text/html".to_string()];
|
||||||
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/html");
|
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/html");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pick_text_over_html_when_no_image() {
|
fn test_pick_text_over_html_when_no_image() {
|
||||||
// html + plain with no image type; plain text wins over html.
|
// Rich text copy: html + plain, no image — prefer plain text
|
||||||
let offered = vec!["text/html".to_string(), "text/plain".to_string()];
|
let offered = vec!["text/html".to_string(), "text/plain".to_string()];
|
||||||
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/plain");
|
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/plain");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
500
src/db/mod.rs
500
src/db/mod.rs
|
|
@ -338,87 +338,215 @@ impl SqliteClipboardDb {
|
||||||
if schema_version == 0 {
|
if schema_version == 0 {
|
||||||
tx.execute_batch(
|
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(migration_err)?;
|
.map_err(|e| {
|
||||||
tx.pragma_update(None, "user_version", 1i64)
|
StashError::Store(
|
||||||
.map_err(migration_err)?;
|
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. Migration MUST be done to
|
||||||
|
// avoid breaking existing installations.
|
||||||
if schema_version < 2 {
|
if schema_version < 2 {
|
||||||
if !column_exists(&tx, "content_hash") {
|
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);
|
||||||
|
|
||||||
|
if !has_content_hash {
|
||||||
tx.execute("ALTER TABLE clipboard ADD COLUMN content_hash INTEGER", [])
|
tx.execute("ALTER TABLE clipboard ADD COLUMN content_hash INTEGER", [])
|
||||||
.map_err(migration_err)?;
|
.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(
|
tx.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_content_hash ON \
|
"CREATE INDEX IF NOT EXISTS idx_content_hash ON \
|
||||||
clipboard(content_hash)",
|
clipboard(content_hash)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(migration_err)?;
|
.map_err(|e| {
|
||||||
tx.pragma_update(None, "user_version", 2i64)
|
StashError::Store(
|
||||||
.map_err(migration_err)?;
|
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 {
|
if schema_version < 3 {
|
||||||
if !column_exists(&tx, "last_accessed") {
|
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", [
|
tx.execute("ALTER TABLE clipboard ADD COLUMN last_accessed INTEGER", [
|
||||||
])
|
])
|
||||||
.map_err(migration_err)?;
|
.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(
|
tx.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_last_accessed ON \
|
"CREATE INDEX IF NOT EXISTS idx_last_accessed ON \
|
||||||
clipboard(last_accessed)",
|
clipboard(last_accessed)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(migration_err)?;
|
.map_err(|e| {
|
||||||
tx.pragma_update(None, "user_version", 3i64)
|
StashError::Store(
|
||||||
.map_err(migration_err)?;
|
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())
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add expires_at column if it doesn't exist (v4)
|
||||||
if schema_version < 4 {
|
if schema_version < 4 {
|
||||||
if !column_exists(&tx, "expires_at") {
|
let has_expires_at: bool = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type='table' AND \
|
||||||
|
name='clipboard'",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
let sql: String = row.get(0)?;
|
||||||
|
Ok(sql.to_lowercase().contains("expires_at"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !has_expires_at {
|
||||||
tx.execute("ALTER TABLE clipboard ADD COLUMN expires_at REAL", [])
|
tx.execute("ALTER TABLE clipboard ADD COLUMN expires_at REAL", [])
|
||||||
.map_err(migration_err)?;
|
.map_err(|e| {
|
||||||
|
StashError::Store(
|
||||||
|
format!("Failed to add expires_at column: {e}").into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create partial index for expires_at (only index non-NULL values)
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_expires_at ON clipboard(expires_at) \
|
"CREATE INDEX IF NOT EXISTS idx_expires_at ON clipboard(expires_at) \
|
||||||
WHERE expires_at IS NOT NULL",
|
WHERE expires_at IS NOT NULL",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(migration_err)?;
|
.map_err(|e| {
|
||||||
tx.pragma_update(None, "user_version", 4i64)
|
StashError::Store(
|
||||||
.map_err(migration_err)?;
|
format!("Failed to create expires_at index: {e}").into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tx.execute("PRAGMA user_version = 4", []).map_err(|e| {
|
||||||
|
StashError::Store(format!("Failed to set schema version: {e}").into())
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add is_expired column if it doesn't exist (v5)
|
||||||
if schema_version < 5 {
|
if schema_version < 5 {
|
||||||
if !column_exists(&tx, "is_expired") {
|
let has_is_expired: bool = tx
|
||||||
|
.query_row(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type='table' AND \
|
||||||
|
name='clipboard'",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
let sql: String = row.get(0)?;
|
||||||
|
Ok(sql.to_lowercase().contains("is_expired"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !has_is_expired {
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"ALTER TABLE clipboard ADD COLUMN is_expired INTEGER DEFAULT 0",
|
"ALTER TABLE clipboard ADD COLUMN is_expired INTEGER DEFAULT 0",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(migration_err)?;
|
.map_err(|e| {
|
||||||
|
StashError::Store(
|
||||||
|
format!("Failed to add is_expired column: {e}").into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create index for is_expired (for filtering)
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_is_expired ON clipboard(is_expired) \
|
"CREATE INDEX IF NOT EXISTS idx_is_expired ON clipboard(is_expired) \
|
||||||
WHERE is_expired = 1",
|
WHERE is_expired = 1",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(migration_err)?;
|
.map_err(|e| {
|
||||||
tx.pragma_update(None, "user_version", 5i64)
|
StashError::Store(
|
||||||
.map_err(migration_err)?;
|
format!("Failed to create is_expired index: {e}").into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tx.execute("PRAGMA user_version = 5", []).map_err(|e| {
|
||||||
|
StashError::Store(format!("Failed to set schema version: {e}").into())
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add mime_types column if it doesn't exist (v6)
|
||||||
|
// Stores all MIME types offered by the source application as JSON array.
|
||||||
|
// Needed for clipboard persistence to re-offer the same types.
|
||||||
if schema_version < 6 {
|
if schema_version < 6 {
|
||||||
if !column_exists(&tx, "mime_types") {
|
let has_mime_types: 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("mime_types"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !has_mime_types {
|
||||||
tx.execute("ALTER TABLE clipboard ADD COLUMN mime_types TEXT", [])
|
tx.execute("ALTER TABLE clipboard ADD COLUMN mime_types TEXT", [])
|
||||||
.map_err(migration_err)?;
|
.map_err(|e| {
|
||||||
|
StashError::Store(
|
||||||
|
format!("Failed to add mime_types column: {e}").into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
tx.pragma_update(None, "user_version", 6i64)
|
|
||||||
.map_err(migration_err)?;
|
tx.execute("PRAGMA user_version = 6", []).map_err(|e| {
|
||||||
|
StashError::Store(format!("Failed to set schema version: {e}").into())
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
tx.commit().map_err(|e| {
|
tx.commit().map_err(|e| {
|
||||||
|
|
@ -427,29 +555,14 @@ impl SqliteClipboardDb {
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Initialize Wayland state in background thread. This will be used to track
|
||||||
|
// focused window state.
|
||||||
#[cfg(feature = "use-toplevel")]
|
#[cfg(feature = "use-toplevel")]
|
||||||
crate::wayland::init_wayland_state();
|
crate::wayland::init_wayland_state();
|
||||||
Ok(Self { conn, db_path })
|
Ok(Self { conn, db_path })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check whether `column` exists in the `clipboard` table.
|
|
||||||
fn column_exists(conn: &Connection, column: &str) -> bool {
|
|
||||||
conn
|
|
||||||
.prepare("PRAGMA table_info(clipboard)")
|
|
||||||
.and_then(|mut stmt| {
|
|
||||||
stmt
|
|
||||||
.query_map([], |row| row.get::<_, String>(1))
|
|
||||||
.map(|rows| rows.filter_map(Result::ok).any(|c| c == column))
|
|
||||||
})
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert a rusqlite error into [`StashError::Store`].
|
|
||||||
fn migration_err(e: rusqlite::Error) -> StashError {
|
|
||||||
StashError::Store(e.to_string().into())
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SqliteClipboardDb {
|
impl SqliteClipboardDb {
|
||||||
pub fn list_json(
|
pub fn list_json(
|
||||||
&self,
|
&self,
|
||||||
|
|
@ -652,7 +765,7 @@ impl ClipboardDb for SqliteClipboardDb {
|
||||||
.conn
|
.conn
|
||||||
.execute(
|
.execute(
|
||||||
"DELETE FROM clipboard WHERE id IN (SELECT id FROM clipboard ORDER \
|
"DELETE FROM clipboard WHERE id IN (SELECT id FROM clipboard ORDER \
|
||||||
BY COALESCE(last_accessed, 0) ASC, id ASC LIMIT ?1)",
|
BY id ASC LIMIT ?1)",
|
||||||
params![i64::try_from(to_delete).unwrap_or(i64::MAX)],
|
params![i64::try_from(to_delete).unwrap_or(i64::MAX)],
|
||||||
)
|
)
|
||||||
.map_err(|e| StashError::Trim(e.to_string().into()))?;
|
.map_err(|e| StashError::Trim(e.to_string().into()))?;
|
||||||
|
|
@ -815,23 +928,40 @@ impl ClipboardDb for SqliteClipboardDb {
|
||||||
&self,
|
&self,
|
||||||
id: i64,
|
id: i64,
|
||||||
) -> Result<(i64, Vec<u8>, Option<String>), StashError> {
|
) -> Result<(i64, Vec<u8>, Option<String>), StashError> {
|
||||||
let (contents, mime): (Vec<u8>, Option<String>) = self
|
let (contents, mime, content_hash): (Vec<u8>, Option<String>, Option<i64>) =
|
||||||
.conn
|
self
|
||||||
.query_row(
|
.conn
|
||||||
"SELECT contents, mime FROM clipboard WHERE id = ?1",
|
.query_row(
|
||||||
params![id],
|
"SELECT contents, mime, content_hash FROM clipboard WHERE id = ?1",
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
params![id],
|
||||||
)
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||||
.map_err(|e| StashError::DecodeGet(e.to_string().into()))?;
|
)
|
||||||
|
.map_err(|e| StashError::DecodeGet(e.to_string().into()))?;
|
||||||
|
|
||||||
self
|
if let Some(hash) = content_hash {
|
||||||
.conn
|
let most_recent_id: Option<i64> = self
|
||||||
.execute(
|
.conn
|
||||||
"UPDATE clipboard SET last_accessed = CAST(strftime('%s', 'now') AS \
|
.query_row(
|
||||||
INTEGER) WHERE id = ?1",
|
"SELECT id FROM clipboard WHERE content_hash = ?1 AND last_accessed \
|
||||||
params![id],
|
= (SELECT MAX(last_accessed) FROM clipboard WHERE content_hash = \
|
||||||
)
|
?1)",
|
||||||
.map_err(|e| StashError::Store(e.to_string().into()))?;
|
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))
|
Ok((id, contents, mime))
|
||||||
}
|
}
|
||||||
|
|
@ -1123,10 +1253,17 @@ pub fn size_str(size: usize) -> String {
|
||||||
/// Check if clipboard should be excluded based on excluded apps configuration.
|
/// Check if clipboard should be excluded based on excluded apps configuration.
|
||||||
/// Uses timing correlation and focused window detection to identify source app.
|
/// Uses timing correlation and focused window detection to identify source app.
|
||||||
fn should_exclude_by_app(excluded_apps: Option<&[String]>) -> bool {
|
fn should_exclude_by_app(excluded_apps: Option<&[String]>) -> bool {
|
||||||
match excluded_apps {
|
let excluded = match excluded_apps {
|
||||||
Some(apps) if !apps.is_empty() => detect_excluded_app_activity(apps),
|
Some(apps) if !apps.is_empty() => apps,
|
||||||
_ => false,
|
_ => 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
|
/// Detect if clipboard likely came from an excluded app using multiple
|
||||||
|
|
@ -2101,231 +2238,4 @@ mod tests {
|
||||||
"Regex loading should be deterministic"
|
"Regex loading should be deterministic"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_migration_from_v3() {
|
|
||||||
let temp_dir = tempfile::tempdir().expect("temp dir");
|
|
||||||
let db_path = temp_dir.path().join("test_v3.db");
|
|
||||||
let conn = Connection::open(&db_path).expect("open");
|
|
||||||
conn
|
|
||||||
.execute_batch(
|
|
||||||
"CREATE TABLE clipboard (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
contents BLOB NOT NULL,
|
|
||||||
mime TEXT,
|
|
||||||
content_hash INTEGER,
|
|
||||||
last_accessed INTEGER
|
|
||||||
);
|
|
||||||
INSERT INTO clipboard (contents, mime, content_hash) VALUES \
|
|
||||||
(x'010203', 'text/plain', 12345);",
|
|
||||||
)
|
|
||||||
.expect("create v3 schema");
|
|
||||||
conn
|
|
||||||
.pragma_update(None, "user_version", 3i64)
|
|
||||||
.expect("set version");
|
|
||||||
|
|
||||||
let db = SqliteClipboardDb::new(conn, db_path).expect("migrate");
|
|
||||||
assert_eq!(get_schema_version(&db.conn).expect("version"), 6);
|
|
||||||
assert!(table_column_exists(&db.conn, "clipboard", "expires_at"));
|
|
||||||
assert!(table_column_exists(&db.conn, "clipboard", "is_expired"));
|
|
||||||
assert!(table_column_exists(&db.conn, "clipboard", "mime_types"));
|
|
||||||
let count: i64 = db
|
|
||||||
.conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM clipboard", [], |r| r.get(0))
|
|
||||||
.expect("count");
|
|
||||||
assert_eq!(count, 1, "existing data must survive migration");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_migration_from_v4() {
|
|
||||||
let temp_dir = tempfile::tempdir().expect("temp dir");
|
|
||||||
let db_path = temp_dir.path().join("test_v4.db");
|
|
||||||
let conn = Connection::open(&db_path).expect("open");
|
|
||||||
conn
|
|
||||||
.execute_batch(
|
|
||||||
"CREATE TABLE clipboard (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
contents BLOB NOT NULL,
|
|
||||||
mime TEXT,
|
|
||||||
content_hash INTEGER,
|
|
||||||
last_accessed INTEGER,
|
|
||||||
expires_at REAL
|
|
||||||
);
|
|
||||||
INSERT INTO clipboard (contents, mime) VALUES (x'aabbcc', \
|
|
||||||
'image/png');",
|
|
||||||
)
|
|
||||||
.expect("create v4 schema");
|
|
||||||
conn
|
|
||||||
.pragma_update(None, "user_version", 4i64)
|
|
||||||
.expect("set version");
|
|
||||||
|
|
||||||
let db = SqliteClipboardDb::new(conn, db_path).expect("migrate");
|
|
||||||
assert_eq!(get_schema_version(&db.conn).expect("version"), 6);
|
|
||||||
assert!(table_column_exists(&db.conn, "clipboard", "is_expired"));
|
|
||||||
assert!(table_column_exists(&db.conn, "clipboard", "mime_types"));
|
|
||||||
let count: i64 = db
|
|
||||||
.conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM clipboard", [], |r| r.get(0))
|
|
||||||
.expect("count");
|
|
||||||
assert_eq!(count, 1, "existing data must survive migration");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_migration_from_v5() {
|
|
||||||
let temp_dir = tempfile::tempdir().expect("temp dir");
|
|
||||||
let db_path = temp_dir.path().join("test_v5.db");
|
|
||||||
let conn = Connection::open(&db_path).expect("open");
|
|
||||||
conn
|
|
||||||
.execute_batch(
|
|
||||||
"CREATE TABLE clipboard (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
contents BLOB NOT NULL,
|
|
||||||
mime TEXT,
|
|
||||||
content_hash INTEGER,
|
|
||||||
last_accessed INTEGER,
|
|
||||||
expires_at REAL,
|
|
||||||
is_expired INTEGER DEFAULT 0
|
|
||||||
);
|
|
||||||
INSERT INTO clipboard (contents, mime) VALUES (x'deadbeef', \
|
|
||||||
'application/octet-stream');",
|
|
||||||
)
|
|
||||||
.expect("create v5 schema");
|
|
||||||
conn
|
|
||||||
.pragma_update(None, "user_version", 5i64)
|
|
||||||
.expect("set version");
|
|
||||||
|
|
||||||
let db = SqliteClipboardDb::new(conn, db_path).expect("migrate");
|
|
||||||
assert_eq!(get_schema_version(&db.conn).expect("version"), 6);
|
|
||||||
assert!(table_column_exists(&db.conn, "clipboard", "mime_types"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pre-migration entries (NULL content_hash) must have last_accessed
|
|
||||||
/// updated when accessed via copy_entry.
|
|
||||||
#[test]
|
|
||||||
fn test_copy_entry_updates_last_accessed_null_hash() {
|
|
||||||
let db = test_db();
|
|
||||||
db.conn
|
|
||||||
.execute(
|
|
||||||
"INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \
|
|
||||||
VALUES (?1, 'text/plain', NULL, 0)",
|
|
||||||
rusqlite::params![b"legacy data".as_ref()],
|
|
||||||
)
|
|
||||||
.expect("insert null-hash entry");
|
|
||||||
let id: i64 = db
|
|
||||||
.conn
|
|
||||||
.query_row("SELECT last_insert_rowid()", [], |r| r.get(0))
|
|
||||||
.expect("id");
|
|
||||||
|
|
||||||
db.copy_entry(id).expect("copy");
|
|
||||||
|
|
||||||
let last_accessed: i64 = db
|
|
||||||
.conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT last_accessed FROM clipboard WHERE id = ?1",
|
|
||||||
[id],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.expect("last_accessed");
|
|
||||||
assert!(
|
|
||||||
last_accessed > 0,
|
|
||||||
"last_accessed must be updated for null-hash entries"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// trim_db must evict the least-recently-accessed entries, not the
|
|
||||||
/// lowest-id entries.
|
|
||||||
#[test]
|
|
||||||
fn test_trim_db_evicts_lru_not_oldest() {
|
|
||||||
let db = test_db();
|
|
||||||
let mut ids = Vec::new();
|
|
||||||
for i in 0..5u8 {
|
|
||||||
let id = db
|
|
||||||
.store_entry(
|
|
||||||
std::io::Cursor::new(vec![i; 4]),
|
|
||||||
0,
|
|
||||||
100,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
DEFAULT_MAX_ENTRY_SIZE,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.expect("store");
|
|
||||||
ids.push(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zero out all timestamps so copy_entry produces a strictly higher value.
|
|
||||||
db.conn
|
|
||||||
.execute("UPDATE clipboard SET last_accessed = 0", [])
|
|
||||||
.expect("reset timestamps");
|
|
||||||
|
|
||||||
// Touch the first (oldest by id) entry to make it most-recently-used.
|
|
||||||
db.copy_entry(ids[0]).expect("copy");
|
|
||||||
|
|
||||||
// Trim to 4; ids[0] was just accessed and must survive.
|
|
||||||
db.trim_db(4).expect("trim");
|
|
||||||
|
|
||||||
let still_there: i64 = db
|
|
||||||
.conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COUNT(*) FROM clipboard WHERE id = ?1",
|
|
||||||
[ids[0]],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.expect("count");
|
|
||||||
assert_eq!(
|
|
||||||
still_there, 1,
|
|
||||||
"recently accessed entry must not be evicted"
|
|
||||||
);
|
|
||||||
|
|
||||||
let total: i64 = db
|
|
||||||
.conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM clipboard", [], |r| r.get(0))
|
|
||||||
.expect("total");
|
|
||||||
assert_eq!(total, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All new columns must be NULL for entries created before their respective
|
|
||||||
/// schema versions.
|
|
||||||
#[test]
|
|
||||||
fn test_migration_null_columns_for_legacy_entries() {
|
|
||||||
let temp_dir = tempfile::tempdir().expect("temp dir");
|
|
||||||
let db_path = temp_dir.path().join("test_legacy.db");
|
|
||||||
{
|
|
||||||
let conn = Connection::open(&db_path).expect("open");
|
|
||||||
conn
|
|
||||||
.execute_batch(
|
|
||||||
"CREATE TABLE clipboard (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
contents BLOB NOT NULL,
|
|
||||||
mime TEXT
|
|
||||||
);
|
|
||||||
INSERT INTO clipboard (contents, mime) VALUES (x'68656c6c6f', \
|
|
||||||
'text/plain');",
|
|
||||||
)
|
|
||||||
.expect("create v0 schema");
|
|
||||||
}
|
|
||||||
|
|
||||||
let conn = Connection::open(&db_path).expect("open");
|
|
||||||
let db = SqliteClipboardDb::new(conn, db_path).expect("migrate");
|
|
||||||
|
|
||||||
let (hash, accessed, expires): (Option<i64>, Option<i64>, Option<f64>) = db
|
|
||||||
.conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT content_hash, last_accessed, expires_at FROM clipboard WHERE \
|
|
||||||
id = 1",
|
|
||||||
[],
|
|
||||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
|
||||||
)
|
|
||||||
.expect("query");
|
|
||||||
assert!(hash.is_none(), "content_hash must be NULL for pre-v2 entry");
|
|
||||||
assert!(
|
|
||||||
accessed.is_none(),
|
|
||||||
"last_accessed must be NULL for pre-v3 entry"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
expires.is_none(),
|
|
||||||
"expires_at must be NULL for pre-v4 entry"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
|
||||||
/// on a thread pool to avoid blocking the async runtime. Since
|
/// on a thread pool to avoid blocking the async runtime. Since
|
||||||
/// [`rusqlite::Connection`] is not Send, we store the database path and open a
|
/// [`rusqlite::Connection`] is not Send, we store the database path and open a
|
||||||
/// new connection for each operation.
|
/// new connection for each operation.
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct AsyncClipboardDb {
|
pub struct AsyncClipboardDb {
|
||||||
db_path: PathBuf,
|
db_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
@ -73,11 +72,25 @@ impl AsyncClipboardDb {
|
||||||
AND (is_expired IS NULL OR is_expired = 0) ORDER BY expires_at ASC",
|
AND (is_expired IS NULL OR is_expired = 0) ORDER BY expires_at ASC",
|
||||||
)
|
)
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||||
stmt
|
|
||||||
.query_map([], |row| Ok((row.get::<_, f64>(0)?, row.get::<_, i64>(1)?)))
|
let mut rows = stmt
|
||||||
|
.query([])
|
||||||
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||||
|
let mut expirations = Vec::new();
|
||||||
|
|
||||||
|
while let Some(row) = rows
|
||||||
|
.next()
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
|
||||||
.collect::<Result<Vec<_>, _>>()
|
{
|
||||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))
|
let exp = row
|
||||||
|
.get::<_, f64>(0)
|
||||||
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||||
|
let id = row
|
||||||
|
.get::<_, i64>(1)
|
||||||
|
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||||
|
expirations.push((exp, id));
|
||||||
|
}
|
||||||
|
Ok(expirations)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
@ -123,6 +136,14 @@ impl AsyncClipboardDb {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Clone for AsyncClipboardDb {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
db_path: self.db_path.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::{collections::HashSet, hash::Hasher};
|
use std::{collections::HashSet, hash::Hasher};
|
||||||
|
|
|
||||||
|
|
@ -222,7 +222,8 @@ fn fork_and_serve(prepared_copy: wl_clipboard_rs::copy::PreparedCopy) {
|
||||||
0 => {
|
0 => {
|
||||||
// Child process - serve clipboard content
|
// Child process - serve clipboard content
|
||||||
if let Err(e) = prepared_copy.serve() {
|
if let Err(e) = prepared_copy.serve() {
|
||||||
log::debug!("background clipboard service ended: {e}");
|
log::error!("background clipboard service failed: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -456,9 +456,6 @@ fn handle_regular_paste(
|
||||||
bail!("no content available and --no-newline specified");
|
bail!("no content available and --no-newline specified");
|
||||||
}
|
}
|
||||||
if let Err(e) = out.write_all(&buf) {
|
if let Err(e) = out.write_all(&buf) {
|
||||||
if e.kind() == io::ErrorKind::BrokenPipe {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
bail!("failed to write to stdout: {e}");
|
bail!("failed to write to stdout: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -474,12 +471,12 @@ fn handle_regular_paste(
|
||||||
|| types == "application/x-sh"
|
|| types == "application/x-sh"
|
||||||
};
|
};
|
||||||
|
|
||||||
if !args.no_newline && is_text_content && !buf.ends_with(b"\n") {
|
if !args.no_newline
|
||||||
if let Err(e) = out.write_all(b"\n") {
|
&& is_text_content
|
||||||
if e.kind() != io::ErrorKind::BrokenPipe {
|
&& !buf.ends_with(b"\n")
|
||||||
bail!("failed to write newline to stdout: {e}");
|
&& let Err(e) = out.write_all(b"\n")
|
||||||
}
|
{
|
||||||
}
|
bail!("failed to write newline to stdout: {e}");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Err(PasteError::NoSeats) => {
|
Err(PasteError::NoSeats) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue