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.
This commit is contained in:
lashman
2026-02-27 22:08:53 +02:00
parent f87403794e
commit e9343da249
27 changed files with 1737 additions and 250 deletions

View File

@@ -22,11 +22,20 @@
<description>Directories to scan for AppImage files.</description> <description>Directories to scan for AppImage files.</description>
</key> </key>
<key name="view-mode" type="s"> <key name="view-mode" type="s">
<choices>
<choice value='grid'/>
<choice value='list'/>
</choices>
<default>'grid'</default> <default>'grid'</default>
<summary>Library view mode</summary> <summary>Library view mode</summary>
<description>The library view mode: grid or list.</description> <description>The library view mode: grid or list.</description>
</key> </key>
<key name="color-scheme" type="s"> <key name="color-scheme" type="s">
<choices>
<choice value='default'/>
<choice value='force-light'/>
<choice value='force-dark'/>
</choices>
<default>'default'</default> <default>'default'</default>
<summary>Color scheme</summary> <summary>Color scheme</summary>
<description>Application color scheme: default (follow system), force-light, or force-dark.</description> <description>Application color scheme: default (follow system), force-light, or force-dark.</description>
@@ -37,6 +46,12 @@
<description>Whether to automatically scan for AppImages when the application starts.</description> <description>Whether to automatically scan for AppImages when the application starts.</description>
</key> </key>
<key name="detail-tab" type="s"> <key name="detail-tab" type="s">
<choices>
<choice value='overview'/>
<choice value='system'/>
<choice value='security'/>
<choice value='storage'/>
</choices>
<default>'overview'</default> <default>'overview'</default>
<summary>Last detail view tab</summary> <summary>Last detail view tab</summary>
<description>The last selected tab in the detail view (overview, system, security, storage).</description> <description>The last selected tab in the detail view (overview, system, security, storage).</description>
@@ -57,6 +72,7 @@
<description>Create a config backup before applying an update.</description> <description>Create a config backup before applying an update.</description>
</key> </key>
<key name="backup-retention-days" type="i"> <key name="backup-retention-days" type="i">
<range min="1" max="365"/>
<default>30</default> <default>30</default>
<summary>Backup retention days</summary> <summary>Backup retention days</summary>
<description>Number of days to keep config backups before auto-cleanup.</description> <description>Number of days to keep config backups before auto-cleanup.</description>
@@ -67,6 +83,11 @@
<description>Show a confirmation dialog before deleting AppImages or backups.</description> <description>Show a confirmation dialog before deleting AppImages or backups.</description>
</key> </key>
<key name="update-cleanup" type="s"> <key name="update-cleanup" type="s">
<choices>
<choice value='ask'/>
<choice value='always'/>
<choice value='never'/>
</choices>
<default>'ask'</default> <default>'ask'</default>
<summary>Update cleanup mode</summary> <summary>Update cleanup mode</summary>
<description>What to do with old versions after update: ask, keep, or delete.</description> <description>What to do with old versions after update: ask, keep, or delete.</description>
@@ -82,6 +103,12 @@
<description>Send desktop notifications when new CVEs are found.</description> <description>Send desktop notifications when new CVEs are found.</description>
</key> </key>
<key name="security-notification-threshold" type="s"> <key name="security-notification-threshold" type="s">
<choices>
<choice value='critical'/>
<choice value='high'/>
<choice value='medium'/>
<choice value='low'/>
</choices>
<default>'high'</default> <default>'high'</default>
<summary>Security notification threshold</summary> <summary>Security notification threshold</summary>
<description>Minimum CVE severity for desktop notifications: critical, high, medium, or low.</description> <description>Minimum CVE severity for desktop notifications: critical, high, medium, or low.</description>

View File

@@ -51,12 +51,6 @@
<control>pointing</control> <control>pointing</control>
</recommends> </recommends>
<categories>
<category>System</category>
<category>PackageManager</category>
<category>GTK</category>
</categories>
<keywords> <keywords>
<keyword>AppImage</keyword> <keyword>AppImage</keyword>
<keyword>Application</keyword> <keyword>Application</keyword>

View File

@@ -94,6 +94,15 @@ flowboxchild:focus-visible .card {
outline-offset: 3px; 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 */ /* Rounded icon clipping for list view */
.icon-rounded { .icon-rounded {
border-radius: 8px; border-radius: 8px;
@@ -117,11 +126,6 @@ row:focus-visible {
outline-offset: -2px; outline-offset: -2px;
} }
/* Badge row in app cards */
.badge-row {
margin-top: 4px;
}
/* Letter-circle fallback icon */ /* Letter-circle fallback icon */
.letter-icon { .letter-icon {
border-radius: 50%; border-radius: 50%;
@@ -151,18 +155,6 @@ row:focus-visible {
margin-bottom: 6px; 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 ===== */ /* ===== Compatibility Warning Banner ===== */
.compat-warning-banner { .compat-warning-banner {
background: alpha(@warning_bg_color, 0.15); background: alpha(@warning_bg_color, 0.15);
@@ -171,45 +163,6 @@ row:focus-visible {
border: 1px solid alpha(@warning_bg_color, 0.3); 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) ===== */ /* ===== Reduced Motion (WCAG AAA 2.3.3) ===== */
/* Note: GTK CSS does not support prefers-reduced-motion or !important. /* Note: GTK CSS does not support prefers-reduced-motion or !important.
Reduced motion is handled by the GTK toolkit settings instead Reduced motion is handled by the GTK toolkit settings instead

View File

@@ -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<String>`, 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 `<categories>` 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 `<choices>` to enumerated string keys, `<range>` 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.

File diff suppressed because it is too large Load Diff

View File

@@ -112,21 +112,19 @@ fn cmd_list(db: &Database, format: &str) -> ExitCode {
} }
if format == "json" { if format == "json" {
// Simple JSON output let items: Vec<serde_json::Value> = records
println!("["); .iter()
for (i, r) in records.iter().enumerate() { .map(|r| {
let comma = if i + 1 < records.len() { "," } else { "" }; serde_json::json!({
println!( "name": r.app_name.as_deref().unwrap_or(&r.filename),
" {{\"name\": \"{}\", \"version\": \"{}\", \"path\": \"{}\", \"size\": {}, \"integrated\": {}}}{}", "version": r.app_version.as_deref().unwrap_or(""),
r.app_name.as_deref().unwrap_or(&r.filename), "path": r.path,
r.app_version.as_deref().unwrap_or(""), "size": r.size_bytes,
r.path, "integrated": r.integrated,
r.size_bytes, })
r.integrated, })
comma, .collect();
); println!("{}", serde_json::to_string_pretty(&items).unwrap_or_else(|_| "[]".into()));
}
println!("]");
return ExitCode::SUCCESS; return ExitCode::SUCCESS;
} }

View File

@@ -15,7 +15,6 @@ const MAX_CONCURRENT_ANALYSES: usize = 2;
static RUNNING_ANALYSES: AtomicUsize = AtomicUsize::new(0); static RUNNING_ANALYSES: AtomicUsize = AtomicUsize::new(0);
/// Returns the number of currently running background analyses. /// Returns the number of currently running background analyses.
#[allow(dead_code)]
pub fn running_count() -> usize { pub fn running_count() -> usize {
RUNNING_ANALYSES.load(Ordering::Relaxed) 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.) // Inspect metadata (app name, version, icon, desktop entry, AppStream, etc.)
if let Ok(meta) = inspector::inspect_appimage(&path, &appimage_type) { 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() { let categories = if meta.categories.is_empty() {
None None
} else { } else {

View File

@@ -405,7 +405,6 @@ fn summarize_content_rating(attrs: &[(String, String)]) -> String {
// AppStream catalog generation - writes catalog XML for GNOME Software/Discover // AppStream catalog generation - writes catalog XML for GNOME Software/Discover
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[allow(dead_code)]
/// Generate an AppStream catalog XML from the Driftwood database. /// Generate an AppStream catalog XML from the Driftwood database.
/// This allows GNOME Software / KDE Discover to see locally managed AppImages. /// This allows GNOME Software / KDE Discover to see locally managed AppImages.
pub fn generate_catalog(db: &Database) -> Result<String, AppStreamError> { pub fn generate_catalog(db: &Database) -> Result<String, AppStreamError> {
@@ -463,7 +462,6 @@ pub fn generate_catalog(db: &Database) -> Result<String, AppStreamError> {
Ok(xml) Ok(xml)
} }
#[allow(dead_code)]
/// Install the AppStream catalog to the local swcatalog directory. /// Install the AppStream catalog to the local swcatalog directory.
/// GNOME Software reads from `~/.local/share/swcatalog/xml/`. /// GNOME Software reads from `~/.local/share/swcatalog/xml/`.
pub fn install_catalog(db: &Database) -> Result<PathBuf, AppStreamError> { pub fn install_catalog(db: &Database) -> Result<PathBuf, AppStreamError> {
@@ -484,7 +482,6 @@ pub fn install_catalog(db: &Database) -> Result<PathBuf, AppStreamError> {
Ok(catalog_path) Ok(catalog_path)
} }
#[allow(dead_code)]
/// Remove the AppStream catalog from the local swcatalog directory. /// Remove the AppStream catalog from the local swcatalog directory.
pub fn uninstall_catalog() -> Result<(), AppStreamError> { pub fn uninstall_catalog() -> Result<(), AppStreamError> {
let catalog_path = dirs::data_dir() let catalog_path = dirs::data_dir()
@@ -501,7 +498,6 @@ pub fn uninstall_catalog() -> Result<(), AppStreamError> {
Ok(()) Ok(())
} }
#[allow(dead_code)]
/// Check if the AppStream catalog is currently installed. /// Check if the AppStream catalog is currently installed.
pub fn is_catalog_installed() -> bool { pub fn is_catalog_installed() -> bool {
let catalog_path = dirs::data_dir() let catalog_path = dirs::data_dir()
@@ -515,7 +511,6 @@ pub fn is_catalog_installed() -> bool {
// --- Utility functions --- // --- Utility functions ---
#[allow(dead_code)]
fn make_component_id(name: &str) -> String { fn make_component_id(name: &str) -> String {
name.chars() name.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c.to_ascii_lowercase() } else { '_' }) .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() .to_string()
} }
#[allow(dead_code)]
fn xml_escape(s: &str) -> String { fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;") s.replace('&', "&amp;")
.replace('<', "&lt;") .replace('<', "&lt;")
@@ -536,7 +530,6 @@ fn xml_escape(s: &str) -> String {
// --- Error types --- // --- Error types ---
#[derive(Debug)] #[derive(Debug)]
#[allow(dead_code)]
pub enum AppStreamError { pub enum AppStreamError {
Database(String), Database(String),
Io(String), Io(String),

View File

@@ -119,16 +119,23 @@ pub fn create_backup(db: &Database, appimage_id: i64) -> Result<PathBuf, BackupE
"manifest.json".to_string(), "manifest.json".to_string(),
]; ];
let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
for entry in &entries { for entry in &entries {
let source = Path::new(&entry.original_path); let source = Path::new(&entry.original_path);
if source.exists() { if source.exists() {
tar_args.push("-C".to_string()); if let Ok(rel) = source.strip_prefix(&home_dir) {
tar_args.push( tar_args.push("-C".to_string());
source.parent().unwrap_or(Path::new("/")).to_string_lossy().to_string(), tar_args.push(home_dir.to_string_lossy().to_string());
); tar_args.push(rel.to_string_lossy().to_string());
tar_args.push( } else {
source.file_name().unwrap_or_default().to_string_lossy().to_string(), 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(),
);
}
} }
} }
@@ -190,12 +197,16 @@ pub fn restore_backup(archive_path: &Path) -> Result<RestoreResult, BackupError>
// Restore each path // Restore each path
let mut restored = 0u32; let mut restored = 0u32;
let mut skipped = 0u32; let mut skipped = 0u32;
let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
for entry in &manifest.paths { for entry in &manifest.paths {
let source_name = Path::new(&entry.original_path) let source = Path::new(&entry.original_path);
.file_name() let extracted = if let Ok(rel) = source.strip_prefix(&home_dir) {
.unwrap_or_default(); temp_dir.path().join(rel)
let extracted = temp_dir.path().join(source_name); } else {
let source_name = source.file_name().unwrap_or_default();
temp_dir.path().join(source_name)
};
let target = Path::new(&entry.original_path); let target = Path::new(&entry.original_path);
if !extracted.exists() { 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. /// Remove backups older than the specified number of days.
#[allow(dead_code)]
pub fn auto_cleanup_old_backups(db: &Database, retention_days: u32) -> Result<u32, BackupError> { pub fn auto_cleanup_old_backups(db: &Database, retention_days: u32) -> Result<u32, BackupError> {
let backups = db.get_all_config_backups().unwrap_or_default(); let backups = db.get_all_config_backups().unwrap_or_default();
let cutoff = chrono::Utc::now() - chrono::Duration::days(retention_days as i64); 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<u3
#[derive(Debug)] #[derive(Debug)]
pub struct BackupInfo { pub struct BackupInfo {
pub id: i64, pub id: i64,
#[allow(dead_code)]
pub appimage_id: i64, pub appimage_id: i64,
pub app_version: Option<String>, pub app_version: Option<String>,
pub archive_path: String, pub archive_path: String,
@@ -304,10 +313,8 @@ pub struct BackupInfo {
#[derive(Debug)] #[derive(Debug)]
pub struct RestoreResult { pub struct RestoreResult {
#[allow(dead_code)]
pub manifest: BackupManifest, pub manifest: BackupManifest,
pub paths_restored: u32, pub paths_restored: u32,
#[allow(dead_code)]
pub paths_skipped: u32, pub paths_skipped: u32,
} }

View File

@@ -184,34 +184,6 @@ pub struct ConfigBackupRecord {
pub last_restored_at: Option<String>, pub last_restored_at: Option<String>,
} }
#[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<String>,
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<String>,
pub categories: Option<String>,
pub latest_version: Option<String>,
pub download_url: String,
pub icon_url: Option<String>,
pub homepage: Option<String>,
pub file_size: Option<i64>,
pub architecture: Option<String>,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SandboxProfileRecord { pub struct SandboxProfileRecord {
pub id: i64, pub id: i64,
@@ -1374,7 +1346,9 @@ impl Database {
WHERE appimage_id = ?1 GROUP BY severity" WHERE appimage_id = ?1 GROUP BY severity"
)?; )?;
let rows = stmt.query_map(params![appimage_id], |row| { let rows = stmt.query_map(params![appimage_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) let severity: String = row.get::<_, Option<String>>(0)?
.unwrap_or_else(|| "MEDIUM".to_string());
Ok((severity, row.get::<_, i64>(1)?))
})?; })?;
for row in rows { for row in rows {
let (severity, count) = row?; let (severity, count) = row?;
@@ -1395,7 +1369,9 @@ impl Database {
"SELECT severity, COUNT(*) FROM cve_matches GROUP BY severity" "SELECT severity, COUNT(*) FROM cve_matches GROUP BY severity"
)?; )?;
let rows = stmt.query_map([], |row| { let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) let severity: String = row.get::<_, Option<String>>(0)?
.unwrap_or_else(|| "MEDIUM".to_string());
Ok((severity, row.get::<_, i64>(1)?))
})?; })?;
for row in rows { for row in rows {
let (severity, count) = row?; let (severity, count) = row?;

View File

@@ -330,9 +330,12 @@ fn build_name_group(name: &str, records: &[&AppImageRecord]) -> DuplicateGroup {
/// Compare two version strings for ordering. /// Compare two version strings for ordering.
fn compare_versions(a: &str, b: &str) -> std::cmp::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 std::cmp::Ordering::Equal
} else if version_is_newer(a, b) { } else if version_is_newer(a, b) {
std::cmp::Ordering::Greater std::cmp::Ordering::Greater

View File

@@ -186,9 +186,20 @@ fn has_static_runtime(appimage_path: &Path) -> bool {
Err(_) => return false, Err(_) => return false,
}; };
let data = &buf[..n]; let data = &buf[..n];
let haystack = String::from_utf8_lossy(data).to_lowercase(); // Search raw bytes directly - avoids allocating a UTF-8 string from binary data.
haystack.contains("type2-runtime") // Case-insensitive matching for the two known signatures.
|| haystack.contains("libfuse3") 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. /// Check if --appimage-extract-and-run is supported.

View File

@@ -38,7 +38,6 @@ pub struct AppImageMetadata {
pub app_version: Option<String>, pub app_version: Option<String>,
pub description: Option<String>, pub description: Option<String>,
pub developer: Option<String>, pub developer: Option<String>,
#[allow(dead_code)]
pub icon_name: Option<String>, pub icon_name: Option<String>,
pub categories: Vec<String>, pub categories: Vec<String>,
pub desktop_entry_content: String, pub desktop_entry_content: String,
@@ -246,7 +245,7 @@ fn extract_metadata_files(
.arg("usr/share/metainfo/*.xml") .arg("usr/share/metainfo/*.xml")
.arg("usr/share/appdata/*.xml") .arg("usr/share/appdata/*.xml")
.stdout(std::process::Stdio::null()) .stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped()) .stderr(std::process::Stdio::null())
.status(); .status();
match status { match status {
@@ -430,8 +429,20 @@ fn detect_architecture(path: &Path) -> Option<String> {
let mut header = [0u8; 20]; let mut header = [0u8; 20];
file.read_exact(&mut header).ok()?; file.read_exact(&mut header).ok()?;
// ELF e_machine at offset 18 (little-endian) // Validate ELF magic
let machine = u16::from_le_bytes([header[18], header[19]]); 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 { match machine {
0x03 => Some("i386".to_string()), 0x03 => Some("i386".to_string()),
0x3E => Some("x86_64".to_string()), 0x3E => Some("x86_64".to_string()),
@@ -529,12 +540,42 @@ fn find_appstream_file(extract_dir: &Path) -> Option<PathBuf> {
/// Check if an AppImage has a GPG signature by looking for the .sha256_sig section name. /// Check if an AppImage has a GPG signature by looking for the .sha256_sig section name.
fn detect_signature(path: &Path) -> bool { fn detect_signature(path: &Path) -> bool {
let data = match fs::read(path) { use std::io::{BufReader, Read};
Ok(d) => d, let file = match fs::File::open(path) {
Ok(f) => f,
Err(_) => return false, Err(_) => return false,
}; };
let needle = b".sha256_sig"; 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. /// Cache an icon file to the driftwood icons directory.

View File

@@ -94,7 +94,7 @@ pub fn integrate(record: &AppImageRecord) -> Result<IntegrationResult, Integrati
"[Desktop Entry]\n\ "[Desktop Entry]\n\
Type=Application\n\ Type=Application\n\
Name={name}\n\ Name={name}\n\
Exec={exec} %U\n\ Exec=\"{exec}\" %U\n\
Icon={icon}\n\ Icon={icon}\n\
Categories={categories}\n\ Categories={categories}\n\
Comment={comment}\n\ Comment={comment}\n\
@@ -228,7 +228,7 @@ mod tests {
#[test] #[test]
fn test_integrate_creates_desktop_file() { fn test_integrate_creates_desktop_file() {
let dir = tempfile::tempdir().unwrap(); let _dir = tempfile::tempdir().unwrap();
// Override the applications dir for testing by creating the record // Override the applications dir for testing by creating the record
// with a specific path and testing the desktop content generation // with a specific path and testing the desktop content generation
let record = AppImageRecord { let record = AppImageRecord {

View File

@@ -42,7 +42,6 @@ pub enum LaunchMethod {
/// Extract-and-run fallback (APPIMAGE_EXTRACT_AND_RUN=1) /// Extract-and-run fallback (APPIMAGE_EXTRACT_AND_RUN=1)
ExtractAndRun, ExtractAndRun,
/// Via firejail sandbox /// Via firejail sandbox
#[allow(dead_code)]
Sandboxed, Sandboxed,
} }
@@ -68,7 +67,6 @@ pub enum LaunchResult {
Crashed { Crashed {
exit_code: Option<i32>, exit_code: Option<i32>,
stderr: String, stderr: String,
#[allow(dead_code)]
method: LaunchMethod, method: LaunchMethod,
}, },
/// Failed to launch. /// 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); let result = execute_appimage(appimage_path, &method, extra_args, extra_env);
// Record the launch event regardless of success // Record the launch event regardless of success
@@ -163,42 +177,38 @@ fn execute_appimage(
cmd.env(key, value); 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.stdin(Stdio::null());
cmd.stderr(Stdio::piped()); cmd.stderr(Stdio::piped());
match cmd.spawn() { match cmd.spawn() {
Ok(mut child) => { Ok(mut child) => {
// Brief wait to detect immediate crashes (e.g. missing Qt plugins) // Give the process a brief moment to fail on immediate errors
std::thread::sleep(std::time::Duration::from_millis(1500)); // (missing libs, exec format errors, Qt plugin failures, etc.)
std::thread::sleep(std::time::Duration::from_millis(150));
match child.try_wait() { match child.try_wait() {
Ok(Some(status)) => { Ok(Some(status)) => {
// Process already exited - it crashed // Already exited - immediate crash. Read stderr for details.
let stderr = child let stderr_text = child.stderr.take().map(|mut pipe| {
.stderr let mut buf = String::new();
.take() use std::io::Read;
.and_then(|mut err| { // Read with a size cap to avoid huge allocations
let mut buf = String::new(); let mut limited = (&mut pipe).take(64 * 1024);
use std::io::Read; let _ = limited.read_to_string(&mut buf);
err.read_to_string(&mut buf).ok()?; buf
Some(buf) }).unwrap_or_default();
})
.unwrap_or_default();
LaunchResult::Crashed { LaunchResult::Crashed {
exit_code: status.code(), exit_code: status.code(),
stderr, stderr: stderr_text,
method: method.clone(), method: method.clone(),
} }
} }
Ok(None) => { _ => {
// Still running - success // Still running after 150ms - drop the stderr pipe so the
LaunchResult::Started { // child process won't block if it fills the pipe buffer.
child, drop(child.stderr.take());
method: method.clone(),
}
}
Err(_) => {
// Can't check status, assume it's running
LaunchResult::Started { LaunchResult::Started {
child, child,
method: method.clone(), method: method.clone(),
@@ -211,11 +221,38 @@ fn execute_appimage(
} }
/// Parse a launch_args string from the database into a Vec of individual arguments. /// 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. /// Parse launch arguments with basic quote support.
#[allow(dead_code)] /// 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<String> { pub fn parse_launch_args(args: Option<&str>) -> Vec<String> {
args.map(|s| s.split_whitespace().map(String::from).collect()) let Some(s) = args else {
.unwrap_or_default() 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. /// Check if firejail is available for sandboxed launches.

View File

@@ -5,7 +5,6 @@ use super::security;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CveNotification { pub struct CveNotification {
pub app_name: String, pub app_name: String,
#[allow(dead_code)]
pub appimage_id: i64, pub appimage_id: i64,
pub severity: String, pub severity: String,
pub cve_count: usize, 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. /// Run a security scan and send notifications for any new findings.
/// This is the CLI entry point for `driftwood security --notify`. /// This is the CLI entry point for `driftwood security --notify`.
#[allow(dead_code)]
pub fn scan_and_notify(db: &Database, threshold: &str) -> Vec<CveNotification> { pub fn scan_and_notify(db: &Database, threshold: &str) -> Vec<CveNotification> {
// First run a batch scan to get fresh data // First run a batch scan to get fresh data
let _results = security::batch_scan(db); let _results = security::batch_scan(db);

View File

@@ -10,7 +10,6 @@ pub enum ReportFormat {
} }
impl ReportFormat { impl ReportFormat {
#[allow(dead_code)]
pub fn from_str(s: &str) -> Option<Self> { pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() { match s.to_lowercase().as_str() {
"json" => Some(Self::Json), "json" => Some(Self::Json),
@@ -20,7 +19,6 @@ impl ReportFormat {
} }
} }
#[allow(dead_code)]
pub fn extension(&self) -> &'static str { pub fn extension(&self) -> &'static str {
match self { match self {
Self::Json => "json", Self::Json => "json",

View File

@@ -28,7 +28,6 @@ pub struct CveMatch {
/// Result of a security scan for a single AppImage. /// Result of a security scan for a single AppImage.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct SecurityScanResult { pub struct SecurityScanResult {
#[allow(dead_code)]
pub appimage_id: i64, pub appimage_id: i64,
pub libraries: Vec<BundledLibrary>, pub libraries: Vec<BundledLibrary>,
pub cve_matches: Vec<(BundledLibrary, Vec<CveMatch>)>, pub cve_matches: Vec<(BundledLibrary, Vec<CveMatch>)>,
@@ -254,10 +253,9 @@ pub fn detect_version_from_binary(
let extract_output = Command::new("unsquashfs") let extract_output = Command::new("unsquashfs")
.args(["-o", &offset, "-f", "-d"]) .args(["-o", &offset, "-f", "-d"])
.arg(temp_dir.path()) .arg(temp_dir.path())
.arg("-e")
.arg(lib_file_path.trim_start_matches("squashfs-root/"))
.arg("-no-progress") .arg("-no-progress")
.arg(appimage_path) .arg(appimage_path)
.arg(lib_file_path.trim_start_matches("squashfs-root/"))
.output() .output()
.ok()?; .ok()?;

View File

@@ -370,27 +370,51 @@ fn parse_elf32_sections(data: &[u8]) -> Option<String> {
} }
/// Fallback: run the AppImage with --appimage-updateinformation flag. /// 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<String> { fn extract_update_info_runtime(path: &Path) -> Option<String> {
let output = std::process::Command::new(path) let mut child = std::process::Command::new(path)
.arg("--appimage-updateinformation") .arg("--appimage-updateinformation")
.env("APPIMAGE_EXTRACT_AND_RUN", "1") .env("APPIMAGE_EXTRACT_AND_RUN", "1")
.output() .stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.ok()?; .ok()?;
if output.status.success() { let timeout = std::time::Duration::from_secs(5);
let info = String::from_utf8_lossy(&output.stdout).trim().to_string(); let start = std::time::Instant::now();
if !info.is_empty() && info.contains('|') { loop {
return Some(info); 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 -- // -- GitHub/GitLab API types for JSON deserialization --
#[derive(Deserialize)] #[derive(Deserialize)]
#[allow(dead_code)]
struct GhRelease { struct GhRelease {
tag_name: String, tag_name: String,
name: Option<String>, name: Option<String>,
@@ -406,7 +430,6 @@ struct GhAsset {
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[allow(dead_code)]
struct GlRelease { struct GlRelease {
tag_name: String, tag_name: String,
name: Option<String>, name: Option<String>,
@@ -492,6 +515,12 @@ fn check_github_release(
let release: GhRelease = response.body_mut().read_json().ok()?; 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); let latest_version = clean_version(&release.tag_name);
// Find matching asset using glob-like pattern // Find matching asset using glob-like pattern
@@ -549,6 +578,12 @@ fn check_gitlab_release(
let release: GlRelease = response.body_mut().read_json().ok()?; 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 latest_version = clean_version(&release.tag_name);
let download_url = release.assets.and_then(|assets| { 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 *) // Last part must match at the end (unless pattern ends with *)
let last = parts[parts.len() - 1]; let last = parts[parts.len() - 1];
if !last.is_empty() { let end_limit = if !last.is_empty() {
if !text.ends_with(last) { if !text.ends_with(last) {
return false; 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] { for part in &parts[1..parts.len() - 1] {
if part.is_empty() { if part.is_empty() {
continue; 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(); pos += found + part.len();
} else { } else {
return false; return false;
@@ -691,7 +732,7 @@ fn glob_match(pattern: &str, text: &str) -> bool {
} }
/// Clean a version string - strip leading 'v' or 'V' prefix. /// 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(); let v = version.trim();
v.strip_prefix('v') v.strip_prefix('v')
.or_else(|| 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). /// Verify that a file is a valid AppImage (has ELF header + AppImage magic bytes).
fn verify_appimage(path: &Path) -> bool { fn verify_appimage(path: &Path) -> bool {
if let Ok(data) = fs::read(path) { use std::io::Read;
if data.len() < 12 { let mut file = match fs::File::open(path) {
return false; Ok(f) => f,
} Err(_) => return false,
// Check ELF magic };
if &data[0..4] != b"\x7FELF" { let mut header = [0u8; 12];
return false; if file.read_exact(&mut header).is_err() {
} 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;
}
} }
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. /// Perform an update using the best available method.

View File

@@ -8,7 +8,7 @@ use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watche
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum WatchEvent { pub enum WatchEvent {
/// One or more AppImage files were created, modified, or deleted. /// One or more AppImage files were created, modified, or deleted.
Changed(#[allow(dead_code)] Vec<PathBuf>), Changed(Vec<PathBuf>),
} }
/// Start watching the given directories for AppImage file changes. /// Start watching the given directories for AppImage file changes.

View File

@@ -307,11 +307,9 @@ pub fn detect_desktop_environment() -> String {
/// Result of analyzing a running process for Wayland usage. /// Result of analyzing a running process for Wayland usage.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RuntimeAnalysis { pub struct RuntimeAnalysis {
#[allow(dead_code)]
pub pid: u32, pub pid: u32,
pub has_wayland_socket: bool, pub has_wayland_socket: bool,
pub has_x11_connection: bool, pub has_x11_connection: bool,
#[allow(dead_code)]
pub env_vars: Vec<(String, String)>, pub env_vars: Vec<(String, String)>,
} }
@@ -391,7 +389,6 @@ pub fn analyze_running_process(pid: u32) -> Result<RuntimeAnalysis, String> {
has_wayland_socket = env_vars.iter().any(|(k, v)| { has_wayland_socket = env_vars.iter().any(|(k, v)| {
(k == "GDK_BACKEND" && v.contains("wayland")) (k == "GDK_BACKEND" && v.contains("wayland"))
|| (k == "QT_QPA_PLATFORM" && v.contains("wayland")) || (k == "QT_QPA_PLATFORM" && v.contains("wayland"))
|| (k == "WAYLAND_DISPLAY" && !v.is_empty())
}); });
} }

View File

@@ -23,7 +23,6 @@ pub fn i18n_f(msgid: &str, args: &[(&str, &str)]) -> String {
} }
/// Translate a string with singular/plural forms and named placeholders. /// 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 { pub fn ni18n_f(singular: &str, plural: &str, n: u32, args: &[(&str, &str)]) -> String {
let base = if n == 1 { singular } else { plural }; let base = if n == 1 { singular } else { plural };
let mut result = base.to_string(); let mut result = base.to_string();

View File

@@ -48,6 +48,20 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
view_stack.add_titled(&storage_page, Some("storage"), "Storage"); view_stack.add_titled(&storage_page, Some("storage"), "Storage");
view_stack.page(&storage_page).set_icon_name(Some("drive-harddisk-symbolic")); 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 // Banner scrolls with content (not sticky) so tall banners don't eat space
let scroll_content = gtk::Box::builder() let scroll_content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical) .orientation(gtk::Orientation::Vertical)
@@ -83,6 +97,7 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
let record_id = record.id; let record_id = record.id;
let path = record.path.clone(); let path = record.path.clone();
let app_name_launch = record.app_name.clone().unwrap_or_else(|| record.filename.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 db_launch = db.clone();
let toast_launch = toast_overlay.clone(); let toast_launch = toast_overlay.clone();
launch_button.connect_clicked(move |btn| { launch_button.connect_clicked(move |btn| {
@@ -92,6 +107,7 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
let app_name = app_name_launch.clone(); let app_name = app_name_launch.clone();
let db_launch = db_launch.clone(); let db_launch = db_launch.clone();
let toast_ref = toast_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 { glib::spawn_future_local(async move {
let path_bg = path.clone(); let path_bg = path.clone();
let result = gio::spawn_blocking(move || { let result = gio::spawn_blocking(move || {
@@ -101,7 +117,7 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
record_id, record_id,
appimage_path, appimage_path,
"gui_detail", "gui_detail",
&[], &launch_args,
&[], &[],
) )
}).await; }).await;
@@ -121,13 +137,16 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
}).await; }).await;
if let Ok(Ok(analysis)) = analysis_result { if let Ok(Ok(analysis)) = analysis_result {
let status_str = analysis.as_status_str(); 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(); db_wayland.update_runtime_wayland_status(record_id, status_str).ok();
} }
}); });
} }
Ok(launcher::LaunchResult::Crashed { exit_code, stderr, .. }) => { Ok(launcher::LaunchResult::Crashed { exit_code, stderr, method }) => {
log::error!("App crashed on launch (exit {}): {}", exit_code.unwrap_or(-1), stderr); 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); widgets::show_crash_dialog(&btn_ref, &app_name, exit_code, &stderr);
} }
Ok(launcher::LaunchResult::Failed(msg)) => { Ok(launcher::LaunchResult::Failed(msg)) => {
@@ -247,9 +266,7 @@ fn build_banner(record: &AppImageRecord) -> gtk::Box {
.margin_top(4) .margin_top(4)
.build(); .build();
if record.integrated { badge_box.append(&widgets::integration_badge(record.integrated));
badge_box.append(&widgets::status_badge("Integrated", "success"));
}
if let Some(ref ws) = record.wayland_status { if let Some(ref ws) = record.wayland_status {
let status = WaylandStatus::from_str(ws); 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); group.add(&empty_row);
} else { } else {
for b in &backups { 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() let expander = adw::ExpanderRow::builder()
.title(&b.created_at) .title(&b.created_at)
.subtitle(&format!( .subtitle(&format!(
@@ -1656,12 +1677,28 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
row_clone.set_sensitive(true); row_clone.set_sensitive(true);
match result { match result {
Ok(Ok(res)) => { Ok(Ok(res)) => {
let skip_note = if res.paths_skipped > 0 {
format!(" ({} skipped)", res.paths_skipped)
} else {
String::new()
};
row_clone.set_subtitle(&format!( row_clone.set_subtitle(&format!(
"Restored {} path{}", "Restored {} path{}{}",
res.paths_restored, res.paths_restored,
if res.paths_restored == 1 { "" } else { "s" }, 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"); row_clone.set_subtitle("Restore failed");
@@ -1922,7 +1959,9 @@ fn show_screenshot_lightbox(
// --- Click outside image to close --- // --- Click outside image to close ---
// Picture's gesture claims clicks on the image, preventing close. // Picture's gesture claims clicks on the image, preventing close.
let pic_gesture = gtk::GestureClick::new(); 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); picture.add_controller(pic_gesture);
// Window gesture fires for clicks on the dark margin area. // Window gesture fires for clicks on the dark margin area.

View File

@@ -2,6 +2,8 @@ use adw::prelude::*;
use gtk::gio; use gtk::gio;
use crate::config::APP_ID; use crate::config::APP_ID;
use crate::core::appstream;
use crate::core::database::Database;
use crate::i18n::i18n; use crate::i18n::i18n;
pub fn show_preferences_dialog(parent: &impl IsA<gtk::Widget>) { pub fn show_preferences_dialog(parent: &impl IsA<gtk::Widget>) {
@@ -153,6 +155,47 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
scan_group.add(&add_button); scan_group.add(&add_button);
page.add(&scan_group); 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 page
} }

View File

@@ -61,7 +61,17 @@ pub fn build_security_report_page(db: &Rc<Database>) -> adw::NavigationPage {
if let Ok(results) = result { if let Ok(results) = result {
let total_cves: usize = results.iter().map(|r| r.total_cves()).sum(); 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()); 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 // Refresh the page content with updated data
let new_content = build_report_content(&db_refresh); let new_content = build_report_content(&db_refresh);
@@ -119,9 +129,14 @@ pub fn build_security_report_page(db: &Rc<Database>) -> adw::NavigationPage {
filters.append(&json_filter); filters.append(&json_filter);
filters.append(&csv_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() let dialog = gtk::FileDialog::builder()
.title("Export Security Report") .title("Export Security Report")
.initial_name("driftwood-security-report.html") .initial_name(&initial_name)
.filters(&filters) .filters(&filters)
.default_filter(&html_filter) .default_filter(&html_filter)
.modal(true) .modal(true)
@@ -142,11 +157,8 @@ pub fn build_security_report_page(db: &Rc<Database>) -> adw::NavigationPage {
.unwrap_or("html") .unwrap_or("html")
.to_lowercase(); .to_lowercase();
let format = match ext.as_str() { let format = report::ReportFormat::from_str(&ext)
"json" => report::ReportFormat::Json, .unwrap_or(report::ReportFormat::Html);
"csv" => report::ReportFormat::Csv,
_ => report::ReportFormat::Html,
};
btn_clone.set_sensitive(false); btn_clone.set_sensitive(false);
btn_clone.set_label("Exporting..."); btn_clone.set_label("Exporting...");

View File

@@ -88,7 +88,6 @@ pub fn status_badge_with_icon(icon_name: &str, text: &str, style_class: &str) ->
} }
/// Create a badge showing integration status. /// Create a badge showing integration status.
#[allow(dead_code)]
pub fn integration_badge(integrated: bool) -> gtk::Label { pub fn integration_badge(integrated: bool) -> gtk::Label {
if integrated { if integrated {
status_badge("Integrated", "success") 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, /// Inserts a hidden label with AccessibleRole::Alert into the given container,
/// which causes AT-SPI to announce the text to screen readers. /// which causes AT-SPI to announce the text to screen readers.
/// The label auto-removes after a short delay. /// The label auto-removes after a short delay.
#[allow(dead_code)]
pub fn announce(container: &impl gtk::prelude::IsA<gtk::Widget>, text: &str) { pub fn announce(container: &impl gtk::prelude::IsA<gtk::Widget>, text: &str) {
let label = gtk::Label::builder() let label = gtk::Label::builder()
.label(text) .label(text)
@@ -354,7 +352,16 @@ pub fn announce(container: &impl gtk::prelude::IsA<gtk::Widget>, text: &str) {
.build(); .build();
label.update_property(&[gtk::accessible::Property::Label(text)]); label.update_property(&[gtk::accessible::Property::Label(text)]);
if let Some(box_widget) = container.dynamic_cast_ref::<gtk::Box>() { // Try to find a suitable Box container to attach the label to
let target_box = container.dynamic_cast_ref::<gtk::Box>().cloned()
.or_else(|| {
// For Stack widgets, use the visible child if it's a Box
container.dynamic_cast_ref::<gtk::Stack>()
.and_then(|s| s.visible_child())
.and_then(|c| c.downcast::<gtk::Box>().ok())
});
if let Some(box_widget) = target_box {
box_widget.append(&label); box_widget.append(&label);
label.set_visible(true); label.set_visible(true);
let label_clone = label.clone(); let label_clone = label.clone();

View File

@@ -595,29 +595,30 @@ impl DriftwoodWindow {
let Some(record_id) = param.and_then(|p| p.get::<i64>()) else { return }; let Some(record_id) = param.and_then(|p| p.get::<i64>()) else { return };
let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); let toast_overlay = window.imp().toast_overlay.get().unwrap().clone();
let window_ref = window.clone(); let window_ref = window.clone();
let (path_str, app_name) = { let (path_str, app_name, launch_args_raw) = {
let db = window.database(); let db = window.database();
match db.get_appimage_by_id(record_id) { match db.get_appimage_by_id(record_id) {
Ok(Some(r)) => { Ok(Some(r)) => {
let name = r.app_name.clone().unwrap_or_else(|| r.filename.clone()); 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, _ => return,
} }
}; };
let launch_args = launcher::parse_launch_args(launch_args_raw.as_deref());
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let path_bg = path_str.clone(); let path_bg = path_str.clone();
let result = gio::spawn_blocking(move || { let result = gio::spawn_blocking(move || {
let bg_db = crate::core::database::Database::open().expect("DB open"); let bg_db = crate::core::database::Database::open().expect("DB open");
let appimage_path = std::path::Path::new(&path_bg); 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; }).await;
match result { match result {
Ok(launcher::LaunchResult::Started { child, method }) => { Ok(launcher::LaunchResult::Started { child, method }) => {
log::info!("Launched: {} (PID: {}, method: {})", path_str, child.id(), method.as_str()); log::info!("Launched: {} (PID: {}, method: {})", path_str, child.id(), method.as_str());
} }
Ok(launcher::LaunchResult::Crashed { exit_code, stderr, .. }) => { Ok(launcher::LaunchResult::Crashed { exit_code, stderr, method }) => {
log::error!("App crashed (exit {}): {}", exit_code.unwrap_or(-1), stderr); 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); widgets::show_crash_dialog(&window_ref, &app_name, exit_code, &stderr);
} }
Ok(launcher::LaunchResult::Failed(msg)) => { Ok(launcher::LaunchResult::Failed(msg)) => {
@@ -830,12 +831,49 @@ impl DriftwoodWindow {
} }
} }
// Always scan on startup to discover new AppImages and complete pending analyses // Scan on startup if enabled in preferences
self.trigger_scan(); if self.settings().boolean("auto-scan-on-startup") {
self.trigger_scan();
}
// Start watching scan directories for new AppImage files // Start watching scan directories for new AppImage files
self.start_file_watcher(); 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 &notifications {
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 // Check for orphaned desktop entries in the background
let toast_overlay = self.imp().toast_overlay.get().unwrap().clone(); let toast_overlay = self.imp().toast_overlay.get().unwrap().clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
@@ -989,6 +1027,10 @@ impl DriftwoodWindow {
toast_overlay.add_toast(adw::Toast::new(&msg)); toast_overlay.add_toast(adw::Toast::new(&msg));
// Phase 2: Background analysis per file with debounced UI refresh // 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() { if !needs_analysis.is_empty() {
let pending = Rc::new(std::cell::Cell::new(needs_analysis.len())); let pending = Rc::new(std::cell::Cell::new(needs_analysis.len()));
let refresh_timer: Rc<std::cell::Cell<Option<glib::SourceId>>> = let refresh_timer: Rc<std::cell::Cell<Option<glib::SourceId>>> =
@@ -1057,20 +1099,27 @@ impl DriftwoodWindow {
let changed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let changed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let changed_watcher = changed.clone(); 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); changed_watcher.store(true, std::sync::atomic::Ordering::Relaxed);
}); });
if let Some(h) = handle { if let Some(h) = handle {
self.imp().watcher_handle.replace(Some(h)); 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(); let window_weak = self.downgrade();
glib::timeout_add_local(std::time::Duration::from_secs(1), move || { 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 changed.swap(false, std::sync::atomic::Ordering::Relaxed) {
if let Some(window) = window_weak.upgrade() { window.trigger_scan();
window.trigger_scan();
}
} }
glib::ControlFlow::Continue glib::ControlFlow::Continue
}); });
@@ -1204,7 +1253,7 @@ impl DriftwoodWindow {
fn save_window_state(&self) { fn save_window_state(&self) {
let settings = self.settings(); let settings = self.settings();
let (width, height) = self.default_size(); let (width, height) = (self.width(), self.height());
settings settings
.set_int("window-width", width) .set_int("window-width", width)
.expect("Failed to save window width"); .expect("Failed to save window width");