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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
107
docs/plans/2026-02-27-audit-fixes-design.md
Normal file
107
docs/plans/2026-02-27-audit-fixes-design.md
Normal 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.
|
||||
1155
docs/plans/2026-02-27-audit-fixes-implementation.md
Normal file
1155
docs/plans/2026-02-27-audit-fixes-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
28
src/cli.rs
28
src/cli.rs
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
@@ -536,7 +530,6 @@ fn xml_escape(s: &str) -> String {
|
||||
// --- Error types ---
|
||||
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
pub enum AppStreamError {
|
||||
Database(String),
|
||||
Io(String),
|
||||
|
||||
@@ -119,16 +119,23 @@ 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() {
|
||||
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(),
|
||||
);
|
||||
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(),
|
||||
);
|
||||
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
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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| {
|
||||
let mut buf = String::new();
|
||||
use std::io::Read;
|
||||
err.read_to_string(&mut buf).ok()?;
|
||||
Some(buf)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// 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;
|
||||
// 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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
|
||||
@@ -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();
|
||||
if !info.is_empty() && info.contains('|') {
|
||||
return Some(info);
|
||||
let timeout = std::time::Duration::from_secs(5);
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
if status.success() {
|
||||
let mut output = String::new();
|
||||
if let Some(mut stdout) = child.stdout.take() {
|
||||
use std::io::Read;
|
||||
stdout.read_to_string(&mut output).ok()?;
|
||||
}
|
||||
let info = output.trim().to_string();
|
||||
if !info.is_empty() && info.contains('|') {
|
||||
return Some(info);
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
Ok(None) => {
|
||||
if start.elapsed() >= timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
log::warn!("Timed out reading update info from {}", path.display());
|
||||
return None;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return false;
|
||||
}
|
||||
// Check ELF magic
|
||||
if &data[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 {
|
||||
return true;
|
||||
}
|
||||
// Check AppImage Type 1 magic at offset 8: AI\x01
|
||||
if data[8] == 0x41 && data[9] == 0x49 && data[10] == 0x01 {
|
||||
return true;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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...");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
self.trigger_scan();
|
||||
// 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 ¬ifications {
|
||||
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,20 +1099,27 @@ 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();
|
||||
}
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user