Files
driftwood/docs/plans/2026-02-27-audit-fixes-implementation.md
lashman e9343da249 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.
2026-02-27 22:08:53 +02:00

1156 lines
31 KiB
Markdown

# 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] <filesystem> [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<String>**
At line 1348-1349, change:
```rust
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
```
to:
```rust
let severity: String = row.get::<_, Option<String>>(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<String>>(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<gtk::Widget>, 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::<gtk::Box>() {
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::<gtk::Stack>() {
// 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::<gtk::Box>() {
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::<adw::ToastOverlay>() {
if let Some(child) = overlay.child() {
if let Some(child_box) = child.dynamic_cast_ref::<gtk::Box>() {
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<serde_json::Value> = 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
<categories>
<category>System</category>
<category>PackageManager</category>
<category>GTK</category>
</categories>
```
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<String> {
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<String> {
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<String> {
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
<key name="view-mode" type="s">
<choices>
<choice value='grid'/>
<choice value='list'/>
</choices>
<default>'grid'</default>
```
For `color-scheme` key:
```xml
<choices>
<choice value='default'/>
<choice value='force-light'/>
<choice value='force-dark'/>
</choices>
```
For `detail-tab` key:
```xml
<choices>
<choice value='overview'/>
<choice value='system'/>
<choice value='security'/>
<choice value='storage'/>
</choices>
```
For `update-cleanup` key:
```xml
<choices>
<choice value='ask'/>
<choice value='always'/>
<choice value='never'/>
</choices>
```
For `security-notification-threshold` key:
```xml
<choices>
<choice value='critical'/>
<choice value='high'/>
<choice value='medium'/>
<choice value='low'/>
</choices>
```
For `backup-retention-days`, add range:
```xml
<key name="backup-retention-days" type="i">
<range min="1" max="365"/>
<default>30</default>
```
**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