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>
|
<description>Directories to scan for AppImage files.</description>
|
||||||
</key>
|
</key>
|
||||||
<key name="view-mode" type="s">
|
<key name="view-mode" type="s">
|
||||||
|
<choices>
|
||||||
|
<choice value='grid'/>
|
||||||
|
<choice value='list'/>
|
||||||
|
</choices>
|
||||||
<default>'grid'</default>
|
<default>'grid'</default>
|
||||||
<summary>Library view mode</summary>
|
<summary>Library view mode</summary>
|
||||||
<description>The library view mode: grid or list.</description>
|
<description>The library view mode: grid or list.</description>
|
||||||
</key>
|
</key>
|
||||||
<key name="color-scheme" type="s">
|
<key name="color-scheme" type="s">
|
||||||
|
<choices>
|
||||||
|
<choice value='default'/>
|
||||||
|
<choice value='force-light'/>
|
||||||
|
<choice value='force-dark'/>
|
||||||
|
</choices>
|
||||||
<default>'default'</default>
|
<default>'default'</default>
|
||||||
<summary>Color scheme</summary>
|
<summary>Color scheme</summary>
|
||||||
<description>Application color scheme: default (follow system), force-light, or force-dark.</description>
|
<description>Application color scheme: default (follow system), force-light, or force-dark.</description>
|
||||||
@@ -37,6 +46,12 @@
|
|||||||
<description>Whether to automatically scan for AppImages when the application starts.</description>
|
<description>Whether to automatically scan for AppImages when the application starts.</description>
|
||||||
</key>
|
</key>
|
||||||
<key name="detail-tab" type="s">
|
<key name="detail-tab" type="s">
|
||||||
|
<choices>
|
||||||
|
<choice value='overview'/>
|
||||||
|
<choice value='system'/>
|
||||||
|
<choice value='security'/>
|
||||||
|
<choice value='storage'/>
|
||||||
|
</choices>
|
||||||
<default>'overview'</default>
|
<default>'overview'</default>
|
||||||
<summary>Last detail view tab</summary>
|
<summary>Last detail view tab</summary>
|
||||||
<description>The last selected tab in the detail view (overview, system, security, storage).</description>
|
<description>The last selected tab in the detail view (overview, system, security, storage).</description>
|
||||||
@@ -57,6 +72,7 @@
|
|||||||
<description>Create a config backup before applying an update.</description>
|
<description>Create a config backup before applying an update.</description>
|
||||||
</key>
|
</key>
|
||||||
<key name="backup-retention-days" type="i">
|
<key name="backup-retention-days" type="i">
|
||||||
|
<range min="1" max="365"/>
|
||||||
<default>30</default>
|
<default>30</default>
|
||||||
<summary>Backup retention days</summary>
|
<summary>Backup retention days</summary>
|
||||||
<description>Number of days to keep config backups before auto-cleanup.</description>
|
<description>Number of days to keep config backups before auto-cleanup.</description>
|
||||||
@@ -67,6 +83,11 @@
|
|||||||
<description>Show a confirmation dialog before deleting AppImages or backups.</description>
|
<description>Show a confirmation dialog before deleting AppImages or backups.</description>
|
||||||
</key>
|
</key>
|
||||||
<key name="update-cleanup" type="s">
|
<key name="update-cleanup" type="s">
|
||||||
|
<choices>
|
||||||
|
<choice value='ask'/>
|
||||||
|
<choice value='always'/>
|
||||||
|
<choice value='never'/>
|
||||||
|
</choices>
|
||||||
<default>'ask'</default>
|
<default>'ask'</default>
|
||||||
<summary>Update cleanup mode</summary>
|
<summary>Update cleanup mode</summary>
|
||||||
<description>What to do with old versions after update: ask, keep, or delete.</description>
|
<description>What to do with old versions after update: ask, keep, or delete.</description>
|
||||||
@@ -82,6 +103,12 @@
|
|||||||
<description>Send desktop notifications when new CVEs are found.</description>
|
<description>Send desktop notifications when new CVEs are found.</description>
|
||||||
</key>
|
</key>
|
||||||
<key name="security-notification-threshold" type="s">
|
<key name="security-notification-threshold" type="s">
|
||||||
|
<choices>
|
||||||
|
<choice value='critical'/>
|
||||||
|
<choice value='high'/>
|
||||||
|
<choice value='medium'/>
|
||||||
|
<choice value='low'/>
|
||||||
|
</choices>
|
||||||
<default>'high'</default>
|
<default>'high'</default>
|
||||||
<summary>Security notification threshold</summary>
|
<summary>Security notification threshold</summary>
|
||||||
<description>Minimum CVE severity for desktop notifications: critical, high, medium, or low.</description>
|
<description>Minimum CVE severity for desktop notifications: critical, high, medium, or low.</description>
|
||||||
|
|||||||
@@ -51,12 +51,6 @@
|
|||||||
<control>pointing</control>
|
<control>pointing</control>
|
||||||
</recommends>
|
</recommends>
|
||||||
|
|
||||||
<categories>
|
|
||||||
<category>System</category>
|
|
||||||
<category>PackageManager</category>
|
|
||||||
<category>GTK</category>
|
|
||||||
</categories>
|
|
||||||
|
|
||||||
<keywords>
|
<keywords>
|
||||||
<keyword>AppImage</keyword>
|
<keyword>AppImage</keyword>
|
||||||
<keyword>Application</keyword>
|
<keyword>Application</keyword>
|
||||||
|
|||||||
@@ -94,6 +94,15 @@ flowboxchild:focus-visible .card {
|
|||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* App card status indicators */
|
||||||
|
.status-ok {
|
||||||
|
border: 1px solid alpha(@success_bg_color, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-attention {
|
||||||
|
border: 1px solid alpha(@warning_bg_color, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
/* Rounded icon clipping for list view */
|
/* Rounded icon clipping for list view */
|
||||||
.icon-rounded {
|
.icon-rounded {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -117,11 +126,6 @@ row:focus-visible {
|
|||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Badge row in app cards */
|
|
||||||
.badge-row {
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Letter-circle fallback icon */
|
/* Letter-circle fallback icon */
|
||||||
.letter-icon {
|
.letter-icon {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -151,18 +155,6 @@ row:focus-visible {
|
|||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inline ViewSwitcher positioning */
|
|
||||||
.detail-view-switcher {
|
|
||||||
margin-top: 6px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Quick Action Pills ===== */
|
|
||||||
.quick-action-pill {
|
|
||||||
border-radius: 18px;
|
|
||||||
padding: 6px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Compatibility Warning Banner ===== */
|
/* ===== Compatibility Warning Banner ===== */
|
||||||
.compat-warning-banner {
|
.compat-warning-banner {
|
||||||
background: alpha(@warning_bg_color, 0.15);
|
background: alpha(@warning_bg_color, 0.15);
|
||||||
@@ -171,45 +163,6 @@ row:focus-visible {
|
|||||||
border: 1px solid alpha(@warning_bg_color, 0.3);
|
border: 1px solid alpha(@warning_bg_color, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Dark Mode Differentiation ===== */
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.compat-warning-banner {
|
|
||||||
background: alpha(@warning_bg_color, 0.1);
|
|
||||||
border: 1px solid alpha(@warning_bg_color, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== High Contrast Mode (WCAG AAA 1.4.6) ===== */
|
|
||||||
@media (prefers-contrast: more) {
|
|
||||||
flowboxchild:focus-visible .card {
|
|
||||||
outline-width: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:focus-visible,
|
|
||||||
togglebutton:focus-visible,
|
|
||||||
menubutton:focus-visible,
|
|
||||||
checkbutton:focus-visible,
|
|
||||||
switch:focus-visible,
|
|
||||||
entry:focus-visible,
|
|
||||||
searchentry:focus-visible,
|
|
||||||
spinbutton:focus-visible {
|
|
||||||
outline-width: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
row:focus-visible {
|
|
||||||
outline-width: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge,
|
|
||||||
.status-badge-with-icon {
|
|
||||||
border: 1px solid currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.compat-warning-banner {
|
|
||||||
border: 2px solid @warning_bg_color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== Reduced Motion (WCAG AAA 2.3.3) ===== */
|
/* ===== Reduced Motion (WCAG AAA 2.3.3) ===== */
|
||||||
/* Note: GTK CSS does not support prefers-reduced-motion or !important.
|
/* Note: GTK CSS does not support prefers-reduced-motion or !important.
|
||||||
Reduced motion is handled by the GTK toolkit settings instead
|
Reduced motion is handled by the GTK toolkit settings instead
|
||||||
|
|||||||
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" {
|
if format == "json" {
|
||||||
// Simple JSON output
|
let items: Vec<serde_json::Value> = records
|
||||||
println!("[");
|
.iter()
|
||||||
for (i, r) in records.iter().enumerate() {
|
.map(|r| {
|
||||||
let comma = if i + 1 < records.len() { "," } else { "" };
|
serde_json::json!({
|
||||||
println!(
|
"name": r.app_name.as_deref().unwrap_or(&r.filename),
|
||||||
" {{\"name\": \"{}\", \"version\": \"{}\", \"path\": \"{}\", \"size\": {}, \"integrated\": {}}}{}",
|
"version": r.app_version.as_deref().unwrap_or(""),
|
||||||
r.app_name.as_deref().unwrap_or(&r.filename),
|
"path": r.path,
|
||||||
r.app_version.as_deref().unwrap_or(""),
|
"size": r.size_bytes,
|
||||||
r.path,
|
"integrated": r.integrated,
|
||||||
r.size_bytes,
|
})
|
||||||
r.integrated,
|
})
|
||||||
comma,
|
.collect();
|
||||||
);
|
println!("{}", serde_json::to_string_pretty(&items).unwrap_or_else(|_| "[]".into()));
|
||||||
}
|
|
||||||
println!("]");
|
|
||||||
return ExitCode::SUCCESS;
|
return ExitCode::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ const MAX_CONCURRENT_ANALYSES: usize = 2;
|
|||||||
static RUNNING_ANALYSES: AtomicUsize = AtomicUsize::new(0);
|
static RUNNING_ANALYSES: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
/// Returns the number of currently running background analyses.
|
/// Returns the number of currently running background analyses.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn running_count() -> usize {
|
pub fn running_count() -> usize {
|
||||||
RUNNING_ANALYSES.load(Ordering::Relaxed)
|
RUNNING_ANALYSES.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
@@ -64,6 +63,10 @@ pub fn run_background_analysis(id: i64, path: PathBuf, appimage_type: AppImageTy
|
|||||||
|
|
||||||
// Inspect metadata (app name, version, icon, desktop entry, AppStream, etc.)
|
// Inspect metadata (app name, version, icon, desktop entry, AppStream, etc.)
|
||||||
if let Ok(meta) = inspector::inspect_appimage(&path, &appimage_type) {
|
if let Ok(meta) = inspector::inspect_appimage(&path, &appimage_type) {
|
||||||
|
log::debug!(
|
||||||
|
"Metadata for id={}: name={:?}, icon_name={:?}",
|
||||||
|
id, meta.app_name.as_deref(), meta.icon_name.as_deref(),
|
||||||
|
);
|
||||||
let categories = if meta.categories.is_empty() {
|
let categories = if meta.categories.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -405,7 +405,6 @@ fn summarize_content_rating(attrs: &[(String, String)]) -> String {
|
|||||||
// AppStream catalog generation - writes catalog XML for GNOME Software/Discover
|
// AppStream catalog generation - writes catalog XML for GNOME Software/Discover
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
/// Generate an AppStream catalog XML from the Driftwood database.
|
/// Generate an AppStream catalog XML from the Driftwood database.
|
||||||
/// This allows GNOME Software / KDE Discover to see locally managed AppImages.
|
/// This allows GNOME Software / KDE Discover to see locally managed AppImages.
|
||||||
pub fn generate_catalog(db: &Database) -> Result<String, AppStreamError> {
|
pub fn generate_catalog(db: &Database) -> Result<String, AppStreamError> {
|
||||||
@@ -463,7 +462,6 @@ pub fn generate_catalog(db: &Database) -> Result<String, AppStreamError> {
|
|||||||
Ok(xml)
|
Ok(xml)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
/// Install the AppStream catalog to the local swcatalog directory.
|
/// Install the AppStream catalog to the local swcatalog directory.
|
||||||
/// GNOME Software reads from `~/.local/share/swcatalog/xml/`.
|
/// GNOME Software reads from `~/.local/share/swcatalog/xml/`.
|
||||||
pub fn install_catalog(db: &Database) -> Result<PathBuf, AppStreamError> {
|
pub fn install_catalog(db: &Database) -> Result<PathBuf, AppStreamError> {
|
||||||
@@ -484,7 +482,6 @@ pub fn install_catalog(db: &Database) -> Result<PathBuf, AppStreamError> {
|
|||||||
Ok(catalog_path)
|
Ok(catalog_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
/// Remove the AppStream catalog from the local swcatalog directory.
|
/// Remove the AppStream catalog from the local swcatalog directory.
|
||||||
pub fn uninstall_catalog() -> Result<(), AppStreamError> {
|
pub fn uninstall_catalog() -> Result<(), AppStreamError> {
|
||||||
let catalog_path = dirs::data_dir()
|
let catalog_path = dirs::data_dir()
|
||||||
@@ -501,7 +498,6 @@ pub fn uninstall_catalog() -> Result<(), AppStreamError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
/// Check if the AppStream catalog is currently installed.
|
/// Check if the AppStream catalog is currently installed.
|
||||||
pub fn is_catalog_installed() -> bool {
|
pub fn is_catalog_installed() -> bool {
|
||||||
let catalog_path = dirs::data_dir()
|
let catalog_path = dirs::data_dir()
|
||||||
@@ -515,7 +511,6 @@ pub fn is_catalog_installed() -> bool {
|
|||||||
|
|
||||||
// --- Utility functions ---
|
// --- Utility functions ---
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn make_component_id(name: &str) -> String {
|
fn make_component_id(name: &str) -> String {
|
||||||
name.chars()
|
name.chars()
|
||||||
.map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c.to_ascii_lowercase() } else { '_' })
|
.map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c.to_ascii_lowercase() } else { '_' })
|
||||||
@@ -524,7 +519,6 @@ fn make_component_id(name: &str) -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn xml_escape(s: &str) -> String {
|
fn xml_escape(s: &str) -> String {
|
||||||
s.replace('&', "&")
|
s.replace('&', "&")
|
||||||
.replace('<', "<")
|
.replace('<', "<")
|
||||||
@@ -536,7 +530,6 @@ fn xml_escape(s: &str) -> String {
|
|||||||
// --- Error types ---
|
// --- Error types ---
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub enum AppStreamError {
|
pub enum AppStreamError {
|
||||||
Database(String),
|
Database(String),
|
||||||
Io(String),
|
Io(String),
|
||||||
|
|||||||
@@ -119,9 +119,15 @@ pub fn create_backup(db: &Database, appimage_id: i64) -> Result<PathBuf, BackupE
|
|||||||
"manifest.json".to_string(),
|
"manifest.json".to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
|
||||||
for entry in &entries {
|
for entry in &entries {
|
||||||
let source = Path::new(&entry.original_path);
|
let source = Path::new(&entry.original_path);
|
||||||
if source.exists() {
|
if source.exists() {
|
||||||
|
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("-C".to_string());
|
||||||
tar_args.push(
|
tar_args.push(
|
||||||
source.parent().unwrap_or(Path::new("/")).to_string_lossy().to_string(),
|
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")
|
let status = Command::new("tar")
|
||||||
.args(&tar_args)
|
.args(&tar_args)
|
||||||
@@ -190,12 +197,16 @@ pub fn restore_backup(archive_path: &Path) -> Result<RestoreResult, BackupError>
|
|||||||
// Restore each path
|
// Restore each path
|
||||||
let mut restored = 0u32;
|
let mut restored = 0u32;
|
||||||
let mut skipped = 0u32;
|
let mut skipped = 0u32;
|
||||||
|
let home_dir = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
|
||||||
|
|
||||||
for entry in &manifest.paths {
|
for entry in &manifest.paths {
|
||||||
let source_name = Path::new(&entry.original_path)
|
let source = Path::new(&entry.original_path);
|
||||||
.file_name()
|
let extracted = if let Ok(rel) = source.strip_prefix(&home_dir) {
|
||||||
.unwrap_or_default();
|
temp_dir.path().join(rel)
|
||||||
let extracted = temp_dir.path().join(source_name);
|
} else {
|
||||||
|
let source_name = source.file_name().unwrap_or_default();
|
||||||
|
temp_dir.path().join(source_name)
|
||||||
|
};
|
||||||
let target = Path::new(&entry.original_path);
|
let target = Path::new(&entry.original_path);
|
||||||
|
|
||||||
if !extracted.exists() {
|
if !extracted.exists() {
|
||||||
@@ -269,7 +280,6 @@ pub fn delete_backup(db: &Database, backup_id: i64) -> Result<(), BackupError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remove backups older than the specified number of days.
|
/// Remove backups older than the specified number of days.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn auto_cleanup_old_backups(db: &Database, retention_days: u32) -> Result<u32, BackupError> {
|
pub fn auto_cleanup_old_backups(db: &Database, retention_days: u32) -> Result<u32, BackupError> {
|
||||||
let backups = db.get_all_config_backups().unwrap_or_default();
|
let backups = db.get_all_config_backups().unwrap_or_default();
|
||||||
let cutoff = chrono::Utc::now() - chrono::Duration::days(retention_days as i64);
|
let cutoff = chrono::Utc::now() - chrono::Duration::days(retention_days as i64);
|
||||||
@@ -292,7 +302,6 @@ pub fn auto_cleanup_old_backups(db: &Database, retention_days: u32) -> Result<u3
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct BackupInfo {
|
pub struct BackupInfo {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
#[allow(dead_code)]
|
|
||||||
pub appimage_id: i64,
|
pub appimage_id: i64,
|
||||||
pub app_version: Option<String>,
|
pub app_version: Option<String>,
|
||||||
pub archive_path: String,
|
pub archive_path: String,
|
||||||
@@ -304,10 +313,8 @@ pub struct BackupInfo {
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct RestoreResult {
|
pub struct RestoreResult {
|
||||||
#[allow(dead_code)]
|
|
||||||
pub manifest: BackupManifest,
|
pub manifest: BackupManifest,
|
||||||
pub paths_restored: u32,
|
pub paths_restored: u32,
|
||||||
#[allow(dead_code)]
|
|
||||||
pub paths_skipped: u32,
|
pub paths_skipped: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -184,34 +184,6 @@ pub struct ConfigBackupRecord {
|
|||||||
pub last_restored_at: Option<String>,
|
pub last_restored_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct CatalogSourceRecord {
|
|
||||||
pub id: i64,
|
|
||||||
pub name: String,
|
|
||||||
pub url: String,
|
|
||||||
pub source_type: String,
|
|
||||||
pub enabled: bool,
|
|
||||||
pub last_synced: Option<String>,
|
|
||||||
pub app_count: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct CatalogAppRecord {
|
|
||||||
pub id: i64,
|
|
||||||
pub source_id: i64,
|
|
||||||
pub name: String,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub categories: Option<String>,
|
|
||||||
pub latest_version: Option<String>,
|
|
||||||
pub download_url: String,
|
|
||||||
pub icon_url: Option<String>,
|
|
||||||
pub homepage: Option<String>,
|
|
||||||
pub file_size: Option<i64>,
|
|
||||||
pub architecture: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SandboxProfileRecord {
|
pub struct SandboxProfileRecord {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
@@ -1374,7 +1346,9 @@ impl Database {
|
|||||||
WHERE appimage_id = ?1 GROUP BY severity"
|
WHERE appimage_id = ?1 GROUP BY severity"
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map(params![appimage_id], |row| {
|
let rows = stmt.query_map(params![appimage_id], |row| {
|
||||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
let severity: String = row.get::<_, Option<String>>(0)?
|
||||||
|
.unwrap_or_else(|| "MEDIUM".to_string());
|
||||||
|
Ok((severity, row.get::<_, i64>(1)?))
|
||||||
})?;
|
})?;
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let (severity, count) = row?;
|
let (severity, count) = row?;
|
||||||
@@ -1395,7 +1369,9 @@ impl Database {
|
|||||||
"SELECT severity, COUNT(*) FROM cve_matches GROUP BY severity"
|
"SELECT severity, COUNT(*) FROM cve_matches GROUP BY severity"
|
||||||
)?;
|
)?;
|
||||||
let rows = stmt.query_map([], |row| {
|
let rows = stmt.query_map([], |row| {
|
||||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
let severity: String = row.get::<_, Option<String>>(0)?
|
||||||
|
.unwrap_or_else(|| "MEDIUM".to_string());
|
||||||
|
Ok((severity, row.get::<_, i64>(1)?))
|
||||||
})?;
|
})?;
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let (severity, count) = row?;
|
let (severity, count) = row?;
|
||||||
|
|||||||
@@ -330,9 +330,12 @@ fn build_name_group(name: &str, records: &[&AppImageRecord]) -> DuplicateGroup {
|
|||||||
|
|
||||||
/// Compare two version strings for ordering.
|
/// Compare two version strings for ordering.
|
||||||
fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
|
fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
|
||||||
use super::updater::version_is_newer;
|
use super::updater::{clean_version, version_is_newer};
|
||||||
|
|
||||||
if a == b {
|
let ca = clean_version(a);
|
||||||
|
let cb = clean_version(b);
|
||||||
|
|
||||||
|
if ca == cb {
|
||||||
std::cmp::Ordering::Equal
|
std::cmp::Ordering::Equal
|
||||||
} else if version_is_newer(a, b) {
|
} else if version_is_newer(a, b) {
|
||||||
std::cmp::Ordering::Greater
|
std::cmp::Ordering::Greater
|
||||||
|
|||||||
@@ -186,9 +186,20 @@ fn has_static_runtime(appimage_path: &Path) -> bool {
|
|||||||
Err(_) => return false,
|
Err(_) => return false,
|
||||||
};
|
};
|
||||||
let data = &buf[..n];
|
let data = &buf[..n];
|
||||||
let haystack = String::from_utf8_lossy(data).to_lowercase();
|
// Search raw bytes directly - avoids allocating a UTF-8 string from binary data.
|
||||||
haystack.contains("type2-runtime")
|
// Case-insensitive matching for the two known signatures.
|
||||||
|| haystack.contains("libfuse3")
|
bytes_contains_ci(data, b"type2-runtime")
|
||||||
|
|| bytes_contains_ci(data, b"libfuse3")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Case-insensitive byte-level substring search (ASCII only).
|
||||||
|
fn bytes_contains_ci(haystack: &[u8], needle: &[u8]) -> bool {
|
||||||
|
if needle.is_empty() || haystack.len() < needle.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
haystack.windows(needle.len()).any(|window| {
|
||||||
|
window.iter().zip(needle).all(|(h, n)| h.to_ascii_lowercase() == n.to_ascii_lowercase())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if --appimage-extract-and-run is supported.
|
/// Check if --appimage-extract-and-run is supported.
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ pub struct AppImageMetadata {
|
|||||||
pub app_version: Option<String>,
|
pub app_version: Option<String>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub developer: Option<String>,
|
pub developer: Option<String>,
|
||||||
#[allow(dead_code)]
|
|
||||||
pub icon_name: Option<String>,
|
pub icon_name: Option<String>,
|
||||||
pub categories: Vec<String>,
|
pub categories: Vec<String>,
|
||||||
pub desktop_entry_content: String,
|
pub desktop_entry_content: String,
|
||||||
@@ -246,7 +245,7 @@ fn extract_metadata_files(
|
|||||||
.arg("usr/share/metainfo/*.xml")
|
.arg("usr/share/metainfo/*.xml")
|
||||||
.arg("usr/share/appdata/*.xml")
|
.arg("usr/share/appdata/*.xml")
|
||||||
.stdout(std::process::Stdio::null())
|
.stdout(std::process::Stdio::null())
|
||||||
.stderr(std::process::Stdio::piped())
|
.stderr(std::process::Stdio::null())
|
||||||
.status();
|
.status();
|
||||||
|
|
||||||
match status {
|
match status {
|
||||||
@@ -430,8 +429,20 @@ fn detect_architecture(path: &Path) -> Option<String> {
|
|||||||
let mut header = [0u8; 20];
|
let mut header = [0u8; 20];
|
||||||
file.read_exact(&mut header).ok()?;
|
file.read_exact(&mut header).ok()?;
|
||||||
|
|
||||||
// ELF e_machine at offset 18 (little-endian)
|
// Validate ELF magic
|
||||||
let machine = u16::from_le_bytes([header[18], header[19]]);
|
if &header[0..4] != b"\x7FELF" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ELF e_machine at offset 18, endianness from byte 5
|
||||||
|
let machine = if header[5] == 2 {
|
||||||
|
// Big-endian
|
||||||
|
u16::from_be_bytes([header[18], header[19]])
|
||||||
|
} else {
|
||||||
|
// Little-endian (default)
|
||||||
|
u16::from_le_bytes([header[18], header[19]])
|
||||||
|
};
|
||||||
|
|
||||||
match machine {
|
match machine {
|
||||||
0x03 => Some("i386".to_string()),
|
0x03 => Some("i386".to_string()),
|
||||||
0x3E => Some("x86_64".to_string()),
|
0x3E => Some("x86_64".to_string()),
|
||||||
@@ -529,12 +540,42 @@ fn find_appstream_file(extract_dir: &Path) -> Option<PathBuf> {
|
|||||||
|
|
||||||
/// Check if an AppImage has a GPG signature by looking for the .sha256_sig section name.
|
/// Check if an AppImage has a GPG signature by looking for the .sha256_sig section name.
|
||||||
fn detect_signature(path: &Path) -> bool {
|
fn detect_signature(path: &Path) -> bool {
|
||||||
let data = match fs::read(path) {
|
use std::io::{BufReader, Read};
|
||||||
Ok(d) => d,
|
let file = match fs::File::open(path) {
|
||||||
|
Ok(f) => f,
|
||||||
Err(_) => return false,
|
Err(_) => return false,
|
||||||
};
|
};
|
||||||
let needle = b".sha256_sig";
|
let needle = b".sha256_sig";
|
||||||
data.windows(needle.len()).any(|w| w == needle)
|
let mut reader = BufReader::new(file);
|
||||||
|
let mut buf = vec![0u8; 64 * 1024];
|
||||||
|
let mut carry = Vec::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let n = match reader.read(&mut buf) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
// Prepend carry bytes from previous chunk to handle needle spanning chunks
|
||||||
|
let search_buf = if carry.is_empty() {
|
||||||
|
&buf[..n]
|
||||||
|
} else {
|
||||||
|
carry.extend_from_slice(&buf[..n]);
|
||||||
|
carry.as_slice()
|
||||||
|
};
|
||||||
|
if search_buf.windows(needle.len()).any(|w| w == needle) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Keep the last (needle.len - 1) bytes as carry for the next iteration
|
||||||
|
let keep = needle.len() - 1;
|
||||||
|
carry.clear();
|
||||||
|
if n >= keep {
|
||||||
|
carry.extend_from_slice(&buf[n - keep..n]);
|
||||||
|
} else {
|
||||||
|
carry.extend_from_slice(&buf[..n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cache an icon file to the driftwood icons directory.
|
/// Cache an icon file to the driftwood icons directory.
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ pub fn integrate(record: &AppImageRecord) -> Result<IntegrationResult, Integrati
|
|||||||
"[Desktop Entry]\n\
|
"[Desktop Entry]\n\
|
||||||
Type=Application\n\
|
Type=Application\n\
|
||||||
Name={name}\n\
|
Name={name}\n\
|
||||||
Exec={exec} %U\n\
|
Exec=\"{exec}\" %U\n\
|
||||||
Icon={icon}\n\
|
Icon={icon}\n\
|
||||||
Categories={categories}\n\
|
Categories={categories}\n\
|
||||||
Comment={comment}\n\
|
Comment={comment}\n\
|
||||||
@@ -228,7 +228,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_integrate_creates_desktop_file() {
|
fn test_integrate_creates_desktop_file() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let _dir = tempfile::tempdir().unwrap();
|
||||||
// Override the applications dir for testing by creating the record
|
// Override the applications dir for testing by creating the record
|
||||||
// with a specific path and testing the desktop content generation
|
// with a specific path and testing the desktop content generation
|
||||||
let record = AppImageRecord {
|
let record = AppImageRecord {
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ pub enum LaunchMethod {
|
|||||||
/// Extract-and-run fallback (APPIMAGE_EXTRACT_AND_RUN=1)
|
/// Extract-and-run fallback (APPIMAGE_EXTRACT_AND_RUN=1)
|
||||||
ExtractAndRun,
|
ExtractAndRun,
|
||||||
/// Via firejail sandbox
|
/// Via firejail sandbox
|
||||||
#[allow(dead_code)]
|
|
||||||
Sandboxed,
|
Sandboxed,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +67,6 @@ pub enum LaunchResult {
|
|||||||
Crashed {
|
Crashed {
|
||||||
exit_code: Option<i32>,
|
exit_code: Option<i32>,
|
||||||
stderr: String,
|
stderr: String,
|
||||||
#[allow(dead_code)]
|
|
||||||
method: LaunchMethod,
|
method: LaunchMethod,
|
||||||
},
|
},
|
||||||
/// Failed to launch.
|
/// Failed to launch.
|
||||||
@@ -99,6 +97,22 @@ pub fn launch_appimage(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Override with sandboxed launch if the user enabled firejail for this app
|
||||||
|
let method = if has_firejail() {
|
||||||
|
let sandbox = db
|
||||||
|
.get_appimage_by_id(record_id)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.and_then(|r| r.sandbox_mode);
|
||||||
|
if sandbox.as_deref() == Some("firejail") {
|
||||||
|
LaunchMethod::Sandboxed
|
||||||
|
} else {
|
||||||
|
method
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
method
|
||||||
|
};
|
||||||
|
|
||||||
let result = execute_appimage(appimage_path, &method, extra_args, extra_env);
|
let result = execute_appimage(appimage_path, &method, extra_args, extra_env);
|
||||||
|
|
||||||
// Record the launch event regardless of success
|
// Record the launch event regardless of success
|
||||||
@@ -163,42 +177,38 @@ fn execute_appimage(
|
|||||||
cmd.env(key, value);
|
cmd.env(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture stderr to detect crash messages, stdin detached
|
// Detach stdin, pipe stderr so we can capture crash messages
|
||||||
cmd.stdin(Stdio::null());
|
cmd.stdin(Stdio::null());
|
||||||
cmd.stderr(Stdio::piped());
|
cmd.stderr(Stdio::piped());
|
||||||
|
|
||||||
match cmd.spawn() {
|
match cmd.spawn() {
|
||||||
Ok(mut child) => {
|
Ok(mut child) => {
|
||||||
// Brief wait to detect immediate crashes (e.g. missing Qt plugins)
|
// Give the process a brief moment to fail on immediate errors
|
||||||
std::thread::sleep(std::time::Duration::from_millis(1500));
|
// (missing libs, exec format errors, Qt plugin failures, etc.)
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||||
|
|
||||||
match child.try_wait() {
|
match child.try_wait() {
|
||||||
Ok(Some(status)) => {
|
Ok(Some(status)) => {
|
||||||
// Process already exited - it crashed
|
// Already exited - immediate crash. Read stderr for details.
|
||||||
let stderr = child
|
let stderr_text = child.stderr.take().map(|mut pipe| {
|
||||||
.stderr
|
|
||||||
.take()
|
|
||||||
.and_then(|mut err| {
|
|
||||||
let mut buf = String::new();
|
let mut buf = String::new();
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
err.read_to_string(&mut buf).ok()?;
|
// Read with a size cap to avoid huge allocations
|
||||||
Some(buf)
|
let mut limited = (&mut pipe).take(64 * 1024);
|
||||||
})
|
let _ = limited.read_to_string(&mut buf);
|
||||||
.unwrap_or_default();
|
buf
|
||||||
|
}).unwrap_or_default();
|
||||||
|
|
||||||
LaunchResult::Crashed {
|
LaunchResult::Crashed {
|
||||||
exit_code: status.code(),
|
exit_code: status.code(),
|
||||||
stderr,
|
stderr: stderr_text,
|
||||||
method: method.clone(),
|
method: method.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
_ => {
|
||||||
// Still running - success
|
// Still running after 150ms - drop the stderr pipe so the
|
||||||
LaunchResult::Started {
|
// child process won't block if it fills the pipe buffer.
|
||||||
child,
|
drop(child.stderr.take());
|
||||||
method: method.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
// Can't check status, assume it's running
|
|
||||||
LaunchResult::Started {
|
LaunchResult::Started {
|
||||||
child,
|
child,
|
||||||
method: method.clone(),
|
method: method.clone(),
|
||||||
@@ -211,11 +221,38 @@ fn execute_appimage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a launch_args string from the database into a Vec of individual arguments.
|
/// Parse a launch_args string from the database into a Vec of individual arguments.
|
||||||
/// Splits on whitespace; returns an empty Vec if the input is None or empty.
|
/// Parse launch arguments with basic quote support.
|
||||||
#[allow(dead_code)]
|
/// Splits on whitespace, respecting double-quoted strings.
|
||||||
|
/// Returns an empty Vec if the input is None or empty.
|
||||||
pub fn parse_launch_args(args: Option<&str>) -> Vec<String> {
|
pub fn parse_launch_args(args: Option<&str>) -> Vec<String> {
|
||||||
args.map(|s| s.split_whitespace().map(String::from).collect())
|
let Some(s) = args else {
|
||||||
.unwrap_or_default()
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let s = s.trim();
|
||||||
|
if s.is_empty() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
let mut in_quotes = false;
|
||||||
|
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'"' => in_quotes = !in_quotes,
|
||||||
|
' ' | '\t' if !in_quotes => {
|
||||||
|
if !current.is_empty() {
|
||||||
|
result.push(std::mem::take(&mut current));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => current.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !current.is_empty() {
|
||||||
|
result.push(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if firejail is available for sandboxed launches.
|
/// Check if firejail is available for sandboxed launches.
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use super::security;
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CveNotification {
|
pub struct CveNotification {
|
||||||
pub app_name: String,
|
pub app_name: String,
|
||||||
#[allow(dead_code)]
|
|
||||||
pub appimage_id: i64,
|
pub appimage_id: i64,
|
||||||
pub severity: String,
|
pub severity: String,
|
||||||
pub cve_count: usize,
|
pub cve_count: usize,
|
||||||
@@ -138,7 +137,6 @@ fn send_desktop_notification(notif: &CveNotification) -> Result<(), Notification
|
|||||||
|
|
||||||
/// Run a security scan and send notifications for any new findings.
|
/// Run a security scan and send notifications for any new findings.
|
||||||
/// This is the CLI entry point for `driftwood security --notify`.
|
/// This is the CLI entry point for `driftwood security --notify`.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn scan_and_notify(db: &Database, threshold: &str) -> Vec<CveNotification> {
|
pub fn scan_and_notify(db: &Database, threshold: &str) -> Vec<CveNotification> {
|
||||||
// First run a batch scan to get fresh data
|
// First run a batch scan to get fresh data
|
||||||
let _results = security::batch_scan(db);
|
let _results = security::batch_scan(db);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ pub enum ReportFormat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ReportFormat {
|
impl ReportFormat {
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn from_str(s: &str) -> Option<Self> {
|
pub fn from_str(s: &str) -> Option<Self> {
|
||||||
match s.to_lowercase().as_str() {
|
match s.to_lowercase().as_str() {
|
||||||
"json" => Some(Self::Json),
|
"json" => Some(Self::Json),
|
||||||
@@ -20,7 +19,6 @@ impl ReportFormat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn extension(&self) -> &'static str {
|
pub fn extension(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Self::Json => "json",
|
Self::Json => "json",
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ pub struct CveMatch {
|
|||||||
/// Result of a security scan for a single AppImage.
|
/// Result of a security scan for a single AppImage.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SecurityScanResult {
|
pub struct SecurityScanResult {
|
||||||
#[allow(dead_code)]
|
|
||||||
pub appimage_id: i64,
|
pub appimage_id: i64,
|
||||||
pub libraries: Vec<BundledLibrary>,
|
pub libraries: Vec<BundledLibrary>,
|
||||||
pub cve_matches: Vec<(BundledLibrary, Vec<CveMatch>)>,
|
pub cve_matches: Vec<(BundledLibrary, Vec<CveMatch>)>,
|
||||||
@@ -254,10 +253,9 @@ pub fn detect_version_from_binary(
|
|||||||
let extract_output = Command::new("unsquashfs")
|
let extract_output = Command::new("unsquashfs")
|
||||||
.args(["-o", &offset, "-f", "-d"])
|
.args(["-o", &offset, "-f", "-d"])
|
||||||
.arg(temp_dir.path())
|
.arg(temp_dir.path())
|
||||||
.arg("-e")
|
|
||||||
.arg(lib_file_path.trim_start_matches("squashfs-root/"))
|
|
||||||
.arg("-no-progress")
|
.arg("-no-progress")
|
||||||
.arg(appimage_path)
|
.arg(appimage_path)
|
||||||
|
.arg(lib_file_path.trim_start_matches("squashfs-root/"))
|
||||||
.output()
|
.output()
|
||||||
.ok()?;
|
.ok()?;
|
||||||
|
|
||||||
|
|||||||
@@ -370,27 +370,51 @@ fn parse_elf32_sections(data: &[u8]) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Fallback: run the AppImage with --appimage-updateinformation flag.
|
/// Fallback: run the AppImage with --appimage-updateinformation flag.
|
||||||
|
/// Uses a 5-second timeout to avoid hanging on apps with custom AppRun scripts.
|
||||||
fn extract_update_info_runtime(path: &Path) -> Option<String> {
|
fn extract_update_info_runtime(path: &Path) -> Option<String> {
|
||||||
let output = std::process::Command::new(path)
|
let mut child = std::process::Command::new(path)
|
||||||
.arg("--appimage-updateinformation")
|
.arg("--appimage-updateinformation")
|
||||||
.env("APPIMAGE_EXTRACT_AND_RUN", "1")
|
.env("APPIMAGE_EXTRACT_AND_RUN", "1")
|
||||||
.output()
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.spawn()
|
||||||
.ok()?;
|
.ok()?;
|
||||||
|
|
||||||
if output.status.success() {
|
let timeout = std::time::Duration::from_secs(5);
|
||||||
let info = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
let start = std::time::Instant::now();
|
||||||
|
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('|') {
|
if !info.is_empty() && info.contains('|') {
|
||||||
return Some(info);
|
return Some(info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return None;
|
||||||
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 --
|
// -- GitHub/GitLab API types for JSON deserialization --
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[allow(dead_code)]
|
|
||||||
struct GhRelease {
|
struct GhRelease {
|
||||||
tag_name: String,
|
tag_name: String,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
@@ -406,7 +430,6 @@ struct GhAsset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[allow(dead_code)]
|
|
||||||
struct GlRelease {
|
struct GlRelease {
|
||||||
tag_name: String,
|
tag_name: String,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
@@ -492,6 +515,12 @@ fn check_github_release(
|
|||||||
|
|
||||||
let release: GhRelease = response.body_mut().read_json().ok()?;
|
let release: GhRelease = response.body_mut().read_json().ok()?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"GitHub release: tag={}, name={:?}",
|
||||||
|
release.tag_name,
|
||||||
|
release.name.as_deref().unwrap_or("(none)"),
|
||||||
|
);
|
||||||
|
|
||||||
let latest_version = clean_version(&release.tag_name);
|
let latest_version = clean_version(&release.tag_name);
|
||||||
|
|
||||||
// Find matching asset using glob-like pattern
|
// Find matching asset using glob-like pattern
|
||||||
@@ -549,6 +578,12 @@ fn check_gitlab_release(
|
|||||||
|
|
||||||
let release: GlRelease = response.body_mut().read_json().ok()?;
|
let release: GlRelease = response.body_mut().read_json().ok()?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"GitLab release: tag={}, name={:?}",
|
||||||
|
release.tag_name,
|
||||||
|
release.name.as_deref().unwrap_or("(none)"),
|
||||||
|
);
|
||||||
|
|
||||||
let latest_version = clean_version(&release.tag_name);
|
let latest_version = clean_version(&release.tag_name);
|
||||||
|
|
||||||
let download_url = release.assets.and_then(|assets| {
|
let download_url = release.assets.and_then(|assets| {
|
||||||
@@ -669,18 +704,24 @@ fn glob_match(pattern: &str, text: &str) -> bool {
|
|||||||
|
|
||||||
// Last part must match at the end (unless pattern ends with *)
|
// Last part must match at the end (unless pattern ends with *)
|
||||||
let last = parts[parts.len() - 1];
|
let last = parts[parts.len() - 1];
|
||||||
if !last.is_empty() {
|
let end_limit = if !last.is_empty() {
|
||||||
if !text.ends_with(last) {
|
if !text.ends_with(last) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
text.len() - last.len()
|
||||||
|
} else {
|
||||||
|
text.len()
|
||||||
|
};
|
||||||
|
|
||||||
// Middle parts must appear in order
|
// Middle parts must appear in order within the allowed range
|
||||||
for part in &parts[1..parts.len() - 1] {
|
for part in &parts[1..parts.len() - 1] {
|
||||||
if part.is_empty() {
|
if part.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(found) = text[pos..].find(part) {
|
if pos >= end_limit {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(found) = text[pos..end_limit].find(part) {
|
||||||
pos += found + part.len();
|
pos += found + part.len();
|
||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
@@ -691,7 +732,7 @@ fn glob_match(pattern: &str, text: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Clean a version string - strip leading 'v' or 'V' prefix.
|
/// Clean a version string - strip leading 'v' or 'V' prefix.
|
||||||
fn clean_version(version: &str) -> String {
|
pub(crate) fn clean_version(version: &str) -> String {
|
||||||
let v = version.trim();
|
let v = version.trim();
|
||||||
v.strip_prefix('v')
|
v.strip_prefix('v')
|
||||||
.or_else(|| v.strip_prefix('V'))
|
.or_else(|| v.strip_prefix('V'))
|
||||||
@@ -949,24 +990,25 @@ fn download_file(
|
|||||||
|
|
||||||
/// Verify that a file is a valid AppImage (has ELF header + AppImage magic bytes).
|
/// Verify that a file is a valid AppImage (has ELF header + AppImage magic bytes).
|
||||||
fn verify_appimage(path: &Path) -> bool {
|
fn verify_appimage(path: &Path) -> bool {
|
||||||
if let Ok(data) = fs::read(path) {
|
use std::io::Read;
|
||||||
if data.len() < 12 {
|
let mut file = match fs::File::open(path) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let mut header = [0u8; 12];
|
||||||
|
if file.read_exact(&mut header).is_err() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Check ELF magic
|
// Check ELF magic
|
||||||
if &data[0..4] != b"\x7FELF" {
|
if &header[0..4] != b"\x7FELF" {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Check AppImage Type 2 magic at offset 8: AI\x02
|
// 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;
|
return true;
|
||||||
}
|
}
|
||||||
// Check AppImage Type 1 magic at offset 8: AI\x01
|
// Check AppImage Type 1 magic at offset 8: AI\x01
|
||||||
if data[8] == 0x41 && data[9] == 0x49 && data[10] == 0x01 {
|
header[8] == 0x41 && header[9] == 0x49 && header[10] == 0x01
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Perform an update using the best available method.
|
/// Perform an update using the best available method.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watche
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum WatchEvent {
|
pub enum WatchEvent {
|
||||||
/// One or more AppImage files were created, modified, or deleted.
|
/// One or more AppImage files were created, modified, or deleted.
|
||||||
Changed(#[allow(dead_code)] Vec<PathBuf>),
|
Changed(Vec<PathBuf>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start watching the given directories for AppImage file changes.
|
/// Start watching the given directories for AppImage file changes.
|
||||||
|
|||||||
@@ -307,11 +307,9 @@ pub fn detect_desktop_environment() -> String {
|
|||||||
/// Result of analyzing a running process for Wayland usage.
|
/// Result of analyzing a running process for Wayland usage.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RuntimeAnalysis {
|
pub struct RuntimeAnalysis {
|
||||||
#[allow(dead_code)]
|
|
||||||
pub pid: u32,
|
pub pid: u32,
|
||||||
pub has_wayland_socket: bool,
|
pub has_wayland_socket: bool,
|
||||||
pub has_x11_connection: bool,
|
pub has_x11_connection: bool,
|
||||||
#[allow(dead_code)]
|
|
||||||
pub env_vars: Vec<(String, String)>,
|
pub env_vars: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,7 +389,6 @@ pub fn analyze_running_process(pid: u32) -> Result<RuntimeAnalysis, String> {
|
|||||||
has_wayland_socket = env_vars.iter().any(|(k, v)| {
|
has_wayland_socket = env_vars.iter().any(|(k, v)| {
|
||||||
(k == "GDK_BACKEND" && v.contains("wayland"))
|
(k == "GDK_BACKEND" && v.contains("wayland"))
|
||||||
|| (k == "QT_QPA_PLATFORM" && v.contains("wayland"))
|
|| (k == "QT_QPA_PLATFORM" && v.contains("wayland"))
|
||||||
|| (k == "WAYLAND_DISPLAY" && !v.is_empty())
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ pub fn i18n_f(msgid: &str, args: &[(&str, &str)]) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Translate a string with singular/plural forms and named placeholders.
|
/// Translate a string with singular/plural forms and named placeholders.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn ni18n_f(singular: &str, plural: &str, n: u32, args: &[(&str, &str)]) -> String {
|
pub fn ni18n_f(singular: &str, plural: &str, n: u32, args: &[(&str, &str)]) -> String {
|
||||||
let base = if n == 1 { singular } else { plural };
|
let base = if n == 1 { singular } else { plural };
|
||||||
let mut result = base.to_string();
|
let mut result = base.to_string();
|
||||||
|
|||||||
@@ -48,6 +48,20 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
|
|||||||
view_stack.add_titled(&storage_page, Some("storage"), "Storage");
|
view_stack.add_titled(&storage_page, Some("storage"), "Storage");
|
||||||
view_stack.page(&storage_page).set_icon_name(Some("drive-harddisk-symbolic"));
|
view_stack.page(&storage_page).set_icon_name(Some("drive-harddisk-symbolic"));
|
||||||
|
|
||||||
|
// Restore last-used tab from GSettings
|
||||||
|
let settings = gio::Settings::new(crate::config::APP_ID);
|
||||||
|
let saved_tab = settings.string("detail-tab");
|
||||||
|
if view_stack.child_by_name(&saved_tab).is_some() {
|
||||||
|
view_stack.set_visible_child_name(&saved_tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist tab choice on switch
|
||||||
|
view_stack.connect_visible_child_name_notify(move |stack| {
|
||||||
|
if let Some(name) = stack.visible_child_name() {
|
||||||
|
settings.set_string("detail-tab", &name).ok();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Banner scrolls with content (not sticky) so tall banners don't eat space
|
// Banner scrolls with content (not sticky) so tall banners don't eat space
|
||||||
let scroll_content = gtk::Box::builder()
|
let scroll_content = gtk::Box::builder()
|
||||||
.orientation(gtk::Orientation::Vertical)
|
.orientation(gtk::Orientation::Vertical)
|
||||||
@@ -83,6 +97,7 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
|
|||||||
let record_id = record.id;
|
let record_id = record.id;
|
||||||
let path = record.path.clone();
|
let path = record.path.clone();
|
||||||
let app_name_launch = record.app_name.clone().unwrap_or_else(|| record.filename.clone());
|
let app_name_launch = record.app_name.clone().unwrap_or_else(|| record.filename.clone());
|
||||||
|
let launch_args_raw = record.launch_args.clone();
|
||||||
let db_launch = db.clone();
|
let db_launch = db.clone();
|
||||||
let toast_launch = toast_overlay.clone();
|
let toast_launch = toast_overlay.clone();
|
||||||
launch_button.connect_clicked(move |btn| {
|
launch_button.connect_clicked(move |btn| {
|
||||||
@@ -92,6 +107,7 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
|
|||||||
let app_name = app_name_launch.clone();
|
let app_name = app_name_launch.clone();
|
||||||
let db_launch = db_launch.clone();
|
let db_launch = db_launch.clone();
|
||||||
let toast_ref = toast_launch.clone();
|
let toast_ref = toast_launch.clone();
|
||||||
|
let launch_args = launcher::parse_launch_args(launch_args_raw.as_deref());
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let path_bg = path.clone();
|
let path_bg = path.clone();
|
||||||
let result = gio::spawn_blocking(move || {
|
let result = gio::spawn_blocking(move || {
|
||||||
@@ -101,7 +117,7 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
|
|||||||
record_id,
|
record_id,
|
||||||
appimage_path,
|
appimage_path,
|
||||||
"gui_detail",
|
"gui_detail",
|
||||||
&[],
|
&launch_args,
|
||||||
&[],
|
&[],
|
||||||
)
|
)
|
||||||
}).await;
|
}).await;
|
||||||
@@ -121,13 +137,16 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
|
|||||||
}).await;
|
}).await;
|
||||||
if let Ok(Ok(analysis)) = analysis_result {
|
if let Ok(Ok(analysis)) = analysis_result {
|
||||||
let status_str = analysis.as_status_str();
|
let status_str = analysis.as_status_str();
|
||||||
log::info!("Runtime Wayland: {} -> {}", path_clone, analysis.status_label());
|
log::info!(
|
||||||
|
"Runtime Wayland: {} -> {} (pid={}, env: {:?})",
|
||||||
|
path_clone, analysis.status_label(), analysis.pid, analysis.env_vars,
|
||||||
|
);
|
||||||
db_wayland.update_runtime_wayland_status(record_id, status_str).ok();
|
db_wayland.update_runtime_wayland_status(record_id, status_str).ok();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(launcher::LaunchResult::Crashed { exit_code, stderr, .. }) => {
|
Ok(launcher::LaunchResult::Crashed { exit_code, stderr, method }) => {
|
||||||
log::error!("App crashed on launch (exit {}): {}", exit_code.unwrap_or(-1), stderr);
|
log::error!("App crashed on launch (exit {}, method: {}): {}", exit_code.unwrap_or(-1), method.as_str(), stderr);
|
||||||
widgets::show_crash_dialog(&btn_ref, &app_name, exit_code, &stderr);
|
widgets::show_crash_dialog(&btn_ref, &app_name, exit_code, &stderr);
|
||||||
}
|
}
|
||||||
Ok(launcher::LaunchResult::Failed(msg)) => {
|
Ok(launcher::LaunchResult::Failed(msg)) => {
|
||||||
@@ -247,9 +266,7 @@ fn build_banner(record: &AppImageRecord) -> gtk::Box {
|
|||||||
.margin_top(4)
|
.margin_top(4)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
if record.integrated {
|
badge_box.append(&widgets::integration_badge(record.integrated));
|
||||||
badge_box.append(&widgets::status_badge("Integrated", "success"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref ws) = record.wayland_status {
|
if let Some(ref ws) = record.wayland_status {
|
||||||
let status = WaylandStatus::from_str(ws);
|
let status = WaylandStatus::from_str(ws);
|
||||||
@@ -1582,6 +1599,10 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
|
|||||||
group.add(&empty_row);
|
group.add(&empty_row);
|
||||||
} else {
|
} else {
|
||||||
for b in &backups {
|
for b in &backups {
|
||||||
|
log::debug!(
|
||||||
|
"Listing backup id={} for appimage_id={} at {}",
|
||||||
|
b.id, b.appimage_id, b.archive_path,
|
||||||
|
);
|
||||||
let expander = adw::ExpanderRow::builder()
|
let expander = adw::ExpanderRow::builder()
|
||||||
.title(&b.created_at)
|
.title(&b.created_at)
|
||||||
.subtitle(&format!(
|
.subtitle(&format!(
|
||||||
@@ -1656,12 +1677,28 @@ fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw:
|
|||||||
row_clone.set_sensitive(true);
|
row_clone.set_sensitive(true);
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(res)) => {
|
Ok(Ok(res)) => {
|
||||||
|
let skip_note = if res.paths_skipped > 0 {
|
||||||
|
format!(" ({} skipped)", res.paths_skipped)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
row_clone.set_subtitle(&format!(
|
row_clone.set_subtitle(&format!(
|
||||||
"Restored {} path{}",
|
"Restored {} path{}{}",
|
||||||
res.paths_restored,
|
res.paths_restored,
|
||||||
if res.paths_restored == 1 { "" } else { "s" },
|
if res.paths_restored == 1 { "" } else { "s" },
|
||||||
|
skip_note,
|
||||||
));
|
));
|
||||||
toast.add_toast(adw::Toast::new("Backup restored"));
|
let toast_msg = format!(
|
||||||
|
"Restored {} path{}{}",
|
||||||
|
res.paths_restored,
|
||||||
|
if res.paths_restored == 1 { "" } else { "s" },
|
||||||
|
skip_note,
|
||||||
|
);
|
||||||
|
toast.add_toast(adw::Toast::new(&toast_msg));
|
||||||
|
log::info!(
|
||||||
|
"Backup restored: app={}, paths_restored={}, paths_skipped={}",
|
||||||
|
res.manifest.app_name, res.paths_restored, res.paths_skipped,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
row_clone.set_subtitle("Restore failed");
|
row_clone.set_subtitle("Restore failed");
|
||||||
@@ -1922,7 +1959,9 @@ fn show_screenshot_lightbox(
|
|||||||
// --- Click outside image to close ---
|
// --- Click outside image to close ---
|
||||||
// Picture's gesture claims clicks on the image, preventing close.
|
// Picture's gesture claims clicks on the image, preventing close.
|
||||||
let pic_gesture = gtk::GestureClick::new();
|
let pic_gesture = gtk::GestureClick::new();
|
||||||
pic_gesture.connect_released(|_, _, _, _| {});
|
pic_gesture.connect_released(|gesture, _, _, _| {
|
||||||
|
gesture.set_state(gtk::EventSequenceState::Claimed);
|
||||||
|
});
|
||||||
picture.add_controller(pic_gesture);
|
picture.add_controller(pic_gesture);
|
||||||
|
|
||||||
// Window gesture fires for clicks on the dark margin area.
|
// Window gesture fires for clicks on the dark margin area.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ use adw::prelude::*;
|
|||||||
use gtk::gio;
|
use gtk::gio;
|
||||||
|
|
||||||
use crate::config::APP_ID;
|
use crate::config::APP_ID;
|
||||||
|
use crate::core::appstream;
|
||||||
|
use crate::core::database::Database;
|
||||||
use crate::i18n::i18n;
|
use crate::i18n::i18n;
|
||||||
|
|
||||||
pub fn show_preferences_dialog(parent: &impl IsA<gtk::Widget>) {
|
pub fn show_preferences_dialog(parent: &impl IsA<gtk::Widget>) {
|
||||||
@@ -153,6 +155,47 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
|
|||||||
scan_group.add(&add_button);
|
scan_group.add(&add_button);
|
||||||
page.add(&scan_group);
|
page.add(&scan_group);
|
||||||
|
|
||||||
|
// Desktop Integration group - AppStream catalog for GNOME Software/Discover
|
||||||
|
let integration_group = adw::PreferencesGroup::builder()
|
||||||
|
.title(&i18n("Desktop Integration"))
|
||||||
|
.description(&i18n(
|
||||||
|
"Make your AppImages visible in GNOME Software and KDE Discover",
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let catalog_row = adw::SwitchRow::builder()
|
||||||
|
.title(&i18n("AppStream catalog"))
|
||||||
|
.subtitle(&i18n(
|
||||||
|
"Generate a local catalog so software centers can list your AppImages",
|
||||||
|
))
|
||||||
|
.active(appstream::is_catalog_installed())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
catalog_row.connect_active_notify(|row| {
|
||||||
|
let enable = row.is_active();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = gio::spawn_blocking(move || {
|
||||||
|
if enable {
|
||||||
|
let db = Database::open().expect("Failed to open database");
|
||||||
|
appstream::install_catalog(&db)
|
||||||
|
.map(|p| log::info!("AppStream catalog installed: {}", p.display()))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
} else {
|
||||||
|
appstream::uninstall_catalog()
|
||||||
|
.map(|()| log::info!("AppStream catalog removed"))
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
if let Ok(Err(e)) = result {
|
||||||
|
log::warn!("AppStream catalog toggle failed: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
integration_group.add(&catalog_row);
|
||||||
|
page.add(&integration_group);
|
||||||
|
|
||||||
page
|
page
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,17 @@ pub fn build_security_report_page(db: &Rc<Database>) -> adw::NavigationPage {
|
|||||||
|
|
||||||
if let Ok(results) = result {
|
if let Ok(results) = result {
|
||||||
let total_cves: usize = results.iter().map(|r| r.total_cves()).sum();
|
let total_cves: usize = results.iter().map(|r| r.total_cves()).sum();
|
||||||
|
for r in &results {
|
||||||
|
log::info!(
|
||||||
|
"Security scan: appimage_id={} found {} CVEs",
|
||||||
|
r.appimage_id, r.total_cves(),
|
||||||
|
);
|
||||||
|
}
|
||||||
log::info!("Security scan complete: {} CVEs found across {} AppImages", total_cves, results.len());
|
log::info!("Security scan complete: {} CVEs found across {} AppImages", total_cves, results.len());
|
||||||
|
widgets::announce(
|
||||||
|
&stack_refresh,
|
||||||
|
&format!("Security scan complete: {} vulnerabilities found", total_cves),
|
||||||
|
);
|
||||||
|
|
||||||
// Refresh the page content with updated data
|
// Refresh the page content with updated data
|
||||||
let new_content = build_report_content(&db_refresh);
|
let new_content = build_report_content(&db_refresh);
|
||||||
@@ -119,9 +129,14 @@ pub fn build_security_report_page(db: &Rc<Database>) -> adw::NavigationPage {
|
|||||||
filters.append(&json_filter);
|
filters.append(&json_filter);
|
||||||
filters.append(&csv_filter);
|
filters.append(&csv_filter);
|
||||||
|
|
||||||
|
let default_format = report::ReportFormat::Html;
|
||||||
|
let initial_name = format!(
|
||||||
|
"driftwood-security-report.{}",
|
||||||
|
default_format.extension(),
|
||||||
|
);
|
||||||
let dialog = gtk::FileDialog::builder()
|
let dialog = gtk::FileDialog::builder()
|
||||||
.title("Export Security Report")
|
.title("Export Security Report")
|
||||||
.initial_name("driftwood-security-report.html")
|
.initial_name(&initial_name)
|
||||||
.filters(&filters)
|
.filters(&filters)
|
||||||
.default_filter(&html_filter)
|
.default_filter(&html_filter)
|
||||||
.modal(true)
|
.modal(true)
|
||||||
@@ -142,11 +157,8 @@ pub fn build_security_report_page(db: &Rc<Database>) -> adw::NavigationPage {
|
|||||||
.unwrap_or("html")
|
.unwrap_or("html")
|
||||||
.to_lowercase();
|
.to_lowercase();
|
||||||
|
|
||||||
let format = match ext.as_str() {
|
let format = report::ReportFormat::from_str(&ext)
|
||||||
"json" => report::ReportFormat::Json,
|
.unwrap_or(report::ReportFormat::Html);
|
||||||
"csv" => report::ReportFormat::Csv,
|
|
||||||
_ => report::ReportFormat::Html,
|
|
||||||
};
|
|
||||||
|
|
||||||
btn_clone.set_sensitive(false);
|
btn_clone.set_sensitive(false);
|
||||||
btn_clone.set_label("Exporting...");
|
btn_clone.set_label("Exporting...");
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ pub fn status_badge_with_icon(icon_name: &str, text: &str, style_class: &str) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a badge showing integration status.
|
/// Create a badge showing integration status.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn integration_badge(integrated: bool) -> gtk::Label {
|
pub fn integration_badge(integrated: bool) -> gtk::Label {
|
||||||
if integrated {
|
if integrated {
|
||||||
status_badge("Integrated", "success")
|
status_badge("Integrated", "success")
|
||||||
@@ -345,7 +344,6 @@ fn crash_explanation(stderr: &str) -> String {
|
|||||||
/// Inserts a hidden label with AccessibleRole::Alert into the given container,
|
/// Inserts a hidden label with AccessibleRole::Alert into the given container,
|
||||||
/// which causes AT-SPI to announce the text to screen readers.
|
/// which causes AT-SPI to announce the text to screen readers.
|
||||||
/// The label auto-removes after a short delay.
|
/// The label auto-removes after a short delay.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn announce(container: &impl gtk::prelude::IsA<gtk::Widget>, text: &str) {
|
pub fn announce(container: &impl gtk::prelude::IsA<gtk::Widget>, text: &str) {
|
||||||
let label = gtk::Label::builder()
|
let label = gtk::Label::builder()
|
||||||
.label(text)
|
.label(text)
|
||||||
@@ -354,7 +352,16 @@ pub fn announce(container: &impl gtk::prelude::IsA<gtk::Widget>, text: &str) {
|
|||||||
.build();
|
.build();
|
||||||
label.update_property(&[gtk::accessible::Property::Label(text)]);
|
label.update_property(&[gtk::accessible::Property::Label(text)]);
|
||||||
|
|
||||||
if let Some(box_widget) = container.dynamic_cast_ref::<gtk::Box>() {
|
// Try to find a suitable Box container to attach the label to
|
||||||
|
let target_box = container.dynamic_cast_ref::<gtk::Box>().cloned()
|
||||||
|
.or_else(|| {
|
||||||
|
// For Stack widgets, use the visible child if it's a Box
|
||||||
|
container.dynamic_cast_ref::<gtk::Stack>()
|
||||||
|
.and_then(|s| s.visible_child())
|
||||||
|
.and_then(|c| c.downcast::<gtk::Box>().ok())
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(box_widget) = target_box {
|
||||||
box_widget.append(&label);
|
box_widget.append(&label);
|
||||||
label.set_visible(true);
|
label.set_visible(true);
|
||||||
let label_clone = label.clone();
|
let label_clone = label.clone();
|
||||||
|
|||||||
@@ -595,29 +595,30 @@ impl DriftwoodWindow {
|
|||||||
let Some(record_id) = param.and_then(|p| p.get::<i64>()) else { return };
|
let Some(record_id) = param.and_then(|p| p.get::<i64>()) else { return };
|
||||||
let toast_overlay = window.imp().toast_overlay.get().unwrap().clone();
|
let toast_overlay = window.imp().toast_overlay.get().unwrap().clone();
|
||||||
let window_ref = window.clone();
|
let window_ref = window.clone();
|
||||||
let (path_str, app_name) = {
|
let (path_str, app_name, launch_args_raw) = {
|
||||||
let db = window.database();
|
let db = window.database();
|
||||||
match db.get_appimage_by_id(record_id) {
|
match db.get_appimage_by_id(record_id) {
|
||||||
Ok(Some(r)) => {
|
Ok(Some(r)) => {
|
||||||
let name = r.app_name.clone().unwrap_or_else(|| r.filename.clone());
|
let name = r.app_name.clone().unwrap_or_else(|| r.filename.clone());
|
||||||
(r.path.clone(), name)
|
(r.path.clone(), name, r.launch_args.clone())
|
||||||
}
|
}
|
||||||
_ => return,
|
_ => return,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let launch_args = launcher::parse_launch_args(launch_args_raw.as_deref());
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
let path_bg = path_str.clone();
|
let path_bg = path_str.clone();
|
||||||
let result = gio::spawn_blocking(move || {
|
let result = gio::spawn_blocking(move || {
|
||||||
let bg_db = crate::core::database::Database::open().expect("DB open");
|
let bg_db = crate::core::database::Database::open().expect("DB open");
|
||||||
let appimage_path = std::path::Path::new(&path_bg);
|
let appimage_path = std::path::Path::new(&path_bg);
|
||||||
launcher::launch_appimage(&bg_db, record_id, appimage_path, "gui_context", &[], &[])
|
launcher::launch_appimage(&bg_db, record_id, appimage_path, "gui_context", &launch_args, &[])
|
||||||
}).await;
|
}).await;
|
||||||
match result {
|
match result {
|
||||||
Ok(launcher::LaunchResult::Started { child, method }) => {
|
Ok(launcher::LaunchResult::Started { child, method }) => {
|
||||||
log::info!("Launched: {} (PID: {}, method: {})", path_str, child.id(), method.as_str());
|
log::info!("Launched: {} (PID: {}, method: {})", path_str, child.id(), method.as_str());
|
||||||
}
|
}
|
||||||
Ok(launcher::LaunchResult::Crashed { exit_code, stderr, .. }) => {
|
Ok(launcher::LaunchResult::Crashed { exit_code, stderr, method }) => {
|
||||||
log::error!("App crashed (exit {}): {}", exit_code.unwrap_or(-1), stderr);
|
log::error!("App crashed (exit {}, method: {}): {}", exit_code.unwrap_or(-1), method.as_str(), stderr);
|
||||||
widgets::show_crash_dialog(&window_ref, &app_name, exit_code, &stderr);
|
widgets::show_crash_dialog(&window_ref, &app_name, exit_code, &stderr);
|
||||||
}
|
}
|
||||||
Ok(launcher::LaunchResult::Failed(msg)) => {
|
Ok(launcher::LaunchResult::Failed(msg)) => {
|
||||||
@@ -830,12 +831,49 @@ impl DriftwoodWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always scan on startup to discover new AppImages and complete pending analyses
|
// Scan on startup if enabled in preferences
|
||||||
|
if self.settings().boolean("auto-scan-on-startup") {
|
||||||
self.trigger_scan();
|
self.trigger_scan();
|
||||||
|
}
|
||||||
|
|
||||||
// Start watching scan directories for new AppImage files
|
// Start watching scan directories for new AppImage files
|
||||||
self.start_file_watcher();
|
self.start_file_watcher();
|
||||||
|
|
||||||
|
// Auto-cleanup old backups based on retention setting
|
||||||
|
let retention_days = self.settings().int("backup-retention-days") as u32;
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let _ = gio::spawn_blocking(move || {
|
||||||
|
let bg_db = Database::open().expect("Failed to open database");
|
||||||
|
match crate::core::backup::auto_cleanup_old_backups(&bg_db, retention_days) {
|
||||||
|
Ok(removed) if removed > 0 => {
|
||||||
|
log::info!("Auto-cleaned {} old backup(s) (retention: {} days)", removed, retention_days);
|
||||||
|
}
|
||||||
|
Err(e) => log::warn!("Backup auto-cleanup failed: {}", e),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run background security scan and notify if auto-security-scan is enabled
|
||||||
|
let settings_sec = self.settings().clone();
|
||||||
|
if settings_sec.boolean("auto-security-scan") {
|
||||||
|
let threshold = settings_sec.string("security-notification-threshold").to_string();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let _ = gio::spawn_blocking(move || {
|
||||||
|
let bg_db = Database::open().expect("Failed to open database");
|
||||||
|
let notifications = notification::scan_and_notify(&bg_db, &threshold);
|
||||||
|
for n in ¬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
|
// Check for orphaned desktop entries in the background
|
||||||
let toast_overlay = self.imp().toast_overlay.get().unwrap().clone();
|
let toast_overlay = self.imp().toast_overlay.get().unwrap().clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
@@ -989,6 +1027,10 @@ impl DriftwoodWindow {
|
|||||||
toast_overlay.add_toast(adw::Toast::new(&msg));
|
toast_overlay.add_toast(adw::Toast::new(&msg));
|
||||||
|
|
||||||
// Phase 2: Background analysis per file with debounced UI refresh
|
// Phase 2: Background analysis per file with debounced UI refresh
|
||||||
|
let running = analysis::running_count();
|
||||||
|
if running > 0 {
|
||||||
|
log::info!("Analyzing {} AppImage(s) in background ({} already running)", needs_analysis.len(), running);
|
||||||
|
}
|
||||||
if !needs_analysis.is_empty() {
|
if !needs_analysis.is_empty() {
|
||||||
let pending = Rc::new(std::cell::Cell::new(needs_analysis.len()));
|
let pending = Rc::new(std::cell::Cell::new(needs_analysis.len()));
|
||||||
let refresh_timer: Rc<std::cell::Cell<Option<glib::SourceId>>> =
|
let refresh_timer: Rc<std::cell::Cell<Option<glib::SourceId>>> =
|
||||||
@@ -1057,21 +1099,28 @@ impl DriftwoodWindow {
|
|||||||
let changed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
let changed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
|
|
||||||
let changed_watcher = changed.clone();
|
let changed_watcher = changed.clone();
|
||||||
let handle = watcher::start_watcher(dirs, move |_event| {
|
let handle = watcher::start_watcher(dirs, move |event| {
|
||||||
|
match &event {
|
||||||
|
watcher::WatchEvent::Changed(paths) => {
|
||||||
|
log::info!("File watcher: {} path(s) changed: {:?}", paths.len(), paths);
|
||||||
|
}
|
||||||
|
}
|
||||||
changed_watcher.store(true, std::sync::atomic::Ordering::Relaxed);
|
changed_watcher.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(h) = handle {
|
if let Some(h) = handle {
|
||||||
self.imp().watcher_handle.replace(Some(h));
|
self.imp().watcher_handle.replace(Some(h));
|
||||||
|
|
||||||
// Poll the flag every second from the main thread
|
// Poll the flag every second from the main thread.
|
||||||
|
// Returns Break when the window is gone to stop the timer.
|
||||||
let window_weak = self.downgrade();
|
let window_weak = self.downgrade();
|
||||||
glib::timeout_add_local(std::time::Duration::from_secs(1), move || {
|
glib::timeout_add_local(std::time::Duration::from_secs(1), move || {
|
||||||
|
let Some(window) = window_weak.upgrade() else {
|
||||||
|
return glib::ControlFlow::Break;
|
||||||
|
};
|
||||||
if changed.swap(false, std::sync::atomic::Ordering::Relaxed) {
|
if changed.swap(false, std::sync::atomic::Ordering::Relaxed) {
|
||||||
if let Some(window) = window_weak.upgrade() {
|
|
||||||
window.trigger_scan();
|
window.trigger_scan();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
glib::ControlFlow::Continue
|
glib::ControlFlow::Continue
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1204,7 +1253,7 @@ impl DriftwoodWindow {
|
|||||||
|
|
||||||
fn save_window_state(&self) {
|
fn save_window_state(&self) {
|
||||||
let settings = self.settings();
|
let settings = self.settings();
|
||||||
let (width, height) = self.default_size();
|
let (width, height) = (self.width(), self.height());
|
||||||
settings
|
settings
|
||||||
.set_int("window-width", width)
|
.set_int("window-width", width)
|
||||||
.expect("Failed to save window width");
|
.expect("Failed to save window width");
|
||||||
|
|||||||
Reference in New Issue
Block a user