Add Phase 5 enhancements: security, i18n, analysis, backup, notifications

- Database v8 migration: tags, pinned, avg_startup_ms columns
- Security scanning with CVE matching and batch scan
- Bundled library extraction and vulnerability reports
- Desktop notification system for security alerts
- Backup/restore system for AppImage configurations
- i18n framework with gettext support
- Runtime analysis and Wayland compatibility detection
- AppStream metadata and Flatpak-style build support
- File watcher module for live directory monitoring
- Preferences panel with GSettings integration
- CLI interface for headless operation
- Detail view: tabbed layout with ViewSwitcher in title bar,
  health score, sandbox controls, changelog links
- Library view: sort dropdown, context menu enhancements
- Dashboard: system status, disk usage, launch history
- Security report page with scan and export
- Packaging: meson build, PKGBUILD, metainfo
This commit is contained in:
lashman
2026-02-27 17:16:41 +02:00
parent a7ed3742fb
commit 423323d5a9
51 changed files with 10583 additions and 481 deletions

View File

@@ -6,20 +6,19 @@ use std::rc::Rc;
use std::time::Instant;
use crate::config::APP_ID;
use crate::core::analysis;
use crate::core::database::Database;
use crate::core::discovery;
use crate::core::fuse;
use crate::core::inspector;
use crate::core::integrator;
use crate::core::launcher;
use crate::core::orphan;
use crate::core::security;
use crate::core::updater;
use crate::core::wayland;
use crate::i18n::{i18n, ni18n_f};
use crate::ui::cleanup_wizard;
use crate::ui::dashboard;
use crate::ui::detail_view;
use crate::ui::drop_dialog;
use crate::ui::duplicate_dialog;
use crate::ui::library_view::{LibraryState, LibraryView};
use crate::ui::preferences;
@@ -35,6 +34,8 @@ mod imp {
pub navigation_view: OnceCell<adw::NavigationView>,
pub library_view: OnceCell<LibraryView>,
pub database: OnceCell<Rc<Database>>,
pub drop_overlay: OnceCell<gtk::Box>,
pub drop_revealer: OnceCell<gtk::Revealer>,
}
impl Default for DriftwoodWindow {
@@ -45,6 +46,8 @@ mod imp {
navigation_view: OnceCell::new(),
library_view: OnceCell::new(),
database: OnceCell::new(),
drop_overlay: OnceCell::new(),
drop_revealer: OnceCell::new(),
}
}
}
@@ -156,9 +159,188 @@ impl DriftwoodWindow {
let navigation_view = adw::NavigationView::new();
navigation_view.push(&library_view.page);
// Toast overlay wraps everything
// Drop overlay - centered opaque card over a dimmed scrim
let drop_overlay_icon = gtk::Image::builder()
.icon_name("document-open-symbolic")
.pixel_size(64)
.halign(gtk::Align::Center)
.build();
drop_overlay_icon.add_css_class("drop-zone-icon");
let drop_overlay_title = gtk::Label::builder()
.label(&i18n("Add AppImage"))
.css_classes(["title-1"])
.halign(gtk::Align::Center)
.build();
let drop_overlay_subtitle = gtk::Label::builder()
.label(&i18n("Drop a file here or click to browse"))
.css_classes(["body", "dimmed"])
.halign(gtk::Align::Center)
.build();
// The card itself - acts as a clickable button to open file picker
let drop_zone_card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(16)
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.width_request(320)
.build();
drop_zone_card.add_css_class("drop-zone-card");
drop_zone_card.set_cursor_from_name(Some("pointer"));
drop_zone_card.append(&drop_overlay_icon);
drop_zone_card.append(&drop_overlay_title);
drop_zone_card.append(&drop_overlay_subtitle);
// Click on the card opens file picker (stop propagation so scrim doesn't dismiss)
{
let window_weak = self.downgrade();
let card_click = gtk::GestureClick::new();
card_click.connect_pressed(move |gesture, _, _, _| {
gesture.set_state(gtk::EventSequenceState::Claimed);
let Some(window) = window_weak.upgrade() else { return };
window.open_file_picker();
});
drop_zone_card.add_controller(card_click);
}
// Revealer for crossfade animation
let drop_revealer = gtk::Revealer::builder()
.transition_type(gtk::RevealerTransitionType::Crossfade)
.transition_duration(200)
.reveal_child(false)
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.vexpand(true)
.hexpand(true)
.build();
drop_revealer.set_child(Some(&drop_zone_card));
// Scrim (dimmed background) that fills the whole window
let drop_overlay_content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.halign(gtk::Align::Fill)
.valign(gtk::Align::Fill)
.hexpand(true)
.vexpand(true)
.build();
drop_overlay_content.add_css_class("drop-overlay-scrim");
drop_overlay_content.append(&drop_revealer);
drop_overlay_content.set_visible(false);
// Click on scrim (outside the card) to dismiss
{
let overlay_ref = drop_overlay_content.clone();
let revealer_ref = drop_revealer.clone();
let click = gtk::GestureClick::new();
click.connect_pressed(move |gesture, _, _, _| {
gesture.set_state(gtk::EventSequenceState::Claimed);
revealer_ref.set_reveal_child(false);
let overlay_hide = overlay_ref.clone();
glib::timeout_add_local_once(
std::time::Duration::from_millis(200),
move || { overlay_hide.set_visible(false); },
);
});
drop_overlay_content.add_controller(click);
}
// Overlay wraps navigation view so the drop indicator sits on top
let overlay = gtk::Overlay::new();
overlay.set_child(Some(&navigation_view));
overlay.add_overlay(&drop_overlay_content);
// Toast overlay wraps the overlay
let toast_overlay = adw::ToastOverlay::new();
toast_overlay.set_child(Some(&navigation_view));
toast_overlay.set_child(Some(&overlay));
// --- Drag-and-drop support ---
let drop_target = gtk::DropTarget::new(gio::File::static_type(), gtk::gdk::DragAction::COPY);
// Show overlay on drag enter
{
let drop_indicator = drop_overlay_content.clone();
let revealer_ref = drop_revealer.clone();
drop_target.connect_enter(move |_target, _x, _y| {
drop_indicator.set_visible(true);
revealer_ref.set_reveal_child(true);
gtk::gdk::DragAction::COPY
});
}
// Hide overlay on drag leave
{
let drop_indicator = drop_overlay_content.clone();
let revealer_ref = drop_revealer.clone();
drop_target.connect_leave(move |_target| {
revealer_ref.set_reveal_child(false);
let overlay_hide = drop_indicator.clone();
glib::timeout_add_local_once(
std::time::Duration::from_millis(200),
move || { overlay_hide.set_visible(false); },
);
});
}
// Handle the drop
{
let drop_indicator = drop_overlay_content.clone();
let revealer_ref = drop_revealer.clone();
let toast_ref = toast_overlay.clone();
let window_weak = self.downgrade();
drop_target.connect_drop(move |_target, value, _x, _y| {
revealer_ref.set_reveal_child(false);
let overlay_hide = drop_indicator.clone();
glib::timeout_add_local_once(
std::time::Duration::from_millis(200),
move || { overlay_hide.set_visible(false); },
);
let file = match value.get::<gio::File>() {
Ok(f) => f,
Err(_) => return false,
};
let path = match file.path() {
Some(p) => p,
None => return false,
};
// Validate it's an AppImage via magic bytes
if discovery::detect_appimage(&path).is_none() {
toast_ref.add_toast(adw::Toast::new(&i18n("Not a valid AppImage file")));
return true;
}
let Some(window) = window_weak.upgrade() else {
return false;
};
let db = window.database().clone();
let toast_for_dialog = toast_ref.clone();
let window_weak2 = window.downgrade();
drop_dialog::show_drop_dialog(
&window,
vec![path],
&toast_for_dialog,
move || {
if let Some(win) = window_weak2.upgrade() {
let lib_view = win.imp().library_view.get().unwrap();
match db.get_all_appimages() {
Ok(records) => lib_view.populate(records),
Err(_) => lib_view.set_state(LibraryState::Empty),
}
}
},
);
true
});
}
toast_overlay.add_controller(drop_target);
self.set_content(Some(&toast_overlay));
@@ -221,6 +403,14 @@ impl DriftwoodWindow {
}
// Store references
self.imp()
.drop_overlay
.set(drop_overlay_content)
.expect("DropOverlay already set");
self.imp()
.drop_revealer
.set(drop_revealer)
.expect("DropRevealer already set");
self.imp()
.toast_overlay
.set(toast_overlay)
@@ -362,6 +552,18 @@ impl DriftwoodWindow {
})
.build();
// Show drop overlay hint (triggered by "Add app" button)
let show_drop_hint_action = gio::ActionEntry::builder("show-drop-hint")
.activate(|window: &Self, _, _| {
if let Some(overlay) = window.imp().drop_overlay.get() {
overlay.set_visible(true);
if let Some(revealer) = window.imp().drop_revealer.get() {
revealer.set_reveal_child(true);
}
}
})
.build();
self.add_action_entries([
dashboard_action,
preferences_action,
@@ -373,6 +575,7 @@ impl DriftwoodWindow {
security_report_action,
cleanup_action,
shortcuts_action,
show_drop_hint_action,
]);
// --- Context menu actions (parameterized with record ID) ---
@@ -635,17 +838,16 @@ impl DriftwoodWindow {
let toast_overlay = self.imp().toast_overlay.get().unwrap().clone();
let window_weak = self.downgrade();
// Run scan in a background thread (opens its own DB connection),
// then update UI on main thread using the window's DB.
// Two-phase scan:
// Phase 1 (fast): discover files, upsert into DB, mark pending analysis
// Phase 2 (background): heavy analysis per file
glib::spawn_future_local(async move {
// Phase 1: Fast registration
let result = gio::spawn_blocking(move || {
let bg_db = Database::open().expect("Failed to open database for scan");
let start = Instant::now();
let discovered = discovery::scan_directories(&dirs);
// Detect system FUSE status once for all AppImages
let fuse_info = fuse::detect_system_fuse();
let mut new_count = 0i32;
let total = discovered.len() as i32;
@@ -654,6 +856,7 @@ impl DriftwoodWindow {
let removed_count = removed.len() as i32;
let mut skipped_count = 0i32;
let mut needs_analysis: Vec<(i64, std::path::PathBuf, discovery::AppImageType)> = Vec::new();
for d in &discovered {
let existing = bg_db
@@ -668,15 +871,14 @@ impl DriftwoodWindow {
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
});
// Skip re-processing unchanged files (same size + mtime, and all analysis done)
// Skip re-processing unchanged files that are fully analyzed.
// Trust analysis_status as the primary signal - some AppImages
// genuinely don't have app_name or other optional fields.
if let Some(ref ex) = existing {
let size_unchanged = ex.size_bytes == d.size_bytes as i64;
let mtime_unchanged = modified.as_deref() == ex.file_modified.as_deref();
let fully_analyzed = ex.app_name.is_some()
&& ex.fuse_status.is_some()
&& ex.wayland_status.is_some()
&& ex.sha256.is_some();
if size_unchanged && mtime_unchanged && fully_analyzed {
let analysis_done = ex.analysis_status.as_deref() == Some("complete");
if size_unchanged && mtime_unchanged && analysis_done {
skipped_count += 1;
continue;
}
@@ -695,73 +897,13 @@ impl DriftwoodWindow {
new_count += 1;
}
let needs_metadata = existing
.as_ref()
.map(|r| r.app_name.is_none())
.unwrap_or(true);
if needs_metadata {
if let Ok(metadata) = inspector::inspect_appimage(&d.path, &d.appimage_type) {
let categories = if metadata.categories.is_empty() {
None
} else {
Some(metadata.categories.join(";"))
};
bg_db.update_metadata(
id,
metadata.app_name.as_deref(),
metadata.app_version.as_deref(),
metadata.description.as_deref(),
metadata.developer.as_deref(),
categories.as_deref(),
metadata.architecture.as_deref(),
metadata.cached_icon_path.as_ref().map(|p| p.to_string_lossy()).as_deref(),
Some(&metadata.desktop_entry_content),
).ok();
}
}
// Per-AppImage FUSE status
let needs_fuse = existing
.as_ref()
.map(|r| r.fuse_status.is_none())
.unwrap_or(true);
if needs_fuse {
let app_fuse = fuse::determine_app_fuse_status(&fuse_info, &d.path);
bg_db.update_fuse_status(id, app_fuse.as_str()).ok();
}
// Wayland compatibility analysis (slower - only for new/unanalyzed)
let needs_wayland = existing
.as_ref()
.map(|r| r.wayland_status.is_none())
.unwrap_or(true);
if needs_wayland {
let analysis = wayland::analyze_appimage(&d.path);
bg_db.update_wayland_status(id, analysis.status.as_str()).ok();
}
// SHA256 hash (for duplicate detection)
let needs_hash = existing
.as_ref()
.map(|r| r.sha256.is_none())
.unwrap_or(true);
if needs_hash {
if let Ok(hash) = crate::core::discovery::compute_sha256(&d.path) {
bg_db.update_sha256(id, &hash).ok();
}
}
// Discover config/data paths (only for new AppImages)
if existing.is_none() {
if let Ok(Some(rec)) = bg_db.get_appimage_by_id(id) {
crate::core::footprint::discover_and_store(&bg_db, id, &rec);
}
}
// Mark for background analysis
bg_db.update_analysis_status(id, "pending").ok();
needs_analysis.push((id, d.path.clone(), d.appimage_type.clone()));
}
log::info!(
"Scan complete: {} files, {} new, {} removed, {} skipped (unchanged), took {}ms",
"Scan phase 1: {} files, {} new, {} removed, {} skipped (unchanged), took {}ms",
total, new_count, removed_count, skipped_count, start.elapsed().as_millis()
);
@@ -775,12 +917,13 @@ impl DriftwoodWindow {
duration,
).ok();
(total, new_count)
(total, new_count, needs_analysis)
})
.await;
if let Ok((total, new_count)) = result {
// Refresh the library view from the window's main-thread DB
if let Ok((total, new_count, needs_analysis)) = result {
// Refresh the library view immediately (apps appear with "Analyzing..." badge)
let window_weak2 = window_weak.clone();
if let Some(window) = window_weak.upgrade() {
let db = window.database();
let lib_view = window.imp().library_view.get().unwrap();
@@ -797,6 +940,50 @@ impl DriftwoodWindow {
n => format!("{} {} {}", i18n("Found"), n, i18n("new AppImages")),
};
toast_overlay.add_toast(adw::Toast::new(&msg));
// Phase 2: Background analysis per file with debounced UI refresh
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>>> =
Rc::new(std::cell::Cell::new(None));
for (id, path, appimage_type) in needs_analysis {
let window_weak3 = window_weak2.clone();
let pending = pending.clone();
let refresh_timer = refresh_timer.clone();
glib::spawn_future_local(async move {
let _ = gio::spawn_blocking(move || {
analysis::run_background_analysis(id, path, appimage_type, false);
})
.await;
let remaining = pending.get().saturating_sub(1);
pending.set(remaining);
// Debounced refresh: wait 300ms before refreshing UI
if let Some(source_id) = refresh_timer.take() {
source_id.remove();
}
let window_weak4 = window_weak3.clone();
let timer_id = glib::timeout_add_local_once(
std::time::Duration::from_millis(300),
move || {
if let Some(window) = window_weak4.upgrade() {
let db = window.database();
let lib_view = window.imp().library_view.get().unwrap();
match db.get_all_appimages() {
Ok(records) => lib_view.populate(records),
Err(_) => {}
}
}
},
);
refresh_timer.set(Some(timer_id));
});
}
}
}
});
}
@@ -856,6 +1043,76 @@ impl DriftwoodWindow {
dialog.present(Some(self));
}
fn dismiss_drop_overlay(&self) {
if let Some(revealer) = self.imp().drop_revealer.get() {
revealer.set_reveal_child(false);
}
if let Some(overlay) = self.imp().drop_overlay.get() {
let overlay = overlay.clone();
glib::timeout_add_local_once(
std::time::Duration::from_millis(200),
move || { overlay.set_visible(false); },
);
}
}
fn open_file_picker(&self) {
self.dismiss_drop_overlay();
let filter = gtk::FileFilter::new();
filter.set_name(Some("AppImage files"));
filter.add_pattern("*.AppImage");
filter.add_pattern("*.appimage");
// Also accept any file (AppImages don't always have the extension)
let all_filter = gtk::FileFilter::new();
all_filter.set_name(Some("All files"));
all_filter.add_pattern("*");
let filters = gio::ListStore::new::<gtk::FileFilter>();
filters.append(&filter);
filters.append(&all_filter);
let dialog = gtk::FileDialog::builder()
.title(i18n("Choose an AppImage"))
.filters(&filters)
.default_filter(&filter)
.modal(true)
.build();
let window_weak = self.downgrade();
dialog.open(Some(self), None::<&gio::Cancellable>, move |result| {
let Ok(file) = result else { return };
let Some(path) = file.path() else { return };
let Some(window) = window_weak.upgrade() else { return };
// Validate it's an AppImage via magic bytes
if discovery::detect_appimage(&path).is_none() {
let toast_overlay = window.imp().toast_overlay.get().unwrap();
toast_overlay.add_toast(adw::Toast::new(&i18n("Not a valid AppImage file")));
return;
}
let db = window.database().clone();
let toast_overlay = window.imp().toast_overlay.get().unwrap().clone();
let window_weak2 = window.downgrade();
drop_dialog::show_drop_dialog(
&window,
vec![path],
&toast_overlay,
move || {
if let Some(win) = window_weak2.upgrade() {
let lib_view = win.imp().library_view.get().unwrap();
match db.get_all_appimages() {
Ok(records) => lib_view.populate(records),
Err(_) => lib_view.set_state(LibraryState::Empty),
}
}
},
);
});
}
fn save_window_state(&self) {
let settings = self.settings();
let (width, height) = self.default_size();