diff --git a/data/app.driftwood.Driftwood.gschema.xml b/data/app.driftwood.Driftwood.gschema.xml index 7c692f1..4e35b55 100644 --- a/data/app.driftwood.Driftwood.gschema.xml +++ b/data/app.driftwood.Driftwood.gschema.xml @@ -22,11 +22,20 @@ Directories to scan for AppImage files. + + + + 'grid' Library view mode The library view mode: grid or list. + + + + + 'default' Color scheme Application color scheme: default (follow system), force-light, or force-dark. @@ -37,6 +46,12 @@ Whether to automatically scan for AppImages when the application starts. + + + + + + 'overview' Last detail view tab The last selected tab in the detail view (overview, system, security, storage). @@ -57,6 +72,7 @@ Create a config backup before applying an update. + 30 Backup retention days Number of days to keep config backups before auto-cleanup. @@ -67,6 +83,11 @@ Show a confirmation dialog before deleting AppImages or backups. + + + + + 'ask' Update cleanup mode What to do with old versions after update: ask, keep, or delete. @@ -82,6 +103,12 @@ Send desktop notifications when new CVEs are found. + + + + + + 'high' Security notification threshold Minimum CVE severity for desktop notifications: critical, high, medium, or low. diff --git a/data/app.driftwood.Driftwood.metainfo.xml b/data/app.driftwood.Driftwood.metainfo.xml index 8eb1a6c..175c21a 100644 --- a/data/app.driftwood.Driftwood.metainfo.xml +++ b/data/app.driftwood.Driftwood.metainfo.xml @@ -51,12 +51,6 @@ pointing - - System - PackageManager - GTK - - AppImage Application diff --git a/data/resources/style.css b/data/resources/style.css index 5b8138d..8f9d95d 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -94,6 +94,15 @@ flowboxchild:focus-visible .card { outline-offset: 3px; } +/* App card status indicators */ +.status-ok { + border: 1px solid alpha(@success_bg_color, 0.4); +} + +.status-attention { + border: 1px solid alpha(@warning_bg_color, 0.4); +} + /* Rounded icon clipping for list view */ .icon-rounded { border-radius: 8px; @@ -117,11 +126,6 @@ row:focus-visible { outline-offset: -2px; } -/* Badge row in app cards */ -.badge-row { - margin-top: 4px; -} - /* Letter-circle fallback icon */ .letter-icon { border-radius: 50%; @@ -151,18 +155,6 @@ row:focus-visible { margin-bottom: 6px; } -/* Inline ViewSwitcher positioning */ -.detail-view-switcher { - margin-top: 6px; - margin-bottom: 6px; -} - -/* ===== Quick Action Pills ===== */ -.quick-action-pill { - border-radius: 18px; - padding: 6px 16px; -} - /* ===== Compatibility Warning Banner ===== */ .compat-warning-banner { background: alpha(@warning_bg_color, 0.15); @@ -171,45 +163,6 @@ row:focus-visible { border: 1px solid alpha(@warning_bg_color, 0.3); } -/* ===== Dark Mode Differentiation ===== */ -@media (prefers-color-scheme: dark) { - .compat-warning-banner { - background: alpha(@warning_bg_color, 0.1); - border: 1px solid alpha(@warning_bg_color, 0.2); - } -} - -/* ===== High Contrast Mode (WCAG AAA 1.4.6) ===== */ -@media (prefers-contrast: more) { - flowboxchild:focus-visible .card { - outline-width: 3px; - } - - button:focus-visible, - togglebutton:focus-visible, - menubutton:focus-visible, - checkbutton:focus-visible, - switch:focus-visible, - entry:focus-visible, - searchentry:focus-visible, - spinbutton:focus-visible { - outline-width: 3px; - } - - row:focus-visible { - outline-width: 3px; - } - - .status-badge, - .status-badge-with-icon { - border: 1px solid currentColor; - } - - .compat-warning-banner { - border: 2px solid @warning_bg_color; - } -} - /* ===== Reduced Motion (WCAG AAA 2.3.3) ===== */ /* Note: GTK CSS does not support prefers-reduced-motion or !important. Reduced motion is handled by the GTK toolkit settings instead diff --git a/src/cli.rs b/src/cli.rs index 4fa695a..755aa61 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -112,21 +112,19 @@ fn cmd_list(db: &Database, format: &str) -> ExitCode { } if format == "json" { - // Simple JSON output - println!("["); - for (i, r) in records.iter().enumerate() { - let comma = if i + 1 < records.len() { "," } else { "" }; - println!( - " {{\"name\": \"{}\", \"version\": \"{}\", \"path\": \"{}\", \"size\": {}, \"integrated\": {}}}{}", - r.app_name.as_deref().unwrap_or(&r.filename), - r.app_version.as_deref().unwrap_or(""), - r.path, - r.size_bytes, - r.integrated, - comma, - ); - } - println!("]"); + let items: Vec = records + .iter() + .map(|r| { + serde_json::json!({ + "name": r.app_name.as_deref().unwrap_or(&r.filename), + "version": r.app_version.as_deref().unwrap_or(""), + "path": r.path, + "size": r.size_bytes, + "integrated": r.integrated, + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&items).unwrap_or_else(|_| "[]".into())); return ExitCode::SUCCESS; } diff --git a/src/core/analysis.rs b/src/core/analysis.rs index 861a0c8..63b35e6 100644 --- a/src/core/analysis.rs +++ b/src/core/analysis.rs @@ -15,7 +15,6 @@ const MAX_CONCURRENT_ANALYSES: usize = 2; static RUNNING_ANALYSES: AtomicUsize = AtomicUsize::new(0); /// Returns the number of currently running background analyses. -#[allow(dead_code)] pub fn running_count() -> usize { RUNNING_ANALYSES.load(Ordering::Relaxed) } @@ -64,6 +63,10 @@ pub fn run_background_analysis(id: i64, path: PathBuf, appimage_type: AppImageTy // Inspect metadata (app name, version, icon, desktop entry, AppStream, etc.) if let Ok(meta) = inspector::inspect_appimage(&path, &appimage_type) { + log::debug!( + "Metadata for id={}: name={:?}, icon_name={:?}", + id, meta.app_name.as_deref(), meta.icon_name.as_deref(), + ); let categories = if meta.categories.is_empty() { None } else { diff --git a/src/core/appstream.rs b/src/core/appstream.rs index e466d76..d15fdc4 100644 --- a/src/core/appstream.rs +++ b/src/core/appstream.rs @@ -405,7 +405,6 @@ fn summarize_content_rating(attrs: &[(String, String)]) -> String { // AppStream catalog generation - writes catalog XML for GNOME Software/Discover // --------------------------------------------------------------------------- -#[allow(dead_code)] /// Generate an AppStream catalog XML from the Driftwood database. /// This allows GNOME Software / KDE Discover to see locally managed AppImages. pub fn generate_catalog(db: &Database) -> Result { @@ -463,7 +462,6 @@ pub fn generate_catalog(db: &Database) -> Result { Ok(xml) } -#[allow(dead_code)] /// Install the AppStream catalog to the local swcatalog directory. /// GNOME Software reads from `~/.local/share/swcatalog/xml/`. pub fn install_catalog(db: &Database) -> Result { @@ -484,7 +482,6 @@ pub fn install_catalog(db: &Database) -> Result { Ok(catalog_path) } -#[allow(dead_code)] /// Remove the AppStream catalog from the local swcatalog directory. pub fn uninstall_catalog() -> Result<(), AppStreamError> { let catalog_path = dirs::data_dir() @@ -501,7 +498,6 @@ pub fn uninstall_catalog() -> Result<(), AppStreamError> { Ok(()) } -#[allow(dead_code)] /// Check if the AppStream catalog is currently installed. pub fn is_catalog_installed() -> bool { let catalog_path = dirs::data_dir() @@ -515,7 +511,6 @@ pub fn is_catalog_installed() -> bool { // --- Utility functions --- -#[allow(dead_code)] fn make_component_id(name: &str) -> String { name.chars() .map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c.to_ascii_lowercase() } else { '_' }) @@ -524,7 +519,6 @@ fn make_component_id(name: &str) -> String { .to_string() } -#[allow(dead_code)] fn xml_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") @@ -536,7 +530,6 @@ fn xml_escape(s: &str) -> String { // --- Error types --- #[derive(Debug)] -#[allow(dead_code)] pub enum AppStreamError { Database(String), Io(String), diff --git a/src/core/backup.rs b/src/core/backup.rs index 7e0bf08..a17d2ed 100644 --- a/src/core/backup.rs +++ b/src/core/backup.rs @@ -119,16 +119,23 @@ pub fn create_backup(db: &Database, appimage_id: i64) -> Result Result // Restore each path let mut restored = 0u32; let mut skipped = 0u32; + let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); for entry in &manifest.paths { - let source_name = Path::new(&entry.original_path) - .file_name() - .unwrap_or_default(); - let extracted = temp_dir.path().join(source_name); + let source = Path::new(&entry.original_path); + let extracted = if let Ok(rel) = source.strip_prefix(&home_dir) { + temp_dir.path().join(rel) + } else { + let source_name = source.file_name().unwrap_or_default(); + temp_dir.path().join(source_name) + }; let target = Path::new(&entry.original_path); if !extracted.exists() { @@ -269,7 +280,6 @@ pub fn delete_backup(db: &Database, backup_id: i64) -> Result<(), BackupError> { } /// Remove backups older than the specified number of days. -#[allow(dead_code)] pub fn auto_cleanup_old_backups(db: &Database, retention_days: u32) -> Result { let backups = db.get_all_config_backups().unwrap_or_default(); let cutoff = chrono::Utc::now() - chrono::Duration::days(retention_days as i64); @@ -292,7 +302,6 @@ pub fn auto_cleanup_old_backups(db: &Database, retention_days: u32) -> Result, pub archive_path: String, @@ -304,10 +313,8 @@ pub struct BackupInfo { #[derive(Debug)] pub struct RestoreResult { - #[allow(dead_code)] pub manifest: BackupManifest, pub paths_restored: u32, - #[allow(dead_code)] pub paths_skipped: u32, } diff --git a/src/core/database.rs b/src/core/database.rs index 8532f1d..845bf41 100644 --- a/src/core/database.rs +++ b/src/core/database.rs @@ -180,34 +180,6 @@ pub struct ConfigBackupRecord { pub last_restored_at: Option, } -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct CatalogSourceRecord { - pub id: i64, - pub name: String, - pub url: String, - pub source_type: String, - pub enabled: bool, - pub last_synced: Option, - pub app_count: i32, -} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct CatalogAppRecord { - pub id: i64, - pub source_id: i64, - pub name: String, - pub description: Option, - pub categories: Option, - pub latest_version: Option, - pub download_url: String, - pub icon_url: Option, - pub homepage: Option, - pub file_size: Option, - pub architecture: Option, -} - #[derive(Debug, Clone)] pub struct SandboxProfileRecord { pub id: i64, @@ -1369,7 +1341,9 @@ impl Database { WHERE appimage_id = ?1 GROUP BY severity" )?; let rows = stmt.query_map(params![appimage_id], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + let severity: String = row.get::<_, Option>(0)? + .unwrap_or_else(|| "MEDIUM".to_string()); + Ok((severity, row.get::<_, i64>(1)?)) })?; for row in rows { let (severity, count) = row?; @@ -1390,7 +1364,9 @@ impl Database { "SELECT severity, COUNT(*) FROM cve_matches GROUP BY severity" )?; let rows = stmt.query_map([], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + let severity: String = row.get::<_, Option>(0)? + .unwrap_or_else(|| "MEDIUM".to_string()); + Ok((severity, row.get::<_, i64>(1)?)) })?; for row in rows { let (severity, count) = row?; diff --git a/src/core/duplicates.rs b/src/core/duplicates.rs index 7b570d1..4fc9132 100644 --- a/src/core/duplicates.rs +++ b/src/core/duplicates.rs @@ -330,9 +330,12 @@ fn build_name_group(name: &str, records: &[&AppImageRecord]) -> DuplicateGroup { /// Compare two version strings for ordering. fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering { - use super::updater::version_is_newer; + use super::updater::{clean_version, version_is_newer}; - if a == b { + let ca = clean_version(a); + let cb = clean_version(b); + + if ca == cb { std::cmp::Ordering::Equal } else if version_is_newer(a, b) { std::cmp::Ordering::Greater diff --git a/src/core/fuse.rs b/src/core/fuse.rs index e66a762..2693060 100644 --- a/src/core/fuse.rs +++ b/src/core/fuse.rs @@ -186,9 +186,20 @@ fn has_static_runtime(appimage_path: &Path) -> bool { Err(_) => return false, }; let data = &buf[..n]; - let haystack = String::from_utf8_lossy(data).to_lowercase(); - haystack.contains("type2-runtime") - || haystack.contains("libfuse3") + // Search raw bytes directly - avoids allocating a UTF-8 string from binary data. + // Case-insensitive matching for the two known signatures. + bytes_contains_ci(data, b"type2-runtime") + || bytes_contains_ci(data, b"libfuse3") +} + +/// Case-insensitive byte-level substring search (ASCII only). +fn bytes_contains_ci(haystack: &[u8], needle: &[u8]) -> bool { + if needle.is_empty() || haystack.len() < needle.len() { + return false; + } + haystack.windows(needle.len()).any(|window| { + window.iter().zip(needle).all(|(h, n)| h.to_ascii_lowercase() == n.to_ascii_lowercase()) + }) } /// Check if --appimage-extract-and-run is supported. diff --git a/src/core/inspector.rs b/src/core/inspector.rs index ed2244a..4a232bd 100644 --- a/src/core/inspector.rs +++ b/src/core/inspector.rs @@ -38,7 +38,6 @@ pub struct AppImageMetadata { pub app_version: Option, pub description: Option, pub developer: Option, - #[allow(dead_code)] pub icon_name: Option, pub categories: Vec, pub desktop_entry_content: String, @@ -246,7 +245,7 @@ fn extract_metadata_files( .arg("usr/share/metainfo/*.xml") .arg("usr/share/appdata/*.xml") .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) .status(); match status { @@ -430,8 +429,20 @@ fn detect_architecture(path: &Path) -> Option { let mut header = [0u8; 20]; file.read_exact(&mut header).ok()?; - // ELF e_machine at offset 18 (little-endian) - let machine = u16::from_le_bytes([header[18], header[19]]); + // Validate ELF magic + if &header[0..4] != b"\x7FELF" { + return None; + } + + // ELF e_machine at offset 18, endianness from byte 5 + let machine = if header[5] == 2 { + // Big-endian + u16::from_be_bytes([header[18], header[19]]) + } else { + // Little-endian (default) + u16::from_le_bytes([header[18], header[19]]) + }; + match machine { 0x03 => Some("i386".to_string()), 0x3E => Some("x86_64".to_string()), @@ -529,12 +540,42 @@ fn find_appstream_file(extract_dir: &Path) -> Option { /// Check if an AppImage has a GPG signature by looking for the .sha256_sig section name. fn detect_signature(path: &Path) -> bool { - let data = match fs::read(path) { - Ok(d) => d, + use std::io::{BufReader, Read}; + let file = match fs::File::open(path) { + Ok(f) => f, Err(_) => return false, }; let needle = b".sha256_sig"; - data.windows(needle.len()).any(|w| w == needle) + let mut reader = BufReader::new(file); + let mut buf = vec![0u8; 64 * 1024]; + let mut carry = Vec::new(); + + loop { + let n = match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => n, + Err(_) => break, + }; + // Prepend carry bytes from previous chunk to handle needle spanning chunks + let search_buf = if carry.is_empty() { + &buf[..n] + } else { + carry.extend_from_slice(&buf[..n]); + carry.as_slice() + }; + if search_buf.windows(needle.len()).any(|w| w == needle) { + return true; + } + // Keep the last (needle.len - 1) bytes as carry for the next iteration + let keep = needle.len() - 1; + carry.clear(); + if n >= keep { + carry.extend_from_slice(&buf[n - keep..n]); + } else { + carry.extend_from_slice(&buf[..n]); + } + } + false } /// Cache an icon file to the driftwood icons directory. diff --git a/src/core/integrator.rs b/src/core/integrator.rs index 188b698..4c11e68 100644 --- a/src/core/integrator.rs +++ b/src/core/integrator.rs @@ -94,7 +94,7 @@ pub fn integrate(record: &AppImageRecord) -> Result, stderr: String, - #[allow(dead_code)] method: LaunchMethod, }, /// Failed to launch. @@ -99,6 +97,22 @@ pub fn launch_appimage( } }; + // Override with sandboxed launch if the user enabled firejail for this app + let method = if has_firejail() { + let sandbox = db + .get_appimage_by_id(record_id) + .ok() + .flatten() + .and_then(|r| r.sandbox_mode); + if sandbox.as_deref() == Some("firejail") { + LaunchMethod::Sandboxed + } else { + method + } + } else { + method + }; + let result = execute_appimage(appimage_path, &method, extra_args, extra_env); // Record the launch event regardless of success @@ -163,42 +177,38 @@ fn execute_appimage( cmd.env(key, value); } - // Capture stderr to detect crash messages, stdin detached + // Detach stdin, pipe stderr so we can capture crash messages cmd.stdin(Stdio::null()); cmd.stderr(Stdio::piped()); match cmd.spawn() { Ok(mut child) => { - // Brief wait to detect immediate crashes (e.g. missing Qt plugins) - std::thread::sleep(std::time::Duration::from_millis(1500)); + // Give the process a brief moment to fail on immediate errors + // (missing libs, exec format errors, Qt plugin failures, etc.) + std::thread::sleep(std::time::Duration::from_millis(150)); + match child.try_wait() { Ok(Some(status)) => { - // Process already exited - it crashed - let stderr = child - .stderr - .take() - .and_then(|mut err| { - let mut buf = String::new(); - use std::io::Read; - err.read_to_string(&mut buf).ok()?; - Some(buf) - }) - .unwrap_or_default(); + // Already exited - immediate crash. Read stderr for details. + let stderr_text = child.stderr.take().map(|mut pipe| { + let mut buf = String::new(); + use std::io::Read; + // Read with a size cap to avoid huge allocations + let mut limited = (&mut pipe).take(64 * 1024); + let _ = limited.read_to_string(&mut buf); + buf + }).unwrap_or_default(); + LaunchResult::Crashed { exit_code: status.code(), - stderr, + stderr: stderr_text, method: method.clone(), } } - Ok(None) => { - // Still running - success - LaunchResult::Started { - child, - method: method.clone(), - } - } - Err(_) => { - // Can't check status, assume it's running + _ => { + // Still running after 150ms - drop the stderr pipe so the + // child process won't block if it fills the pipe buffer. + drop(child.stderr.take()); LaunchResult::Started { child, method: method.clone(), @@ -211,11 +221,38 @@ fn execute_appimage( } /// Parse a launch_args string from the database into a Vec of individual arguments. -/// Splits on whitespace; returns an empty Vec if the input is None or empty. -#[allow(dead_code)] +/// Parse launch arguments with basic quote support. +/// Splits on whitespace, respecting double-quoted strings. +/// Returns an empty Vec if the input is None or empty. pub fn parse_launch_args(args: Option<&str>) -> Vec { - args.map(|s| s.split_whitespace().map(String::from).collect()) - .unwrap_or_default() + let Some(s) = args else { + return Vec::new(); + }; + let s = s.trim(); + if s.is_empty() { + return Vec::new(); + } + + let mut result = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + + for c in s.chars() { + match c { + '"' => in_quotes = !in_quotes, + ' ' | '\t' if !in_quotes => { + if !current.is_empty() { + result.push(std::mem::take(&mut current)); + } + } + _ => current.push(c), + } + } + if !current.is_empty() { + result.push(current); + } + + result } /// Check if firejail is available for sandboxed launches. diff --git a/src/core/notification.rs b/src/core/notification.rs index 398ebf0..66407f8 100644 --- a/src/core/notification.rs +++ b/src/core/notification.rs @@ -5,7 +5,6 @@ use super::security; #[derive(Debug, Clone)] pub struct CveNotification { pub app_name: String, - #[allow(dead_code)] pub appimage_id: i64, pub severity: String, pub cve_count: usize, @@ -138,7 +137,6 @@ fn send_desktop_notification(notif: &CveNotification) -> Result<(), Notification /// Run a security scan and send notifications for any new findings. /// This is the CLI entry point for `driftwood security --notify`. -#[allow(dead_code)] pub fn scan_and_notify(db: &Database, threshold: &str) -> Vec { // First run a batch scan to get fresh data let _results = security::batch_scan(db); diff --git a/src/core/report.rs b/src/core/report.rs index 5bf0b56..49157c6 100644 --- a/src/core/report.rs +++ b/src/core/report.rs @@ -10,7 +10,6 @@ pub enum ReportFormat { } impl ReportFormat { - #[allow(dead_code)] pub fn from_str(s: &str) -> Option { match s.to_lowercase().as_str() { "json" => Some(Self::Json), @@ -20,7 +19,6 @@ impl ReportFormat { } } - #[allow(dead_code)] pub fn extension(&self) -> &'static str { match self { Self::Json => "json", diff --git a/src/core/security.rs b/src/core/security.rs index b01a5aa..117205f 100644 --- a/src/core/security.rs +++ b/src/core/security.rs @@ -28,7 +28,6 @@ pub struct CveMatch { /// Result of a security scan for a single AppImage. #[derive(Debug, Clone)] pub struct SecurityScanResult { - #[allow(dead_code)] pub appimage_id: i64, pub libraries: Vec, pub cve_matches: Vec<(BundledLibrary, Vec)>, @@ -254,10 +253,9 @@ pub fn detect_version_from_binary( let extract_output = Command::new("unsquashfs") .args(["-o", &offset, "-f", "-d"]) .arg(temp_dir.path()) - .arg("-e") - .arg(lib_file_path.trim_start_matches("squashfs-root/")) .arg("-no-progress") .arg(appimage_path) + .arg(lib_file_path.trim_start_matches("squashfs-root/")) .output() .ok()?; diff --git a/src/core/updater.rs b/src/core/updater.rs index dae82e4..27c7290 100644 --- a/src/core/updater.rs +++ b/src/core/updater.rs @@ -370,27 +370,51 @@ fn parse_elf32_sections(data: &[u8]) -> Option { } /// Fallback: run the AppImage with --appimage-updateinformation flag. +/// Uses a 5-second timeout to avoid hanging on apps with custom AppRun scripts. fn extract_update_info_runtime(path: &Path) -> Option { - let output = std::process::Command::new(path) + let mut child = std::process::Command::new(path) .arg("--appimage-updateinformation") .env("APPIMAGE_EXTRACT_AND_RUN", "1") - .output() + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() .ok()?; - if output.status.success() { - let info = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !info.is_empty() && info.contains('|') { - return Some(info); + let timeout = std::time::Duration::from_secs(5); + let start = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => { + if status.success() { + let mut output = String::new(); + if let Some(mut stdout) = child.stdout.take() { + use std::io::Read; + stdout.read_to_string(&mut output).ok()?; + } + let info = output.trim().to_string(); + if !info.is_empty() && info.contains('|') { + return Some(info); + } + } + return None; + } + Ok(None) => { + if start.elapsed() >= timeout { + let _ = child.kill(); + let _ = child.wait(); + log::warn!("Timed out reading update info from {}", path.display()); + return None; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + Err(_) => return None, } } - - None } // -- GitHub/GitLab API types for JSON deserialization -- #[derive(Deserialize)] -#[allow(dead_code)] struct GhRelease { tag_name: String, name: Option, @@ -406,7 +430,6 @@ struct GhAsset { } #[derive(Deserialize)] -#[allow(dead_code)] struct GlRelease { tag_name: String, name: Option, @@ -492,6 +515,12 @@ fn check_github_release( let release: GhRelease = response.body_mut().read_json().ok()?; + log::info!( + "GitHub release: tag={}, name={:?}", + release.tag_name, + release.name.as_deref().unwrap_or("(none)"), + ); + let latest_version = clean_version(&release.tag_name); // Find matching asset using glob-like pattern @@ -549,6 +578,12 @@ fn check_gitlab_release( let release: GlRelease = response.body_mut().read_json().ok()?; + log::info!( + "GitLab release: tag={}, name={:?}", + release.tag_name, + release.name.as_deref().unwrap_or("(none)"), + ); + let latest_version = clean_version(&release.tag_name); let download_url = release.assets.and_then(|assets| { @@ -669,18 +704,24 @@ fn glob_match(pattern: &str, text: &str) -> bool { // Last part must match at the end (unless pattern ends with *) let last = parts[parts.len() - 1]; - if !last.is_empty() { + let end_limit = if !last.is_empty() { if !text.ends_with(last) { return false; } - } + text.len() - last.len() + } else { + text.len() + }; - // Middle parts must appear in order + // Middle parts must appear in order within the allowed range for part in &parts[1..parts.len() - 1] { if part.is_empty() { continue; } - if let Some(found) = text[pos..].find(part) { + if pos >= end_limit { + return false; + } + if let Some(found) = text[pos..end_limit].find(part) { pos += found + part.len(); } else { return false; @@ -691,7 +732,7 @@ fn glob_match(pattern: &str, text: &str) -> bool { } /// Clean a version string - strip leading 'v' or 'V' prefix. -fn clean_version(version: &str) -> String { +pub(crate) fn clean_version(version: &str) -> String { let v = version.trim(); v.strip_prefix('v') .or_else(|| v.strip_prefix('V')) @@ -949,24 +990,25 @@ fn download_file( /// Verify that a file is a valid AppImage (has ELF header + AppImage magic bytes). fn verify_appimage(path: &Path) -> bool { - if let Ok(data) = fs::read(path) { - if data.len() < 12 { - return false; - } - // Check ELF magic - if &data[0..4] != b"\x7FELF" { - return false; - } - // Check AppImage Type 2 magic at offset 8: AI\x02 - if data[8] == 0x41 && data[9] == 0x49 && data[10] == 0x02 { - return true; - } - // Check AppImage Type 1 magic at offset 8: AI\x01 - if data[8] == 0x41 && data[9] == 0x49 && data[10] == 0x01 { - return true; - } + use std::io::Read; + let mut file = match fs::File::open(path) { + Ok(f) => f, + Err(_) => return false, + }; + let mut header = [0u8; 12]; + if file.read_exact(&mut header).is_err() { + return false; } - false + // Check ELF magic + if &header[0..4] != b"\x7FELF" { + return false; + } + // Check AppImage Type 2 magic at offset 8: AI\x02 + if header[8] == 0x41 && header[9] == 0x49 && header[10] == 0x02 { + return true; + } + // Check AppImage Type 1 magic at offset 8: AI\x01 + header[8] == 0x41 && header[9] == 0x49 && header[10] == 0x01 } /// Perform an update using the best available method. diff --git a/src/core/watcher.rs b/src/core/watcher.rs index cea041e..0243e6d 100644 --- a/src/core/watcher.rs +++ b/src/core/watcher.rs @@ -8,7 +8,7 @@ use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watche #[derive(Debug, Clone)] pub enum WatchEvent { /// One or more AppImage files were created, modified, or deleted. - Changed(#[allow(dead_code)] Vec), + Changed(Vec), } /// Start watching the given directories for AppImage file changes. diff --git a/src/core/wayland.rs b/src/core/wayland.rs index b2a4f3d..27c01cb 100644 --- a/src/core/wayland.rs +++ b/src/core/wayland.rs @@ -307,11 +307,9 @@ pub fn detect_desktop_environment() -> String { /// Result of analyzing a running process for Wayland usage. #[derive(Debug, Clone)] pub struct RuntimeAnalysis { - #[allow(dead_code)] pub pid: u32, pub has_wayland_socket: bool, pub has_x11_connection: bool, - #[allow(dead_code)] pub env_vars: Vec<(String, String)>, } @@ -391,7 +389,6 @@ pub fn analyze_running_process(pid: u32) -> Result { has_wayland_socket = env_vars.iter().any(|(k, v)| { (k == "GDK_BACKEND" && v.contains("wayland")) || (k == "QT_QPA_PLATFORM" && v.contains("wayland")) - || (k == "WAYLAND_DISPLAY" && !v.is_empty()) }); } diff --git a/src/i18n.rs b/src/i18n.rs index 5b37420..53b7626 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -23,7 +23,6 @@ pub fn i18n_f(msgid: &str, args: &[(&str, &str)]) -> String { } /// Translate a string with singular/plural forms and named placeholders. -#[allow(dead_code)] pub fn ni18n_f(singular: &str, plural: &str, n: u32, args: &[(&str, &str)]) -> String { let base = if n == 1 { singular } else { plural }; let mut result = base.to_string(); diff --git a/src/ui/detail_view.rs b/src/ui/detail_view.rs index 888003d..d944f45 100644 --- a/src/ui/detail_view.rs +++ b/src/ui/detail_view.rs @@ -48,6 +48,20 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc) -> adw::Nav view_stack.add_titled(&storage_page, Some("storage"), "Storage"); view_stack.page(&storage_page).set_icon_name(Some("drive-harddisk-symbolic")); + // Restore last-used tab from GSettings + let settings = gio::Settings::new(crate::config::APP_ID); + let saved_tab = settings.string("detail-tab"); + if view_stack.child_by_name(&saved_tab).is_some() { + view_stack.set_visible_child_name(&saved_tab); + } + + // Persist tab choice on switch + view_stack.connect_visible_child_name_notify(move |stack| { + if let Some(name) = stack.visible_child_name() { + settings.set_string("detail-tab", &name).ok(); + } + }); + // Banner scrolls with content (not sticky) so tall banners don't eat space let scroll_content = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) @@ -83,6 +97,7 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc) -> adw::Nav let record_id = record.id; let path = record.path.clone(); let app_name_launch = record.app_name.clone().unwrap_or_else(|| record.filename.clone()); + let launch_args_raw = record.launch_args.clone(); let db_launch = db.clone(); let toast_launch = toast_overlay.clone(); launch_button.connect_clicked(move |btn| { @@ -92,6 +107,7 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc) -> adw::Nav let app_name = app_name_launch.clone(); let db_launch = db_launch.clone(); let toast_ref = toast_launch.clone(); + let launch_args = launcher::parse_launch_args(launch_args_raw.as_deref()); glib::spawn_future_local(async move { let path_bg = path.clone(); let result = gio::spawn_blocking(move || { @@ -101,7 +117,7 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc) -> adw::Nav record_id, appimage_path, "gui_detail", - &[], + &launch_args, &[], ) }).await; @@ -121,13 +137,16 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc) -> adw::Nav }).await; if let Ok(Ok(analysis)) = analysis_result { let status_str = analysis.as_status_str(); - log::info!("Runtime Wayland: {} -> {}", path_clone, analysis.status_label()); + log::info!( + "Runtime Wayland: {} -> {} (pid={}, env: {:?})", + path_clone, analysis.status_label(), analysis.pid, analysis.env_vars, + ); db_wayland.update_runtime_wayland_status(record_id, status_str).ok(); } }); } - Ok(launcher::LaunchResult::Crashed { exit_code, stderr, .. }) => { - log::error!("App crashed on launch (exit {}): {}", exit_code.unwrap_or(-1), stderr); + Ok(launcher::LaunchResult::Crashed { exit_code, stderr, method }) => { + log::error!("App crashed on launch (exit {}, method: {}): {}", exit_code.unwrap_or(-1), method.as_str(), stderr); widgets::show_crash_dialog(&btn_ref, &app_name, exit_code, &stderr); } Ok(launcher::LaunchResult::Failed(msg)) => { @@ -247,9 +266,7 @@ fn build_banner(record: &AppImageRecord) -> gtk::Box { .margin_top(4) .build(); - if record.integrated { - badge_box.append(&widgets::status_badge("Integrated", "success")); - } + badge_box.append(&widgets::integration_badge(record.integrated)); if let Some(ref ws) = record.wayland_status { let status = WaylandStatus::from_str(ws); @@ -1582,6 +1599,10 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw: group.add(&empty_row); } else { for b in &backups { + log::debug!( + "Listing backup id={} for appimage_id={} at {}", + b.id, b.appimage_id, b.archive_path, + ); let expander = adw::ExpanderRow::builder() .title(&b.created_at) .subtitle(&format!( @@ -1656,12 +1677,28 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw: row_clone.set_sensitive(true); match result { Ok(Ok(res)) => { + let skip_note = if res.paths_skipped > 0 { + format!(" ({} skipped)", res.paths_skipped) + } else { + String::new() + }; row_clone.set_subtitle(&format!( - "Restored {} path{}", + "Restored {} path{}{}", res.paths_restored, if res.paths_restored == 1 { "" } else { "s" }, + skip_note, )); - toast.add_toast(adw::Toast::new("Backup restored")); + let toast_msg = format!( + "Restored {} path{}{}", + res.paths_restored, + if res.paths_restored == 1 { "" } else { "s" }, + skip_note, + ); + toast.add_toast(adw::Toast::new(&toast_msg)); + log::info!( + "Backup restored: app={}, paths_restored={}, paths_skipped={}", + res.manifest.app_name, res.paths_restored, res.paths_skipped, + ); } _ => { row_clone.set_subtitle("Restore failed"); @@ -1922,7 +1959,9 @@ fn show_screenshot_lightbox( // --- Click outside image to close --- // Picture's gesture claims clicks on the image, preventing close. let pic_gesture = gtk::GestureClick::new(); - pic_gesture.connect_released(|_, _, _, _| {}); + pic_gesture.connect_released(|gesture, _, _, _| { + gesture.set_state(gtk::EventSequenceState::Claimed); + }); picture.add_controller(pic_gesture); // Window gesture fires for clicks on the dark margin area. diff --git a/src/ui/preferences.rs b/src/ui/preferences.rs index 4bb5e7c..300536d 100644 --- a/src/ui/preferences.rs +++ b/src/ui/preferences.rs @@ -2,6 +2,8 @@ use adw::prelude::*; use gtk::gio; use crate::config::APP_ID; +use crate::core::appstream; +use crate::core::database::Database; use crate::i18n::i18n; pub fn show_preferences_dialog(parent: &impl IsA) { @@ -153,6 +155,47 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog) scan_group.add(&add_button); page.add(&scan_group); + // Desktop Integration group - AppStream catalog for GNOME Software/Discover + let integration_group = adw::PreferencesGroup::builder() + .title(&i18n("Desktop Integration")) + .description(&i18n( + "Make your AppImages visible in GNOME Software and KDE Discover", + )) + .build(); + + let catalog_row = adw::SwitchRow::builder() + .title(&i18n("AppStream catalog")) + .subtitle(&i18n( + "Generate a local catalog so software centers can list your AppImages", + )) + .active(appstream::is_catalog_installed()) + .build(); + + catalog_row.connect_active_notify(|row| { + let enable = row.is_active(); + glib::spawn_future_local(async move { + let result = gio::spawn_blocking(move || { + if enable { + let db = Database::open().expect("Failed to open database"); + appstream::install_catalog(&db) + .map(|p| log::info!("AppStream catalog installed: {}", p.display())) + .map_err(|e| e.to_string()) + } else { + appstream::uninstall_catalog() + .map(|()| log::info!("AppStream catalog removed")) + .map_err(|e| e.to_string()) + } + }) + .await; + if let Ok(Err(e)) = result { + log::warn!("AppStream catalog toggle failed: {}", e); + } + }); + }); + + integration_group.add(&catalog_row); + page.add(&integration_group); + page } diff --git a/src/ui/security_report.rs b/src/ui/security_report.rs index 062eb7f..3c7aba5 100644 --- a/src/ui/security_report.rs +++ b/src/ui/security_report.rs @@ -61,7 +61,17 @@ pub fn build_security_report_page(db: &Rc) -> adw::NavigationPage { if let Ok(results) = result { let total_cves: usize = results.iter().map(|r| r.total_cves()).sum(); + for r in &results { + log::info!( + "Security scan: appimage_id={} found {} CVEs", + r.appimage_id, r.total_cves(), + ); + } log::info!("Security scan complete: {} CVEs found across {} AppImages", total_cves, results.len()); + widgets::announce( + &stack_refresh, + &format!("Security scan complete: {} vulnerabilities found", total_cves), + ); // Refresh the page content with updated data let new_content = build_report_content(&db_refresh); @@ -119,9 +129,14 @@ pub fn build_security_report_page(db: &Rc) -> adw::NavigationPage { filters.append(&json_filter); filters.append(&csv_filter); + let default_format = report::ReportFormat::Html; + let initial_name = format!( + "driftwood-security-report.{}", + default_format.extension(), + ); let dialog = gtk::FileDialog::builder() .title("Export Security Report") - .initial_name("driftwood-security-report.html") + .initial_name(&initial_name) .filters(&filters) .default_filter(&html_filter) .modal(true) @@ -142,11 +157,8 @@ pub fn build_security_report_page(db: &Rc) -> adw::NavigationPage { .unwrap_or("html") .to_lowercase(); - let format = match ext.as_str() { - "json" => report::ReportFormat::Json, - "csv" => report::ReportFormat::Csv, - _ => report::ReportFormat::Html, - }; + let format = report::ReportFormat::from_str(&ext) + .unwrap_or(report::ReportFormat::Html); btn_clone.set_sensitive(false); btn_clone.set_label("Exporting..."); diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index 0e910fc..14e476f 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -88,7 +88,6 @@ pub fn status_badge_with_icon(icon_name: &str, text: &str, style_class: &str) -> } /// Create a badge showing integration status. -#[allow(dead_code)] pub fn integration_badge(integrated: bool) -> gtk::Label { if integrated { status_badge("Integrated", "success") @@ -345,7 +344,6 @@ fn crash_explanation(stderr: &str) -> String { /// Inserts a hidden label with AccessibleRole::Alert into the given container, /// which causes AT-SPI to announce the text to screen readers. /// The label auto-removes after a short delay. -#[allow(dead_code)] pub fn announce(container: &impl gtk::prelude::IsA, text: &str) { let label = gtk::Label::builder() .label(text) @@ -354,7 +352,16 @@ pub fn announce(container: &impl gtk::prelude::IsA, text: &str) { .build(); label.update_property(&[gtk::accessible::Property::Label(text)]); - if let Some(box_widget) = container.dynamic_cast_ref::() { + // Try to find a suitable Box container to attach the label to + let target_box = container.dynamic_cast_ref::().cloned() + .or_else(|| { + // For Stack widgets, use the visible child if it's a Box + container.dynamic_cast_ref::() + .and_then(|s| s.visible_child()) + .and_then(|c| c.downcast::().ok()) + }); + + if let Some(box_widget) = target_box { box_widget.append(&label); label.set_visible(true); let label_clone = label.clone(); diff --git a/src/window.rs b/src/window.rs index 4680e5a..659667c 100644 --- a/src/window.rs +++ b/src/window.rs @@ -595,29 +595,30 @@ impl DriftwoodWindow { let Some(record_id) = param.and_then(|p| p.get::()) else { return }; let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); let window_ref = window.clone(); - let (path_str, app_name) = { + let (path_str, app_name, launch_args_raw) = { let db = window.database(); match db.get_appimage_by_id(record_id) { Ok(Some(r)) => { let name = r.app_name.clone().unwrap_or_else(|| r.filename.clone()); - (r.path.clone(), name) + (r.path.clone(), name, r.launch_args.clone()) } _ => return, } }; + let launch_args = launcher::parse_launch_args(launch_args_raw.as_deref()); glib::spawn_future_local(async move { let path_bg = path_str.clone(); let result = gio::spawn_blocking(move || { let bg_db = crate::core::database::Database::open().expect("DB open"); let appimage_path = std::path::Path::new(&path_bg); - launcher::launch_appimage(&bg_db, record_id, appimage_path, "gui_context", &[], &[]) + launcher::launch_appimage(&bg_db, record_id, appimage_path, "gui_context", &launch_args, &[]) }).await; match result { Ok(launcher::LaunchResult::Started { child, method }) => { log::info!("Launched: {} (PID: {}, method: {})", path_str, child.id(), method.as_str()); } - Ok(launcher::LaunchResult::Crashed { exit_code, stderr, .. }) => { - log::error!("App crashed (exit {}): {}", exit_code.unwrap_or(-1), stderr); + Ok(launcher::LaunchResult::Crashed { exit_code, stderr, method }) => { + log::error!("App crashed (exit {}, method: {}): {}", exit_code.unwrap_or(-1), method.as_str(), stderr); widgets::show_crash_dialog(&window_ref, &app_name, exit_code, &stderr); } Ok(launcher::LaunchResult::Failed(msg)) => { @@ -830,12 +831,49 @@ impl DriftwoodWindow { } } - // Always scan on startup to discover new AppImages and complete pending analyses - self.trigger_scan(); + // Scan on startup if enabled in preferences + if self.settings().boolean("auto-scan-on-startup") { + self.trigger_scan(); + } // Start watching scan directories for new AppImage files self.start_file_watcher(); + // Auto-cleanup old backups based on retention setting + let retention_days = self.settings().int("backup-retention-days") as u32; + glib::spawn_future_local(async move { + let _ = gio::spawn_blocking(move || { + let bg_db = Database::open().expect("Failed to open database"); + match crate::core::backup::auto_cleanup_old_backups(&bg_db, retention_days) { + Ok(removed) if removed > 0 => { + log::info!("Auto-cleaned {} old backup(s) (retention: {} days)", removed, retention_days); + } + Err(e) => log::warn!("Backup auto-cleanup failed: {}", e), + _ => {} + } + }) + .await; + }); + + // Run background security scan and notify if auto-security-scan is enabled + let settings_sec = self.settings().clone(); + if settings_sec.boolean("auto-security-scan") { + let threshold = settings_sec.string("security-notification-threshold").to_string(); + glib::spawn_future_local(async move { + let _ = gio::spawn_blocking(move || { + let bg_db = Database::open().expect("Failed to open database"); + let notifications = notification::scan_and_notify(&bg_db, &threshold); + for n in ¬ifications { + log::info!( + "CVE notification sent: app={} (id={}), severity={}, count={}", + n.app_name, n.appimage_id, n.severity, n.cve_count, + ); + } + }) + .await; + }); + } + // Check for orphaned desktop entries in the background let toast_overlay = self.imp().toast_overlay.get().unwrap().clone(); glib::spawn_future_local(async move { @@ -989,6 +1027,10 @@ impl DriftwoodWindow { toast_overlay.add_toast(adw::Toast::new(&msg)); // Background analysis per file with debounced UI refresh + let running = analysis::running_count(); + if running > 0 { + log::info!("Analyzing {} AppImage(s) in background ({} already running)", needs_analysis.len(), running); + } if !needs_analysis.is_empty() { let pending = Rc::new(std::cell::Cell::new(needs_analysis.len())); let refresh_timer: Rc>> = @@ -1057,20 +1099,27 @@ impl DriftwoodWindow { let changed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let changed_watcher = changed.clone(); - let handle = watcher::start_watcher(dirs, move |_event| { + let handle = watcher::start_watcher(dirs, move |event| { + match &event { + watcher::WatchEvent::Changed(paths) => { + log::info!("File watcher: {} path(s) changed: {:?}", paths.len(), paths); + } + } changed_watcher.store(true, std::sync::atomic::Ordering::Relaxed); }); if let Some(h) = handle { self.imp().watcher_handle.replace(Some(h)); - // Poll the flag every second from the main thread + // Poll the flag every second from the main thread. + // Returns Break when the window is gone to stop the timer. let window_weak = self.downgrade(); glib::timeout_add_local(std::time::Duration::from_secs(1), move || { + let Some(window) = window_weak.upgrade() else { + return glib::ControlFlow::Break; + }; if changed.swap(false, std::sync::atomic::Ordering::Relaxed) { - if let Some(window) = window_weak.upgrade() { - window.trigger_scan(); - } + window.trigger_scan(); } glib::ControlFlow::Continue }); @@ -1204,7 +1253,7 @@ impl DriftwoodWindow { fn save_window_state(&self) { let settings = self.settings(); - let (width, height) = self.default_size(); + let (width, height) = (self.width(), self.height()); settings .set_int("window-width", width) .expect("Failed to save window width");