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>
</key>
<key name="view-mode" type="s">
<choices>
<choice value='grid'/>
<choice value='list'/>
</choices>
<default>'grid'</default>
<summary>Library view mode</summary>
<description>The library view mode: grid or list.</description>
</key>
<key name="color-scheme" type="s">
<choices>
<choice value='default'/>
<choice value='force-light'/>
<choice value='force-dark'/>
</choices>
<default>'default'</default>
<summary>Color scheme</summary>
<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>
</key>
<key name="detail-tab" type="s">
<choices>
<choice value='overview'/>
<choice value='system'/>
<choice value='security'/>
<choice value='storage'/>
</choices>
<default>'overview'</default>
<summary>Last detail view tab</summary>
<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>
</key>
<key name="backup-retention-days" type="i">
<range min="1" max="365"/>
<default>30</default>
<summary>Backup retention days</summary>
<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>
</key>
<key name="update-cleanup" type="s">
<choices>
<choice value='ask'/>
<choice value='always'/>
<choice value='never'/>
</choices>
<default>'ask'</default>
<summary>Update cleanup mode</summary>
<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>
</key>
<key name="security-notification-threshold" type="s">
<choices>
<choice value='critical'/>
<choice value='high'/>
<choice value='medium'/>
<choice value='low'/>
</choices>
<default>'high'</default>
<summary>Security notification threshold</summary>
<description>Minimum CVE severity for desktop notifications: critical, high, medium, or low.</description>

View File

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

View File

@@ -94,6 +94,15 @@ flowboxchild:focus-visible .card {
outline-offset: 3px;
}
/* App card status indicators */
.status-ok {
border: 1px solid alpha(@success_bg_color, 0.4);
}
.status-attention {
border: 1px solid alpha(@warning_bg_color, 0.4);
}
/* Rounded icon clipping for list view */
.icon-rounded {
border-radius: 8px;
@@ -117,11 +126,6 @@ row:focus-visible {
outline-offset: -2px;
}
/* Badge row in app cards */
.badge-row {
margin-top: 4px;
}
/* Letter-circle fallback icon */
.letter-icon {
border-radius: 50%;
@@ -151,18 +155,6 @@ row:focus-visible {
margin-bottom: 6px;
}
/* Inline ViewSwitcher positioning */
.detail-view-switcher {
margin-top: 6px;
margin-bottom: 6px;
}
/* ===== Quick Action Pills ===== */
.quick-action-pill {
border-radius: 18px;
padding: 6px 16px;
}
/* ===== Compatibility Warning Banner ===== */
.compat-warning-banner {
background: alpha(@warning_bg_color, 0.15);
@@ -171,45 +163,6 @@ row:focus-visible {
border: 1px solid alpha(@warning_bg_color, 0.3);
}
/* ===== Dark Mode Differentiation ===== */
@media (prefers-color-scheme: dark) {
.compat-warning-banner {
background: alpha(@warning_bg_color, 0.1);
border: 1px solid alpha(@warning_bg_color, 0.2);
}
}
/* ===== High Contrast Mode (WCAG AAA 1.4.6) ===== */
@media (prefers-contrast: more) {
flowboxchild:focus-visible .card {
outline-width: 3px;
}
button:focus-visible,
togglebutton:focus-visible,
menubutton:focus-visible,
checkbutton:focus-visible,
switch:focus-visible,
entry:focus-visible,
searchentry:focus-visible,
spinbutton:focus-visible {
outline-width: 3px;
}
row:focus-visible {
outline-width: 3px;
}
.status-badge,
.status-badge-with-icon {
border: 1px solid currentColor;
}
.compat-warning-banner {
border: 2px solid @warning_bg_color;
}
}
/* ===== Reduced Motion (WCAG AAA 2.3.3) ===== */
/* Note: GTK CSS does not support prefers-reduced-motion or !important.
Reduced motion is handled by the GTK toolkit settings instead

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" {
// Simple JSON output
println!("[");
for (i, r) in records.iter().enumerate() {
let comma = if i + 1 < records.len() { "," } else { "" };
println!(
" {{\"name\": \"{}\", \"version\": \"{}\", \"path\": \"{}\", \"size\": {}, \"integrated\": {}}}{}",
r.app_name.as_deref().unwrap_or(&r.filename),
r.app_version.as_deref().unwrap_or(""),
r.path,
r.size_bytes,
r.integrated,
comma,
);
}
println!("]");
let items: Vec<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;
}

View File

@@ -15,7 +15,6 @@ const MAX_CONCURRENT_ANALYSES: usize = 2;
static RUNNING_ANALYSES: AtomicUsize = AtomicUsize::new(0);
/// Returns the number of currently running background analyses.
#[allow(dead_code)]
pub fn running_count() -> usize {
RUNNING_ANALYSES.load(Ordering::Relaxed)
}
@@ -64,6 +63,10 @@ pub fn run_background_analysis(id: i64, path: PathBuf, appimage_type: AppImageTy
// Inspect metadata (app name, version, icon, desktop entry, AppStream, etc.)
if let Ok(meta) = inspector::inspect_appimage(&path, &appimage_type) {
log::debug!(
"Metadata for id={}: name={:?}, icon_name={:?}",
id, meta.app_name.as_deref(), meta.icon_name.as_deref(),
);
let categories = if meta.categories.is_empty() {
None
} else {

View File

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

View File

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

View File

@@ -184,34 +184,6 @@ pub struct ConfigBackupRecord {
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)]
pub struct SandboxProfileRecord {
pub id: i64,
@@ -1374,7 +1346,9 @@ impl Database {
WHERE appimage_id = ?1 GROUP BY severity"
)?;
let rows = stmt.query_map(params![appimage_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
let severity: String = row.get::<_, Option<String>>(0)?
.unwrap_or_else(|| "MEDIUM".to_string());
Ok((severity, row.get::<_, i64>(1)?))
})?;
for row in rows {
let (severity, count) = row?;
@@ -1395,7 +1369,9 @@ impl Database {
"SELECT severity, COUNT(*) FROM cve_matches GROUP BY severity"
)?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
let severity: String = row.get::<_, Option<String>>(0)?
.unwrap_or_else(|| "MEDIUM".to_string());
Ok((severity, row.get::<_, i64>(1)?))
})?;
for row in rows {
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.
fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
use super::updater::version_is_newer;
use super::updater::{clean_version, version_is_newer};
if a == b {
let ca = clean_version(a);
let cb = clean_version(b);
if ca == cb {
std::cmp::Ordering::Equal
} else if version_is_newer(a, b) {
std::cmp::Ordering::Greater

View File

@@ -186,9 +186,20 @@ fn has_static_runtime(appimage_path: &Path) -> bool {
Err(_) => return false,
};
let data = &buf[..n];
let haystack = String::from_utf8_lossy(data).to_lowercase();
haystack.contains("type2-runtime")
|| haystack.contains("libfuse3")
// Search raw bytes directly - avoids allocating a UTF-8 string from binary data.
// Case-insensitive matching for the two known signatures.
bytes_contains_ci(data, b"type2-runtime")
|| bytes_contains_ci(data, b"libfuse3")
}
/// Case-insensitive byte-level substring search (ASCII only).
fn bytes_contains_ci(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || haystack.len() < needle.len() {
return false;
}
haystack.windows(needle.len()).any(|window| {
window.iter().zip(needle).all(|(h, n)| h.to_ascii_lowercase() == n.to_ascii_lowercase())
})
}
/// Check if --appimage-extract-and-run is supported.

View File

@@ -38,7 +38,6 @@ pub struct AppImageMetadata {
pub app_version: Option<String>,
pub description: Option<String>,
pub developer: Option<String>,
#[allow(dead_code)]
pub icon_name: Option<String>,
pub categories: Vec<String>,
pub desktop_entry_content: String,
@@ -246,7 +245,7 @@ fn extract_metadata_files(
.arg("usr/share/metainfo/*.xml")
.arg("usr/share/appdata/*.xml")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.status();
match status {
@@ -430,8 +429,20 @@ fn detect_architecture(path: &Path) -> Option<String> {
let mut header = [0u8; 20];
file.read_exact(&mut header).ok()?;
// ELF e_machine at offset 18 (little-endian)
let machine = u16::from_le_bytes([header[18], header[19]]);
// Validate ELF magic
if &header[0..4] != b"\x7FELF" {
return None;
}
// ELF e_machine at offset 18, endianness from byte 5
let machine = if header[5] == 2 {
// Big-endian
u16::from_be_bytes([header[18], header[19]])
} else {
// Little-endian (default)
u16::from_le_bytes([header[18], header[19]])
};
match machine {
0x03 => Some("i386".to_string()),
0x3E => Some("x86_64".to_string()),
@@ -529,12 +540,42 @@ fn find_appstream_file(extract_dir: &Path) -> Option<PathBuf> {
/// Check if an AppImage has a GPG signature by looking for the .sha256_sig section name.
fn detect_signature(path: &Path) -> bool {
let data = match fs::read(path) {
Ok(d) => d,
use std::io::{BufReader, Read};
let file = match fs::File::open(path) {
Ok(f) => f,
Err(_) => return false,
};
let needle = b".sha256_sig";
data.windows(needle.len()).any(|w| w == needle)
let mut reader = BufReader::new(file);
let mut buf = vec![0u8; 64 * 1024];
let mut carry = Vec::new();
loop {
let n = match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => n,
Err(_) => break,
};
// Prepend carry bytes from previous chunk to handle needle spanning chunks
let search_buf = if carry.is_empty() {
&buf[..n]
} else {
carry.extend_from_slice(&buf[..n]);
carry.as_slice()
};
if search_buf.windows(needle.len()).any(|w| w == needle) {
return true;
}
// Keep the last (needle.len - 1) bytes as carry for the next iteration
let keep = needle.len() - 1;
carry.clear();
if n >= keep {
carry.extend_from_slice(&buf[n - keep..n]);
} else {
carry.extend_from_slice(&buf[..n]);
}
}
false
}
/// Cache an icon file to the driftwood icons directory.

View File

@@ -94,7 +94,7 @@ pub fn integrate(record: &AppImageRecord) -> Result<IntegrationResult, Integrati
"[Desktop Entry]\n\
Type=Application\n\
Name={name}\n\
Exec={exec} %U\n\
Exec=\"{exec}\" %U\n\
Icon={icon}\n\
Categories={categories}\n\
Comment={comment}\n\
@@ -228,7 +228,7 @@ mod tests {
#[test]
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
// with a specific path and testing the desktop content generation
let record = AppImageRecord {

View File

@@ -42,7 +42,6 @@ pub enum LaunchMethod {
/// Extract-and-run fallback (APPIMAGE_EXTRACT_AND_RUN=1)
ExtractAndRun,
/// Via firejail sandbox
#[allow(dead_code)]
Sandboxed,
}
@@ -68,7 +67,6 @@ pub enum LaunchResult {
Crashed {
exit_code: Option<i32>,
stderr: String,
#[allow(dead_code)]
method: LaunchMethod,
},
/// Failed to launch.
@@ -99,6 +97,22 @@ pub fn launch_appimage(
}
};
// Override with sandboxed launch if the user enabled firejail for this app
let method = if has_firejail() {
let sandbox = db
.get_appimage_by_id(record_id)
.ok()
.flatten()
.and_then(|r| r.sandbox_mode);
if sandbox.as_deref() == Some("firejail") {
LaunchMethod::Sandboxed
} else {
method
}
} else {
method
};
let result = execute_appimage(appimage_path, &method, extra_args, extra_env);
// Record the launch event regardless of success
@@ -163,42 +177,38 @@ fn execute_appimage(
cmd.env(key, value);
}
// Capture stderr to detect crash messages, stdin detached
// Detach stdin, pipe stderr so we can capture crash messages
cmd.stdin(Stdio::null());
cmd.stderr(Stdio::piped());
match cmd.spawn() {
Ok(mut child) => {
// Brief wait to detect immediate crashes (e.g. missing Qt plugins)
std::thread::sleep(std::time::Duration::from_millis(1500));
// Give the process a brief moment to fail on immediate errors
// (missing libs, exec format errors, Qt plugin failures, etc.)
std::thread::sleep(std::time::Duration::from_millis(150));
match child.try_wait() {
Ok(Some(status)) => {
// Process already exited - it crashed
let stderr = child
.stderr
.take()
.and_then(|mut err| {
// Already exited - immediate crash. Read stderr for details.
let stderr_text = child.stderr.take().map(|mut pipe| {
let mut buf = String::new();
use std::io::Read;
err.read_to_string(&mut buf).ok()?;
Some(buf)
})
.unwrap_or_default();
// Read with a size cap to avoid huge allocations
let mut limited = (&mut pipe).take(64 * 1024);
let _ = limited.read_to_string(&mut buf);
buf
}).unwrap_or_default();
LaunchResult::Crashed {
exit_code: status.code(),
stderr,
stderr: stderr_text,
method: method.clone(),
}
}
Ok(None) => {
// Still running - success
LaunchResult::Started {
child,
method: method.clone(),
}
}
Err(_) => {
// Can't check status, assume it's running
_ => {
// Still running after 150ms - drop the stderr pipe so the
// child process won't block if it fills the pipe buffer.
drop(child.stderr.take());
LaunchResult::Started {
child,
method: method.clone(),
@@ -211,11 +221,38 @@ fn execute_appimage(
}
/// Parse a launch_args string from the database into a Vec of individual arguments.
/// Splits on whitespace; returns an empty Vec if the input is None or empty.
#[allow(dead_code)]
/// Parse launch arguments with basic quote support.
/// Splits on whitespace, respecting double-quoted strings.
/// Returns an empty Vec if the input is None or empty.
pub fn parse_launch_args(args: Option<&str>) -> Vec<String> {
args.map(|s| s.split_whitespace().map(String::from).collect())
.unwrap_or_default()
let Some(s) = args else {
return Vec::new();
};
let s = s.trim();
if s.is_empty() {
return Vec::new();
}
let mut result = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
for c in s.chars() {
match c {
'"' => in_quotes = !in_quotes,
' ' | '\t' if !in_quotes => {
if !current.is_empty() {
result.push(std::mem::take(&mut current));
}
}
_ => current.push(c),
}
}
if !current.is_empty() {
result.push(current);
}
result
}
/// Check if firejail is available for sandboxed launches.

View File

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

View File

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

View File

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

View File

@@ -370,27 +370,51 @@ fn parse_elf32_sections(data: &[u8]) -> Option<String> {
}
/// 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> {
let output = std::process::Command::new(path)
let mut child = std::process::Command::new(path)
.arg("--appimage-updateinformation")
.env("APPIMAGE_EXTRACT_AND_RUN", "1")
.output()
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.ok()?;
if output.status.success() {
let info = String::from_utf8_lossy(&output.stdout).trim().to_string();
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);
}
}
None
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,
}
}
}
// -- GitHub/GitLab API types for JSON deserialization --
#[derive(Deserialize)]
#[allow(dead_code)]
struct GhRelease {
tag_name: String,
name: Option<String>,
@@ -406,7 +430,6 @@ struct GhAsset {
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct GlRelease {
tag_name: String,
name: Option<String>,
@@ -492,6 +515,12 @@ fn check_github_release(
let release: GhRelease = response.body_mut().read_json().ok()?;
log::info!(
"GitHub release: tag={}, name={:?}",
release.tag_name,
release.name.as_deref().unwrap_or("(none)"),
);
let latest_version = clean_version(&release.tag_name);
// Find matching asset using glob-like pattern
@@ -549,6 +578,12 @@ fn check_gitlab_release(
let release: GlRelease = response.body_mut().read_json().ok()?;
log::info!(
"GitLab release: tag={}, name={:?}",
release.tag_name,
release.name.as_deref().unwrap_or("(none)"),
);
let latest_version = clean_version(&release.tag_name);
let download_url = release.assets.and_then(|assets| {
@@ -669,18 +704,24 @@ fn glob_match(pattern: &str, text: &str) -> bool {
// Last part must match at the end (unless pattern ends with *)
let last = parts[parts.len() - 1];
if !last.is_empty() {
let end_limit = if !last.is_empty() {
if !text.ends_with(last) {
return false;
}
}
text.len() - last.len()
} else {
text.len()
};
// Middle parts must appear in order
// Middle parts must appear in order within the allowed range
for part in &parts[1..parts.len() - 1] {
if part.is_empty() {
continue;
}
if let Some(found) = text[pos..].find(part) {
if pos >= end_limit {
return false;
}
if let Some(found) = text[pos..end_limit].find(part) {
pos += found + part.len();
} else {
return false;
@@ -691,7 +732,7 @@ fn glob_match(pattern: &str, text: &str) -> bool {
}
/// Clean a version string - strip leading 'v' or 'V' prefix.
fn clean_version(version: &str) -> String {
pub(crate) fn clean_version(version: &str) -> String {
let v = version.trim();
v.strip_prefix('v')
.or_else(|| v.strip_prefix('V'))
@@ -949,24 +990,25 @@ fn download_file(
/// Verify that a file is a valid AppImage (has ELF header + AppImage magic bytes).
fn verify_appimage(path: &Path) -> bool {
if let Ok(data) = fs::read(path) {
if data.len() < 12 {
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 &data[0..4] != b"\x7FELF" {
if &header[0..4] != b"\x7FELF" {
return false;
}
// Check AppImage Type 2 magic at offset 8: AI\x02
if data[8] == 0x41 && data[9] == 0x49 && data[10] == 0x02 {
if header[8] == 0x41 && header[9] == 0x49 && header[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
header[8] == 0x41 && header[9] == 0x49 && header[10] == 0x01
}
/// 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)]
pub enum WatchEvent {
/// 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.

View File

@@ -307,11 +307,9 @@ pub fn detect_desktop_environment() -> String {
/// Result of analyzing a running process for Wayland usage.
#[derive(Debug, Clone)]
pub struct RuntimeAnalysis {
#[allow(dead_code)]
pub pid: u32,
pub has_wayland_socket: bool,
pub has_x11_connection: bool,
#[allow(dead_code)]
pub env_vars: Vec<(String, String)>,
}
@@ -391,7 +389,6 @@ pub fn analyze_running_process(pid: u32) -> Result<RuntimeAnalysis, String> {
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())
});
}

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.
#[allow(dead_code)]
pub fn ni18n_f(singular: &str, plural: &str, n: u32, args: &[(&str, &str)]) -> String {
let base = if n == 1 { singular } else { plural };
let mut result = base.to_string();

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.page(&storage_page).set_icon_name(Some("drive-harddisk-symbolic"));
// Restore last-used tab from GSettings
let settings = gio::Settings::new(crate::config::APP_ID);
let saved_tab = settings.string("detail-tab");
if view_stack.child_by_name(&saved_tab).is_some() {
view_stack.set_visible_child_name(&saved_tab);
}
// Persist tab choice on switch
view_stack.connect_visible_child_name_notify(move |stack| {
if let Some(name) = stack.visible_child_name() {
settings.set_string("detail-tab", &name).ok();
}
});
// Banner scrolls with content (not sticky) so tall banners don't eat space
let scroll_content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
@@ -83,6 +97,7 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
let record_id = record.id;
let path = record.path.clone();
let app_name_launch = record.app_name.clone().unwrap_or_else(|| record.filename.clone());
let launch_args_raw = record.launch_args.clone();
let db_launch = db.clone();
let toast_launch = toast_overlay.clone();
launch_button.connect_clicked(move |btn| {
@@ -92,6 +107,7 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
let app_name = app_name_launch.clone();
let db_launch = db_launch.clone();
let toast_ref = toast_launch.clone();
let launch_args = launcher::parse_launch_args(launch_args_raw.as_deref());
glib::spawn_future_local(async move {
let path_bg = path.clone();
let result = gio::spawn_blocking(move || {
@@ -101,7 +117,7 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
record_id,
appimage_path,
"gui_detail",
&[],
&launch_args,
&[],
)
}).await;
@@ -121,13 +137,16 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
}).await;
if let Ok(Ok(analysis)) = analysis_result {
let status_str = analysis.as_status_str();
log::info!("Runtime Wayland: {} -> {}", path_clone, analysis.status_label());
log::info!(
"Runtime Wayland: {} -> {} (pid={}, env: {:?})",
path_clone, analysis.status_label(), analysis.pid, analysis.env_vars,
);
db_wayland.update_runtime_wayland_status(record_id, status_str).ok();
}
});
}
Ok(launcher::LaunchResult::Crashed { exit_code, stderr, .. }) => {
log::error!("App crashed on launch (exit {}): {}", exit_code.unwrap_or(-1), stderr);
Ok(launcher::LaunchResult::Crashed { exit_code, stderr, method }) => {
log::error!("App crashed on launch (exit {}, method: {}): {}", exit_code.unwrap_or(-1), method.as_str(), stderr);
widgets::show_crash_dialog(&btn_ref, &app_name, exit_code, &stderr);
}
Ok(launcher::LaunchResult::Failed(msg)) => {
@@ -247,9 +266,7 @@ fn build_banner(record: &AppImageRecord) -> gtk::Box {
.margin_top(4)
.build();
if record.integrated {
badge_box.append(&widgets::status_badge("Integrated", "success"));
}
badge_box.append(&widgets::integration_badge(record.integrated));
if let Some(ref ws) = record.wayland_status {
let status = WaylandStatus::from_str(ws);
@@ -1582,6 +1599,10 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
group.add(&empty_row);
} else {
for b in &backups {
log::debug!(
"Listing backup id={} for appimage_id={} at {}",
b.id, b.appimage_id, b.archive_path,
);
let expander = adw::ExpanderRow::builder()
.title(&b.created_at)
.subtitle(&format!(
@@ -1656,12 +1677,28 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
row_clone.set_sensitive(true);
match result {
Ok(Ok(res)) => {
let skip_note = if res.paths_skipped > 0 {
format!(" ({} skipped)", res.paths_skipped)
} else {
String::new()
};
row_clone.set_subtitle(&format!(
"Restored {} path{}",
"Restored {} path{}{}",
res.paths_restored,
if res.paths_restored == 1 { "" } else { "s" },
skip_note,
));
toast.add_toast(adw::Toast::new("Backup restored"));
let toast_msg = format!(
"Restored {} path{}{}",
res.paths_restored,
if res.paths_restored == 1 { "" } else { "s" },
skip_note,
);
toast.add_toast(adw::Toast::new(&toast_msg));
log::info!(
"Backup restored: app={}, paths_restored={}, paths_skipped={}",
res.manifest.app_name, res.paths_restored, res.paths_skipped,
);
}
_ => {
row_clone.set_subtitle("Restore failed");
@@ -1922,7 +1959,9 @@ fn show_screenshot_lightbox(
// --- Click outside image to close ---
// Picture's gesture claims clicks on the image, preventing close.
let pic_gesture = gtk::GestureClick::new();
pic_gesture.connect_released(|_, _, _, _| {});
pic_gesture.connect_released(|gesture, _, _, _| {
gesture.set_state(gtk::EventSequenceState::Claimed);
});
picture.add_controller(pic_gesture);
// Window gesture fires for clicks on the dark margin area.

View File

@@ -2,6 +2,8 @@ use adw::prelude::*;
use gtk::gio;
use crate::config::APP_ID;
use crate::core::appstream;
use crate::core::database::Database;
use crate::i18n::i18n;
pub fn show_preferences_dialog(parent: &impl IsA<gtk::Widget>) {
@@ -153,6 +155,47 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
scan_group.add(&add_button);
page.add(&scan_group);
// Desktop Integration group - AppStream catalog for GNOME Software/Discover
let integration_group = adw::PreferencesGroup::builder()
.title(&i18n("Desktop Integration"))
.description(&i18n(
"Make your AppImages visible in GNOME Software and KDE Discover",
))
.build();
let catalog_row = adw::SwitchRow::builder()
.title(&i18n("AppStream catalog"))
.subtitle(&i18n(
"Generate a local catalog so software centers can list your AppImages",
))
.active(appstream::is_catalog_installed())
.build();
catalog_row.connect_active_notify(|row| {
let enable = row.is_active();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || {
if enable {
let db = Database::open().expect("Failed to open database");
appstream::install_catalog(&db)
.map(|p| log::info!("AppStream catalog installed: {}", p.display()))
.map_err(|e| e.to_string())
} else {
appstream::uninstall_catalog()
.map(|()| log::info!("AppStream catalog removed"))
.map_err(|e| e.to_string())
}
})
.await;
if let Ok(Err(e)) = result {
log::warn!("AppStream catalog toggle failed: {}", e);
}
});
});
integration_group.add(&catalog_row);
page.add(&integration_group);
page
}

View File

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

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.
#[allow(dead_code)]
pub fn integration_badge(integrated: bool) -> gtk::Label {
if integrated {
status_badge("Integrated", "success")
@@ -345,7 +344,6 @@ fn crash_explanation(stderr: &str) -> String {
/// Inserts a hidden label with AccessibleRole::Alert into the given container,
/// which causes AT-SPI to announce the text to screen readers.
/// The label auto-removes after a short delay.
#[allow(dead_code)]
pub fn announce(container: &impl gtk::prelude::IsA<gtk::Widget>, text: &str) {
let label = gtk::Label::builder()
.label(text)
@@ -354,7 +352,16 @@ pub fn announce(container: &impl gtk::prelude::IsA<gtk::Widget>, text: &str) {
.build();
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);
label.set_visible(true);
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 toast_overlay = window.imp().toast_overlay.get().unwrap().clone();
let window_ref = window.clone();
let (path_str, app_name) = {
let (path_str, app_name, launch_args_raw) = {
let db = window.database();
match db.get_appimage_by_id(record_id) {
Ok(Some(r)) => {
let name = r.app_name.clone().unwrap_or_else(|| r.filename.clone());
(r.path.clone(), name)
(r.path.clone(), name, r.launch_args.clone())
}
_ => return,
}
};
let launch_args = launcher::parse_launch_args(launch_args_raw.as_deref());
glib::spawn_future_local(async move {
let path_bg = path_str.clone();
let result = gio::spawn_blocking(move || {
let bg_db = crate::core::database::Database::open().expect("DB open");
let appimage_path = std::path::Path::new(&path_bg);
launcher::launch_appimage(&bg_db, record_id, appimage_path, "gui_context", &[], &[])
launcher::launch_appimage(&bg_db, record_id, appimage_path, "gui_context", &launch_args, &[])
}).await;
match result {
Ok(launcher::LaunchResult::Started { child, method }) => {
log::info!("Launched: {} (PID: {}, method: {})", path_str, child.id(), method.as_str());
}
Ok(launcher::LaunchResult::Crashed { exit_code, stderr, .. }) => {
log::error!("App crashed (exit {}): {}", exit_code.unwrap_or(-1), stderr);
Ok(launcher::LaunchResult::Crashed { exit_code, stderr, method }) => {
log::error!("App crashed (exit {}, method: {}): {}", exit_code.unwrap_or(-1), method.as_str(), stderr);
widgets::show_crash_dialog(&window_ref, &app_name, exit_code, &stderr);
}
Ok(launcher::LaunchResult::Failed(msg)) => {
@@ -830,12 +831,49 @@ impl DriftwoodWindow {
}
}
// Always scan on startup to discover new AppImages and complete pending analyses
// Scan on startup if enabled in preferences
if self.settings().boolean("auto-scan-on-startup") {
self.trigger_scan();
}
// Start watching scan directories for new AppImage files
self.start_file_watcher();
// Auto-cleanup old backups based on retention setting
let retention_days = self.settings().int("backup-retention-days") as u32;
glib::spawn_future_local(async move {
let _ = gio::spawn_blocking(move || {
let bg_db = Database::open().expect("Failed to open database");
match crate::core::backup::auto_cleanup_old_backups(&bg_db, retention_days) {
Ok(removed) if removed > 0 => {
log::info!("Auto-cleaned {} old backup(s) (retention: {} days)", removed, retention_days);
}
Err(e) => log::warn!("Backup auto-cleanup failed: {}", e),
_ => {}
}
})
.await;
});
// Run background security scan and notify if auto-security-scan is enabled
let settings_sec = self.settings().clone();
if settings_sec.boolean("auto-security-scan") {
let threshold = settings_sec.string("security-notification-threshold").to_string();
glib::spawn_future_local(async move {
let _ = gio::spawn_blocking(move || {
let bg_db = Database::open().expect("Failed to open database");
let notifications = notification::scan_and_notify(&bg_db, &threshold);
for n in &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
let toast_overlay = self.imp().toast_overlay.get().unwrap().clone();
glib::spawn_future_local(async move {
@@ -989,6 +1027,10 @@ impl DriftwoodWindow {
toast_overlay.add_toast(adw::Toast::new(&msg));
// Phase 2: Background analysis per file with debounced UI refresh
let running = analysis::running_count();
if running > 0 {
log::info!("Analyzing {} AppImage(s) in background ({} already running)", needs_analysis.len(), running);
}
if !needs_analysis.is_empty() {
let pending = Rc::new(std::cell::Cell::new(needs_analysis.len()));
let refresh_timer: Rc<std::cell::Cell<Option<glib::SourceId>>> =
@@ -1057,21 +1099,28 @@ impl DriftwoodWindow {
let changed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let changed_watcher = changed.clone();
let handle = watcher::start_watcher(dirs, move |_event| {
let handle = watcher::start_watcher(dirs, move |event| {
match &event {
watcher::WatchEvent::Changed(paths) => {
log::info!("File watcher: {} path(s) changed: {:?}", paths.len(), paths);
}
}
changed_watcher.store(true, std::sync::atomic::Ordering::Relaxed);
});
if let Some(h) = handle {
self.imp().watcher_handle.replace(Some(h));
// Poll the flag every second from the main thread
// Poll the flag every second from the main thread.
// Returns Break when the window is gone to stop the timer.
let window_weak = self.downgrade();
glib::timeout_add_local(std::time::Duration::from_secs(1), move || {
let Some(window) = window_weak.upgrade() else {
return glib::ControlFlow::Break;
};
if changed.swap(false, std::sync::atomic::Ordering::Relaxed) {
if let Some(window) = window_weak.upgrade() {
window.trigger_scan();
}
}
glib::ControlFlow::Continue
});
}
@@ -1204,7 +1253,7 @@ impl DriftwoodWindow {
fn save_window_state(&self) {
let settings = self.settings();
let (width, height) = self.default_size();
let (width, height) = (self.width(), self.height());
settings
.set_int("window-width", width)
.expect("Failed to save window width");