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:
423
src/window.rs
423
src/window.rs
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user