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