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.
1156 lines
31 KiB
Markdown
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
|