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.
31 KiB
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:
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:
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:
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
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-1349and1369-1370
Step 1: Change get_cve_summary to handle Option
At line 1348-1349, change:
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
to:
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:
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:
.stderr(std::process::Stdio::piped())
to:
.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):
// 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-132and195-198
Step 1: Use home-relative paths in archive creation
Replace the tar archive entry loop (lines 122-132):
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):
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):
/// 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:
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:
// Always scan on startup to discover new AppImages and complete pending analyses
self.trigger_scan();
with:
// 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:
let (width, height) = self.default_size();
to:
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:
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:
pic_gesture.connect_released(|_, _, _, _| {});
to:
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:
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:
// 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:
// 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:
<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:
let data = &buf[..n];
let haystack = String::from_utf8_lossy(data).to_lowercase();
haystack.contains("type2-runtime")
|| haystack.contains("libfuse3")
with:
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:
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:
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:
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:
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:
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:
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:
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:
<key name="view-mode" type="s">
<choices>
<choice value='grid'/>
<choice value='list'/>
</choices>
<default>'grid'</default>
For color-scheme key:
<choices>
<choice value='default'/>
<choice value='force-light'/>
<choice value='force-dark'/>
</choices>
For detail-tab key:
<choices>
<choice value='overview'/>
<choice value='system'/>
<choice value='security'/>
<choice value='storage'/>
</choices>
For update-cleanup key:
<choices>
<choice value='ask'/>
<choice value='always'/>
<choice value='never'/>
</choices>
For security-notification-threshold key:
<choices>
<choice value='critical'/>
<choice value='high'/>
<choice value='medium'/>
<choice value='low'/>
</choices>
For backup-retention-days, add range:
<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:
/* 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:
cargo build- zero errors, zero warningscargo run- app launches and shows library view- Navigate to preferences, toggle settings, verify they save
- Open a detail view, switch tabs, reopen - tab should be restored