Compare commits

..

1 commit

Author SHA1 Message Date
dependabot[bot]
34a4726411
build(deps): bump clap from 4.6.0 to 4.6.1
Bumps [clap](https://github.com/clap-rs/clap) from 4.6.0 to 4.6.1.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.6.0...clap_complete-v4.6.1)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.6.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-28 14:54:11 +00:00
10 changed files with 256 additions and 334 deletions

8
Cargo.lock generated
View file

@ -395,9 +395,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.6.0" version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [ dependencies = [
"clap_builder", "clap_builder",
"clap_derive", "clap_derive",
@ -427,9 +427,9 @@ dependencies = [
[[package]] [[package]]
name = "clap_derive" name = "clap_derive"
version = "4.6.0" version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro2", "proc-macro2",

View file

@ -17,7 +17,7 @@ path = "src/main.rs"
arc-swap = { version = "1.9.1", optional = true } arc-swap = { version = "1.9.1", optional = true }
base64 = "0.22.1" base64 = "0.22.1"
blocking = "1.6.2" blocking = "1.6.2"
clap = { version = "4.6.0", features = [ "derive", "env" ] } clap = { version = "4.6.1", features = [ "derive", "env" ] }
clap-verbosity-flag = "3.0.4" clap-verbosity-flag = "3.0.4"
color-eyre = "0.6.5" color-eyre = "0.6.5"
crossterm = "0.29.0" crossterm = "0.29.0"

12
flake.lock generated
View file

@ -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": {

View file

@ -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);
}, },
} }
} }

View file

@ -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);

View file

@ -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");
} }

View file

@ -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"
);
}
} }

View file

@ -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};

View file

@ -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);
}, },

View file

@ -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) => {