From e9343da2491c7b3f1695e9502a3366a1aea67b8a Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 27 Feb 2026 22:08:53 +0200 Subject: [PATCH] Fix 29 audit findings across all severity tiers Critical: fix unsquashfs arg order, quote Exec paths with spaces, fix compare_versions antisymmetry, chunk-based signature detection, bounded ELF header reads. High: handle NULL CVE severity, prevent pipe deadlock in inspector, fix glob_match edge case, fix backup archive path collisions, async crash detection with stderr capture. Medium: gate scan on auto-scan setting, fix window size persistence, fix announce() for Stack containers, claim lightbox gesture, use serde_json for CLI output, remove dead CSS @media blocks, add detail-tab persistence, remove invalid metainfo categories, byte-level fuse signature search. Low: tighten Wayland env var detection, ELF magic validation, timeout for update info extraction, quoted arg parsing, stop watcher timer on window destroy, GSettings choices/range constraints, remove unused CSS classes, define status-ok/status-attention CSS. --- data/app.driftwood.Driftwood.gschema.xml | 27 + data/app.driftwood.Driftwood.metainfo.xml | 6 - data/resources/style.css | 65 +- docs/plans/2026-02-27-audit-fixes-design.md | 107 ++ .../2026-02-27-audit-fixes-implementation.md | 1155 +++++++++++++++++ src/cli.rs | 28 +- src/core/analysis.rs | 5 +- src/core/appstream.rs | 7 - src/core/backup.rs | 37 +- src/core/database.rs | 36 +- src/core/duplicates.rs | 7 +- src/core/fuse.rs | 17 +- src/core/inspector.rs | 55 +- src/core/integrator.rs | 4 +- src/core/launcher.rs | 97 +- src/core/notification.rs | 2 - src/core/report.rs | 2 - src/core/security.rs | 4 +- src/core/updater.rs | 106 +- src/core/watcher.rs | 2 +- src/core/wayland.rs | 3 - src/i18n.rs | 1 - src/ui/detail_view.rs | 59 +- src/ui/preferences.rs | 43 + src/ui/security_report.rs | 24 +- src/ui/widgets.rs | 13 +- src/window.rs | 75 +- 27 files changed, 1737 insertions(+), 250 deletions(-) create mode 100644 docs/plans/2026-02-27-audit-fixes-design.md create mode 100644 docs/plans/2026-02-27-audit-fixes-implementation.md 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/docs/plans/2026-02-27-audit-fixes-design.md b/docs/plans/2026-02-27-audit-fixes-design.md new file mode 100644 index 0000000..4eac524 --- /dev/null +++ b/docs/plans/2026-02-27-audit-fixes-design.md @@ -0,0 +1,107 @@ +# Audit Fixes Design + +## Goal + +Fix all 29 findings from the full codebase audit, organized by severity tier with build verification between tiers. + +## Approach + +Fix by severity tier (Critical -> High -> Medium -> Low). Run `cargo build` after each tier to catch regressions early. + +## Tier 1: Critical (5 items) + +### #1 - security.rs: Fix unsquashfs argument order +`detect_version_from_binary` passes `appimage_path` after the extract pattern. unsquashfs expects the archive before patterns. Move `appimage_path` before the file pattern, remove the `-e` flag. + +### #2 - integrator.rs: Quote Exec path in .desktop files +`Exec={exec} %U` breaks for paths with spaces. Change to `Exec="{exec}" %U`. + +### #3 - duplicates.rs: Fix compare_versions total order +`compare_versions("1.0", "v1.0")` returns `Less` both ways (violates antisymmetry). Use `clean_version()` on both inputs for the equality check. + +### #4 - inspector.rs: Chunk-based signature detection +`detect_signature` reads entire files (1.5GB+) into memory. Replace with `BufReader` reading 64KB chunks, scanning each for the signature bytes. + +### #5 - updater.rs: Read only first 12 bytes in verify_appimage +Replace `fs::read(path)` with `File::open` + `read_exact` for just the ELF/AI magic bytes. + +## Tier 2: High (6 items) + +### #6 - database.rs: Handle NULL severity in CVE summaries +`get_cve_summary` and `get_all_cve_summary` fail on NULL severity. Change to `Option`, default `None` to `"MEDIUM"`. + +### #7 - inspector.rs: Fix deadlock in extract_metadata_files +Piped stderr + `.status()` can deadlock. Change to `Stdio::null()` since we don't use stderr. + +### #8 - updater.rs: Fix glob_match edge case +After matching the last part with `ends_with`, reduce the search text before checking middle parts. + +### #9 - backup.rs: Prevent archive filename collisions +Use relative paths from home directory instead of bare filenames, so two dirs with the same leaf name don't collide. + +### #10 - launcher.rs: Async crash detection +Remove the 1.5s blocking sleep from `execute_appimage`. Return `Started` immediately with the `Child`. Callers (already async) handle crash detection by polling the child after a delay. + +### #11 - launcher.rs: Drop stderr pipe on success +After returning `Started`, either drop `child.stderr` or use `Stdio::null()` for stderr to prevent pipe buffer deadlock on long-running apps. + +## Tier 3: Medium (9 items) + +### #12 - window.rs: Gate scan on auto-scan-on-startup +Wrap `self.trigger_scan()` in `if self.settings().boolean("auto-scan-on-startup")`. + +### #13 - window.rs: Fix window size persistence +Change `self.default_size()` to `(self.width(), self.height())`. + +### #14 - widgets.rs: Fix announce() for any container +Change `announce()` to not require a `gtk::Box` - use a more generic approach or fix callers to pass the correct widget type. + +### #15 - detail_view.rs: Claim gesture in lightbox +Add `gesture.set_state(gtk::EventSequenceState::Claimed)` in the picture click handler. + +### #16 - cli.rs: Use serde_json for JSON output +Replace hand-crafted `format!` JSON with `serde_json::json!()`. + +### #17 - style.css: Remove dead @media blocks +Delete `@media (prefers-color-scheme: dark)` and `@media (prefers-contrast: more)` blocks. libadwaita named colors already adapt. + +### #18 - gschema.xml + detail_view.rs: Wire detail-tab persistence +Save active tab on switch, restore on open. + +### #19 - metainfo.xml: Remove invalid categories +Delete `` block (already in .desktop file, invalid in metainfo per AppStream spec). + +### #20 - fuse.rs: Byte-level search +Replace `String::from_utf8_lossy().to_lowercase()` with direct byte-level case-insensitive search using `windows()`. + +## Tier 4: Low (9 items) + +### #21 - wayland.rs: Tighten env var detection +Remove `WAYLAND_DISPLAY` from fallback heuristic. Keep only `GDK_BACKEND` and `QT_QPA_PLATFORM`. + +### #22 - inspector.rs: Add ELF magic validation +Check `\x7fELF` magic and endianness byte before parsing `e_machine`. + +### #23 - updater.rs: Add timeout to extract_update_info_runtime +Add 5-second timeout to prevent indefinite blocking. + +### #24 - launcher.rs: Handle quoted args +Use a shell-like tokenizer that respects double-quoted strings in `parse_launch_args`. + +### #25 - (merged with #20) + +### #26 - window.rs: Stop watcher timer on window destroy +Return `glib::ControlFlow::Break` when `window_weak.upgrade()` returns `None`. + +### #27 - gschema.xml: Add choices/range constraints +Add `` to enumerated string keys, `` to backup-retention-days. + +### #28 - style.css: Remove unused CSS classes +Delete `.quick-action-pill`, `.badge-row`, `.detail-view-switcher`, base `.letter-icon`. + +### #29 - style.css/app_card.rs: Fix status-ok/status-attention +Define CSS rules for these classes or remove the class additions from code. + +## Verification + +After each tier: `cargo build` with zero errors and zero warnings. After all tiers: manual app launch test. diff --git a/docs/plans/2026-02-27-audit-fixes-implementation.md b/docs/plans/2026-02-27-audit-fixes-implementation.md new file mode 100644 index 0000000..ff01240 --- /dev/null +++ b/docs/plans/2026-02-27-audit-fixes-implementation.md @@ -0,0 +1,1155 @@ +# Audit Fixes Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix all 29 findings from the full codebase correctness audit, organized by severity tier. + +**Architecture:** Fix by severity tier (Critical -> High -> Medium -> Low) with a `cargo build` gate after each tier. Each task is a single focused fix. Fixes #10 and #11 (launcher async + stderr) are combined because they touch the same function. + +**Tech Stack:** Rust, gtk4-rs, libadwaita-rs, rusqlite, serde_json, std::io + +--- + +## Tier 1: Critical Fixes + +### Task 1: Fix unsquashfs argument order in security.rs + +**Files:** +- Modify: `src/core/security.rs:253-261` + +**Step 1: Fix the argument order** + +The `unsquashfs` command expects: `unsquashfs [options] [patterns]`. Currently the AppImage path comes after the pattern. Fix: + +```rust + let extract_output = Command::new("unsquashfs") + .args(["-o", &offset, "-f", "-d"]) + .arg(temp_dir.path()) + .arg("-no-progress") + .arg(appimage_path) + .arg(lib_file_path.trim_start_matches("squashfs-root/")) + .output() + .ok()?; +``` + +Remove the `-e` flag (unsquashfs takes extract patterns as positional args after the archive path, not with `-e`). + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 2: Quote Exec path in desktop entries + +**Files:** +- Modify: `src/core/integrator.rs:97` + +**Step 1: Add quotes around the exec path** + +Change line 97 from: +``` + Exec={exec} %U\n\ +``` +to: +``` + Exec=\"{exec}\" %U\n\ +``` + +This ensures paths with spaces (e.g., `/home/user/My Apps/Firefox.AppImage`) work correctly per the Desktop Entry Specification. + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 3: Fix compare_versions total order violation + +**Files:** +- Modify: `src/core/duplicates.rs:332-341` + +**Step 1: Use clean_version for equality check** + +Replace the function body: + +```rust +fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering { + use super::updater::version_is_newer; + use super::updater::clean_version; + + 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 + } else { + std::cmp::Ordering::Less + } +} +``` + +Note: `clean_version` is currently `fn` (private). It must be made `pub(crate)` in `src/core/updater.rs:704`. + +**Step 2: Make clean_version pub(crate)** + +In `src/core/updater.rs:704`, change `fn clean_version` to `pub(crate) fn clean_version`. + +**Step 3: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 4: Chunk-based signature detection in inspector.rs + +**Files:** +- Modify: `src/core/inspector.rs:530-537` + +**Step 1: Replace fs::read with chunked BufReader** + +Replace the `detect_signature` function: + +```rust +fn detect_signature(path: &Path) -> bool { + use std::io::{BufReader, Read}; + let file = match fs::File::open(path) { + Ok(f) => f, + Err(_) => return false, + }; + let needle = b".sha256_sig"; + 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 +} +``` + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 5: Read only first 12 bytes in verify_appimage + +**Files:** +- Modify: `src/core/updater.rs:961-980` + +**Step 1: Replace fs::read with targeted read** + +```rust +fn verify_appimage(path: &Path) -> bool { + 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; + } + // 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 +} +``` + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 6: Tier 1 build gate + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Zero output (clean build with no warnings or errors) + +--- + +## Tier 2: High Fixes + +### Task 7: Handle NULL severity in CVE summaries + +**Files:** +- Modify: `src/core/database.rs:1348-1349` and `1369-1370` + +**Step 1: Change get_cve_summary to handle Option** + +At line 1348-1349, change: +```rust + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) +``` +to: +```rust + let severity: String = row.get::<_, Option>(0)? + .unwrap_or_else(|| "MEDIUM".to_string()); + Ok((severity, row.get::<_, i64>(1)?)) +``` + +**Step 2: Same fix for get_all_cve_summary** + +At line 1369-1370, apply the same change: +```rust + let severity: String = row.get::<_, Option>(0)? + .unwrap_or_else(|| "MEDIUM".to_string()); + Ok((severity, row.get::<_, i64>(1)?)) +``` + +**Step 3: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 8: Fix potential deadlock in extract_metadata_files + +**Files:** +- Modify: `src/core/inspector.rs:248` + +**Step 1: Change piped stderr to null** + +At line 248, change: +```rust + .stderr(std::process::Stdio::piped()) +``` +to: +```rust + .stderr(std::process::Stdio::null()) +``` + +We don't use the stderr output (we only check the exit status), so piping it creates a deadlock risk for no benefit. + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 9: Fix glob_match edge case + +**Files:** +- Modify: `src/core/updater.rs:680-698` + +**Step 1: Reduce search text after matching the last part** + +Replace the function body from the `// Last part must match` section (line 680) through the return (line 700): + +```rust + // Last part must match at the end (unless pattern ends with *) + let last = parts[parts.len() - 1]; + 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 within the allowed range + for part in &parts[1..parts.len() - 1] { + if part.is_empty() { + continue; + } + if pos >= end_limit { + return false; + } + if let Some(found) = text[pos..end_limit].find(part) { + pos += found + part.len(); + } else { + return false; + } + } + + true +} +``` + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 10: Fix backup archive filename collisions + +**Files:** +- Modify: `src/core/backup.rs:122-132` and `195-198` + +**Step 1: Use home-relative paths in archive creation** + +Replace the tar archive entry loop (lines 122-132): + +```rust + let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); + + for entry in &entries { + let source = Path::new(&entry.original_path); + if source.exists() { + if let Ok(rel) = source.strip_prefix(&home_dir) { + tar_args.push("-C".to_string()); + tar_args.push(home_dir.to_string_lossy().to_string()); + tar_args.push(rel.to_string_lossy().to_string()); + } else { + // Path outside home dir - use parent/filename as fallback + tar_args.push("-C".to_string()); + tar_args.push( + source.parent().unwrap_or(Path::new("/")).to_string_lossy().to_string(), + ); + tar_args.push( + source.file_name().unwrap_or_default().to_string_lossy().to_string(), + ); + } + } + } +``` + +Note: Need `use std::path::PathBuf;` if not already imported. + +**Step 2: Fix restore to match the new archive layout** + +Replace the restore path lookup (lines 195-198): + +```rust + for entry in &manifest.paths { + 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); +``` + +Add `let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));` before the restore loop. + +**Step 3: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 11: Async crash detection + drop stderr pipe + +**Files:** +- Modify: `src/core/launcher.rs:147-224` + +**Step 1: Remove blocking sleep, return immediately, drop stderr on success** + +Replace the entire `execute_appimage` function (lines 147-224): + +```rust +/// Execute the AppImage process with the given method. +fn execute_appimage( + appimage_path: &Path, + method: &LaunchMethod, + args: &[String], + extra_env: &[(&str, &str)], +) -> LaunchResult { + let mut cmd = match method { + LaunchMethod::Direct => { + let mut c = Command::new(appimage_path); + c.args(args); + c + } + LaunchMethod::ExtractAndRun => { + let mut c = Command::new(appimage_path); + c.env("APPIMAGE_EXTRACT_AND_RUN", "1"); + c.args(args); + c + } + LaunchMethod::Sandboxed => { + let mut c = Command::new("firejail"); + c.arg("--appimage"); + c.arg(appimage_path); + c.args(args); + c + } + }; + + // Apply extra environment variables + for (key, value) in extra_env { + cmd.env(key, value); + } + + // Detach stdin, let stderr go to /dev/null to prevent pipe buffer deadlock + cmd.stdin(Stdio::null()); + cmd.stderr(Stdio::null()); + + match cmd.spawn() { + Ok(child) => { + LaunchResult::Started { + child, + method: method.clone(), + } + } + Err(e) => LaunchResult::Failed(e.to_string()), + } +} +``` + +The caller side (detail_view.rs and window.rs) already has async crash detection via `glib::timeout_future` + `child.try_wait()`. The 1.5s sleep was redundant with that pattern. Since we now use `Stdio::null()` for stderr, we need to update the `Crashed` variant handling in callers to not expect stderr content from the launcher - callers can capture stderr separately if needed. + +Actually, let me check if callers rely on the `Crashed` variant. Looking at the current flow: `execute_appimage` returns `Started` or `Failed`. The `Crashed` variant was produced by the sleep+try_wait logic we're removing. The callers in detail_view.rs (line 131) and window.rs pattern-match on `Crashed`. We need to remove those match arms since `execute_appimage` will never produce `Crashed` anymore. + +**Step 2: Update detail_view.rs caller** + +At line 131 in detail_view.rs, the match arm `Ok(launcher::LaunchResult::Crashed { exit_code, stderr, method })` should be removed. The caller already detects crashes async via the wayland analysis timeout (lines 119-129). However, crash detection UX needs to be preserved. Let's keep the `Crashed` variant in the enum but not produce it from `execute_appimage`. Instead, add a comment that callers should poll `child.try_wait()` after a delay to detect crashes. This is already the pattern used in window.rs. + +Actually, looking more carefully at the callers: the detail_view.rs code at line 112-146 handles `Started`, `Crashed`, and `Failed`. If we remove `Crashed` production from `execute_appimage`, immediate crashes (process exits within milliseconds of spawning) would show as `Started` and the user would see "Launched" with no feedback that it crashed. + +Better approach: keep crash detection but make it non-blocking. Change `execute_appimage` to not sleep. Instead, do a single non-blocking `try_wait()` with no sleep. This catches processes that fail immediately (e.g., missing executable, permission denied) without blocking for 1.5 seconds: + +```rust + match cmd.spawn() { + Ok(mut child) => { + // Non-blocking check: catch immediate spawn failures + // (e.g., missing libs, exec format error) + match child.try_wait() { + Ok(Some(status)) => { + // Already exited - immediate crash + LaunchResult::Crashed { + exit_code: status.code(), + stderr: String::new(), + method: method.clone(), + } + } + _ => { + // Still running or can't check - assume success + LaunchResult::Started { + child, + method: method.clone(), + } + } + } + } + Err(e) => LaunchResult::Failed(e.to_string()), + } +``` + +This gives us: no blocking sleep, no stderr pipe deadlock, still catches instant crashes. The stderr field is empty (since we use Stdio::null) but that's fine - the crash dialog still shows the exit code. + +**Step 3: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 12: Tier 2 build gate + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Zero output + +--- + +## Tier 3: Medium Fixes + +### Task 13: Gate scan on auto-scan-on-startup setting + +**Files:** +- Modify: `src/window.rs:834-835` + +**Step 1: Wrap trigger_scan in settings check** + +Replace lines 834-835: +```rust + // Always scan on startup to discover new AppImages and complete pending analyses + self.trigger_scan(); +``` +with: +```rust + // Scan on startup if enabled in preferences + if self.settings().boolean("auto-scan-on-startup") { + self.trigger_scan(); + } +``` + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 14: Fix window size persistence + +**Files:** +- Modify: `src/window.rs:1252` + +**Step 1: Use actual dimensions instead of default_size** + +Change line 1252 from: +```rust + let (width, height) = self.default_size(); +``` +to: +```rust + let (width, height) = (self.width(), self.height()); +``` + +`self.width()` and `self.height()` return the current allocated size, which is what we want to persist. `default_size()` returns the value set by `set_default_size()`, which doesn't change on manual resize. + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 15: Fix announce() to work with any container + +**Files:** +- Modify: `src/ui/widgets.rs:347-363` + +**Step 1: Replace Box-specific logic with generic parent approach** + +Replace the `announce` function: + +```rust +pub fn announce(container: &impl gtk::prelude::IsA, text: &str) { + let label = gtk::Label::builder() + .label(text) + .visible(false) + .accessible_role(gtk::AccessibleRole::Alert) + .build(); + label.update_property(&[gtk::accessible::Property::Label(text)]); + + // Try common container types + if let Some(box_widget) = container.dynamic_cast_ref::() { + box_widget.append(&label); + label.set_visible(true); + let label_clone = label.clone(); + let box_clone = box_widget.clone(); + glib::timeout_add_local_once(std::time::Duration::from_millis(500), move || { + box_clone.remove(&label_clone); + }); + } else if let Some(stack) = container.dynamic_cast_ref::() { + // For stacks, add to the visible child if it's a box, else use overlay + if let Some(child) = stack.visible_child() { + if let Some(child_box) = child.dynamic_cast_ref::() { + child_box.append(&label); + label.set_visible(true); + let label_clone = label.clone(); + let box_clone = child_box.clone(); + glib::timeout_add_local_once(std::time::Duration::from_millis(500), move || { + box_clone.remove(&label_clone); + }); + } + } + } else if let Some(overlay) = container.dynamic_cast_ref::() { + if let Some(child) = overlay.child() { + if let Some(child_box) = child.dynamic_cast_ref::() { + child_box.append(&label); + label.set_visible(true); + let label_clone = label.clone(); + let box_clone = child_box.clone(); + glib::timeout_add_local_once(std::time::Duration::from_millis(500), move || { + box_clone.remove(&label_clone); + }); + } + } + } +} +``` + +Note: Needs `use adw::prelude::*;` if not already imported in widgets.rs. + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 16: Claim gesture in lightbox + +**Files:** +- Modify: `src/ui/detail_view.rs:1948` + +**Step 1: Claim the gesture sequence** + +Change line 1948 from: +```rust + pic_gesture.connect_released(|_, _, _, _| {}); +``` +to: +```rust + pic_gesture.connect_released(|gesture, _, _, _| { + gesture.set_state(gtk::EventSequenceState::Claimed); + }); +``` + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 17: Use serde_json for CLI JSON output + +**Files:** +- Modify: `src/cli.rs:114-131` + +**Step 1: Replace hand-crafted JSON with serde_json** + +Replace lines 114-131: + +```rust + if format == "json" { + 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; + } +``` + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 18: Remove dead @media blocks from CSS + +**Files:** +- Modify: `data/resources/style.css:174-211` + +**Step 1: Delete both @media blocks** + +Remove lines 174-211 (the `@media (prefers-color-scheme: dark)` and `@media (prefers-contrast: more)` blocks). GTK4 CSS does not support these media queries - they are silently ignored. The libadwaita named colors (`@warning_bg_color`, etc.) already adapt to dark mode automatically. + +Keep the comment at line 213-216 about `prefers-reduced-motion` since it explains *why* GTK handles it differently. + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build (CSS is compiled at build time via gresource) + +--- + +### Task 19: Wire detail-tab persistence + +**Files:** +- Modify: `src/ui/detail_view.rs:30-32` (read setting) and add save on tab switch + +**Step 1: Restore saved tab on detail view open** + +After the view_stack creation (line 32), after all tabs are added (around line 55), add: + +```rust + // Restore last selected tab + 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); + } +``` + +**Step 2: Save tab on switch** + +After the tab restore code, add: + +```rust + // Save tab selection + let settings_tab = settings.clone(); + view_stack.connect_visible_child_name_notify(move |stack| { + if let Some(name) = stack.visible_child_name() { + settings_tab.set_string("detail-tab", &name).ok(); + } + }); +``` + +Ensure `use gtk::gio;` and `use crate::config::APP_ID;` are imported at the top of detail_view.rs (check if they already are). + +**Step 3: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 20: Remove invalid categories from metainfo + +**Files:** +- Modify: `data/app.driftwood.Driftwood.metainfo.xml:54-58` + +**Step 1: Delete the categories block** + +Remove lines 54-58: +```xml + + System + PackageManager + GTK + +``` + +Per AppStream spec, categories belong in the `.desktop` file only (where they already are). + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 21: Byte-level search in fuse.rs + +**Files:** +- Modify: `src/core/fuse.rs:189-192` + +**Step 1: Replace lossy UTF-8 conversion with byte-level search** + +Replace lines 188-192: +```rust + let data = &buf[..n]; + let haystack = String::from_utf8_lossy(data).to_lowercase(); + haystack.contains("type2-runtime") + || haystack.contains("libfuse3") +``` +with: +```rust + let data = &buf[..n]; + fn bytes_contains_ci(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| { + w.iter().zip(needle).all(|(a, b)| a.to_ascii_lowercase() == *b) + }) + } + bytes_contains_ci(data, b"type2-runtime") + || bytes_contains_ci(data, b"libfuse3") +``` + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 22: Tier 3 build gate + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Zero output + +--- + +## Tier 4: Low Fixes + +### Task 23: Tighten Wayland env var detection + +**Files:** +- Modify: `src/core/wayland.rs:389-393` + +**Step 1: Remove WAYLAND_DISPLAY from fallback** + +Change lines 389-393 from: +```rust + 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()) + }); +``` +to: +```rust + has_wayland_socket = env_vars.iter().any(|(k, v)| { + (k == "GDK_BACKEND" && v.contains("wayland")) + || (k == "QT_QPA_PLATFORM" && v.contains("wayland")) + }); +``` + +`WAYLAND_DISPLAY` is typically inherited from the parent environment and doesn't indicate the app is actually using Wayland. + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 24: Add ELF magic validation to detect_architecture + +**Files:** +- Modify: `src/core/inspector.rs:427-440` + +**Step 1: Add magic check and endianness-aware parsing** + +Replace the function: + +```rust +fn detect_architecture(path: &Path) -> Option { + let mut file = fs::File::open(path).ok()?; + let mut header = [0u8; 20]; + file.read_exact(&mut header).ok()?; + + // 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()), + 0xB7 => Some("aarch64".to_string()), + 0x28 => Some("armhf".to_string()), + _ => Some(format!("unknown(0x{:02X})", machine)), + } +} +``` + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 25: Add timeout to extract_update_info_runtime + +**Files:** +- Modify: `src/core/updater.rs:372-388` + +**Step 1: Add 5-second timeout** + +Replace the function: + +```rust +fn extract_update_info_runtime(path: &Path) -> Option { + let mut child = std::process::Command::new(path) + .arg("--appimage-updateinformation") + .env("APPIMAGE_EXTRACT_AND_RUN", "1") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .ok()?; + + 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, + } + } +} +``` + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 26: Handle quoted args in parse_launch_args + +**Files:** +- Modify: `src/core/launcher.rs:227-232` + +**Step 1: Implement simple shell-like tokenizer** + +Replace the function: + +```rust +pub fn parse_launch_args(args: Option<&str>) -> Vec { + 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; + let mut chars = s.chars().peekable(); + + while let Some(c) = chars.next() { + 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 +} +``` + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 27: Stop watcher timer on window destroy + +**Files:** +- Modify: `src/window.rs:1114-1121` + +**Step 1: Return Break when window is gone** + +Replace lines 1114-1121: +```rust + glib::timeout_add_local(std::time::Duration::from_secs(1), move || { + if changed.swap(false, std::sync::atomic::Ordering::Relaxed) { + if let Some(window) = window_weak.upgrade() { + window.trigger_scan(); + } + } + glib::ControlFlow::Continue + }); +``` +with: +```rust + 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) { + window.trigger_scan(); + } + glib::ControlFlow::Continue + }); +``` + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 28: Add GSettings choices/range constraints + +**Files:** +- Modify: `data/app.driftwood.Driftwood.gschema.xml` + +**Step 1: Add choices to enumerated string keys** + +For `view-mode` key, add choices: +```xml + + + + + + 'grid' +``` + +For `color-scheme` key: +```xml + + + + + +``` + +For `detail-tab` key: +```xml + + + + + + +``` + +For `update-cleanup` key: +```xml + + + + + +``` + +For `security-notification-threshold` key: +```xml + + + + + + +``` + +For `backup-retention-days`, add range: +```xml + + + 30 +``` + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 29: Remove unused CSS classes + +**Files:** +- Modify: `data/resources/style.css` + +**Step 1: Remove unused class definitions** + +Remove these blocks: +- `.badge-row` (lines 120-123) - defined but never added to any widget +- `.detail-view-switcher` (lines 154-158) - defined but never added +- `.quick-action-pill` (lines 160-164) - defined but never added + +Keep `.letter-icon` (lines 125-130) since the per-letter variants in widgets.rs inherit these base properties through CSS specificity. + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 30: Define CSS for status-ok/status-attention or remove from code + +**Files:** +- Modify: `data/resources/style.css` (add CSS rules) + +**Step 1: Add CSS rules for the card status classes** + +Add after the `.card` styles: + +```css +/* 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); +} +``` + +**Step 2: Build and verify** + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Clean build + +--- + +### Task 31: Tier 4 build gate + final verification + +Run: `cargo build 2>&1 | grep "warning\|error"` +Expected: Zero output + +Run: `cargo run` and verify app launches. + +--- + +## Verification Checklist + +After all tasks: +1. `cargo build` - zero errors, zero warnings +2. `cargo run` - app launches and shows library view +3. Navigate to preferences, toggle settings, verify they save +4. Open a detail view, switch tabs, reopen - tab should be restored 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 9eb2327..8c1d6b6 100644 --- a/src/core/database.rs +++ b/src/core/database.rs @@ -184,34 +184,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, @@ -1374,7 +1346,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?; @@ -1395,7 +1369,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 c1c8b10..e9a8e8e 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 cd4edb4..f3efcab 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)); // Phase 2: 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");