Files
driftwood/src/window.rs
lashman e9343da249 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.
2026-02-27 22:08:53 +02:00

1280 lines
53 KiB
Rust

use adw::prelude::*;
use adw::subclass::prelude::*;
use gtk::gio;
use std::cell::OnceCell;
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::integrator;
use crate::core::launcher;
use crate::core::notification;
use crate::core::orphan;
use crate::core::security;
use crate::core::updater;
use crate::core::watcher;
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;
use crate::ui::security_report;
use crate::ui::update_dialog;
use crate::ui::widgets;
mod imp {
use super::*;
pub struct DriftwoodWindow {
pub settings: OnceCell<gio::Settings>,
pub toast_overlay: OnceCell<adw::ToastOverlay>,
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>,
pub watcher_handle: std::cell::RefCell<Option<notify::RecommendedWatcher>>,
}
impl Default for DriftwoodWindow {
fn default() -> Self {
Self {
settings: OnceCell::new(),
toast_overlay: OnceCell::new(),
navigation_view: OnceCell::new(),
library_view: OnceCell::new(),
database: OnceCell::new(),
drop_overlay: OnceCell::new(),
drop_revealer: OnceCell::new(),
watcher_handle: std::cell::RefCell::new(None),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for DriftwoodWindow {
const NAME: &'static str = "DriftwoodWindow";
type Type = super::DriftwoodWindow;
type ParentType = adw::ApplicationWindow;
}
impl ObjectImpl for DriftwoodWindow {
fn constructed(&self) {
self.parent_constructed();
let window = self.obj();
window.setup_settings();
window.setup_database();
window.setup_ui();
window.restore_window_state();
window.load_initial_data();
}
}
impl WidgetImpl for DriftwoodWindow {}
impl WindowImpl for DriftwoodWindow {
fn close_request(&self) -> glib::Propagation {
self.obj().save_window_state();
self.parent_close_request()
}
}
impl ApplicationWindowImpl for DriftwoodWindow {}
impl AdwApplicationWindowImpl for DriftwoodWindow {}
}
glib::wrapper! {
pub struct DriftwoodWindow(ObjectSubclass<imp::DriftwoodWindow>)
@extends adw::ApplicationWindow, gtk::ApplicationWindow, gtk::Window, gtk::Widget,
@implements gio::ActionGroup, gio::ActionMap,
gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget,
gtk::Native, gtk::Root, gtk::ShortcutManager;
}
fn shortcut_row(accel: &str, description: &str) -> adw::ActionRow {
let row = adw::ActionRow::builder()
.title(description)
.build();
let accel_label = gtk::Label::builder()
.label(accel)
.css_classes(["monospace", "dimmed"])
.valign(gtk::Align::Center)
.build();
row.add_suffix(&accel_label);
row
}
impl DriftwoodWindow {
pub fn new(app: &crate::application::DriftwoodApplication) -> Self {
glib::Object::builder()
.property("application", app)
.build()
}
fn setup_settings(&self) {
let settings = gio::Settings::new(APP_ID);
self.imp()
.settings
.set(settings)
.expect("Settings already initialized");
}
fn settings(&self) -> &gio::Settings {
self.imp().settings.get().expect("Settings not initialized")
}
fn setup_database(&self) {
let db = Database::open().expect("Failed to open database");
if self.imp().database.set(Rc::new(db)).is_err() {
panic!("Database already initialized");
}
}
fn database(&self) -> &Rc<Database> {
self.imp().database.get().expect("Database not initialized")
}
fn setup_ui(&self) {
// Build the hamburger menu model
let menu = gio::Menu::new();
menu.append(Some(&i18n("Dashboard")), Some("win.dashboard"));
menu.append(Some(&i18n("Preferences")), Some("win.preferences"));
let section2 = gio::Menu::new();
section2.append(Some(&i18n("Scan for AppImages")), Some("win.scan"));
section2.append(Some(&i18n("Check for Updates")), Some("win.check-updates"));
section2.append(Some(&i18n("Find Duplicates")), Some("win.find-duplicates"));
section2.append(Some(&i18n("Security Report")), Some("win.security-report"));
section2.append(Some(&i18n("Disk Cleanup")), Some("win.cleanup"));
menu.append_section(None, &section2);
let section3 = gio::Menu::new();
section3.append(Some(&i18n("Keyboard Shortcuts")), Some("win.show-shortcuts"));
section3.append(Some(&i18n("About Driftwood")), Some("app.about"));
menu.append_section(None, &section3);
// Library view (contains header bar, search, grid/list, empty state)
let library_view = LibraryView::new(&menu);
// Navigation view
let navigation_view = adw::NavigationView::new();
navigation_view.push(&library_view.page);
// 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(&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));
// Wire up card/row activation to push detail view
{
let nav = navigation_view.clone();
let db = self.database().clone();
library_view.connect_grid_activated(move |record_id| {
if let Ok(Some(record)) = db.get_appimage_by_id(record_id) {
let page = detail_view::build_detail_page(&record, &db);
nav.push(&page);
}
});
}
{
let nav = navigation_view.clone();
let db = self.database().clone();
library_view.connect_list_activated(move |record_id| {
if let Ok(Some(record)) = db.get_appimage_by_id(record_id) {
let page = detail_view::build_detail_page(&record, &db);
nav.push(&page);
}
});
}
// Refresh library view when navigating back from detail
// (in case integration or other state changed)
{
let db = self.database().clone();
let window_weak = self.downgrade();
navigation_view.connect_popped(move |_nav, page| {
if page.tag().as_deref() == Some("detail") {
if let Some(window) = window_weak.upgrade() {
// Update window title for accessibility (WCAG 2.4.8)
window.set_title(Some("Driftwood"));
let lib_view = window.imp().library_view.get().unwrap();
match db.get_all_appimages() {
Ok(records) => lib_view.populate(records),
Err(_) => lib_view.set_state(LibraryState::Empty),
}
}
}
});
}
// Update window title when navigating to sub-pages (WCAG 2.4.8 Location)
{
let window_weak = self.downgrade();
navigation_view.connect_pushed(move |nav| {
if let Some(window) = window_weak.upgrade() {
if let Some(page) = nav.visible_page() {
let page_title = page.title();
if !page_title.is_empty() {
window.set_title(Some(&format!("Driftwood - {}", page_title)));
}
}
}
});
}
// 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)
.expect("ToastOverlay already set");
self.imp()
.navigation_view
.set(navigation_view)
.expect("NavigationView already set");
if self.imp().library_view.set(library_view).is_err() {
panic!("LibraryView already set");
}
// Set up window actions
self.setup_window_actions();
}
fn setup_window_actions(&self) {
let dashboard_action = gio::ActionEntry::builder("dashboard")
.activate(|window: &Self, _, _| {
let db = window.database().clone();
let nav = window.imp().navigation_view.get().unwrap();
let page = dashboard::build_dashboard_page(&db);
nav.push(&page);
})
.build();
// Preferences action
let preferences_action = gio::ActionEntry::builder("preferences")
.activate(|window: &Self, _, _| {
preferences::show_preferences_dialog(window);
})
.build();
// Scan action - runs real scan
let scan_action = gio::ActionEntry::builder("scan")
.activate(|window: &Self, _, _| {
window.trigger_scan();
})
.build();
// Clean orphans action
let clean_toast = self.imp().toast_overlay.get().unwrap().clone();
let clean_action = gio::ActionEntry::builder("clean-orphans")
.activate(move |_window: &Self, _, _| {
let toast_ref = clean_toast.clone();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(|| {
orphan::clean_all_orphans()
})
.await;
match result {
Ok(Ok(summary)) => {
let msg = format!(
"{} {} {}, {} {}",
i18n("Cleaned"),
summary.entries_removed,
i18n("desktop entries"),
summary.icons_removed,
i18n("icons"),
);
toast_ref.add_toast(adw::Toast::new(&msg));
}
_ => {
toast_ref.add_toast(adw::Toast::new(&i18n("Failed to clean orphaned entries")));
}
}
});
})
.build();
// Search action - toggles search bar
let search_action = gio::ActionEntry::builder("search")
.activate(|window: &Self, _, _| {
let lib_view = window.imp().library_view.get().unwrap();
lib_view.toggle_search();
})
.build();
// Check for updates action
let updates_toast = self.imp().toast_overlay.get().unwrap().clone();
let check_updates_action = gio::ActionEntry::builder("check-updates")
.activate(move |_window: &Self, _, _| {
let toast_ref = updates_toast.clone();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || {
let bg_db = Database::open().expect("Failed to open database");
update_dialog::batch_check_updates(&bg_db)
})
.await;
match result {
Ok(0) => {
toast_ref.add_toast(adw::Toast::new(&i18n("All AppImages are up to date")));
}
Ok(n) => {
let msg = format!("{} update{} available", n, if n == 1 { "" } else { "s" });
toast_ref.add_toast(adw::Toast::new(&msg));
}
Err(_) => {
toast_ref.add_toast(adw::Toast::new(&i18n("Failed to check for updates")));
}
}
});
})
.build();
// Find duplicates action
let find_duplicates_action = gio::ActionEntry::builder("find-duplicates")
.activate(|window: &Self, _, _| {
let db = window.database().clone();
let toast_overlay = window.imp().toast_overlay.get().unwrap();
duplicate_dialog::show_duplicate_dialog(window, &db, toast_overlay);
})
.build();
// Security report action
let security_report_action = gio::ActionEntry::builder("security-report")
.activate(|window: &Self, _, _| {
let db = window.database().clone();
let nav = window.imp().navigation_view.get().unwrap();
let page = security_report::build_security_report_page(&db);
nav.push(&page);
})
.build();
// Disk cleanup wizard action
let cleanup_action = gio::ActionEntry::builder("cleanup")
.activate(|window: &Self, _, _| {
let db = window.database().clone();
cleanup_wizard::show_cleanup_wizard(window, &db);
})
.build();
// Show keyboard shortcuts dialog
let shortcuts_action = gio::ActionEntry::builder("show-shortcuts")
.activate(|window: &Self, _, _| {
window.show_shortcuts_dialog();
})
.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,
scan_action,
clean_action,
search_action,
check_updates_action,
find_duplicates_action,
security_report_action,
cleanup_action,
shortcuts_action,
show_drop_hint_action,
]);
// --- Context menu actions (parameterized with record ID) ---
let param_type = Some(glib::VariantTy::INT64);
// Launch action
let launch_action = gio::SimpleAction::new("launch-appimage", param_type);
{
let window_weak = self.downgrade();
launch_action.connect_activate(move |_, param| {
let Some(window) = window_weak.upgrade() 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 window_ref = window.clone();
let (path_str, app_name, launch_args_raw) = {
let db = window.database();
match db.get_appimage_by_id(record_id) {
Ok(Some(r)) => {
let name = r.app_name.clone().unwrap_or_else(|| r.filename.clone());
(r.path.clone(), name, r.launch_args.clone())
}
_ => return,
}
};
let launch_args = launcher::parse_launch_args(launch_args_raw.as_deref());
glib::spawn_future_local(async move {
let path_bg = path_str.clone();
let result = gio::spawn_blocking(move || {
let bg_db = crate::core::database::Database::open().expect("DB open");
let appimage_path = std::path::Path::new(&path_bg);
launcher::launch_appimage(&bg_db, record_id, appimage_path, "gui_context", &launch_args, &[])
}).await;
match result {
Ok(launcher::LaunchResult::Started { child, method }) => {
log::info!("Launched: {} (PID: {}, method: {})", path_str, child.id(), method.as_str());
}
Ok(launcher::LaunchResult::Crashed { exit_code, stderr, method }) => {
log::error!("App crashed (exit {}, method: {}): {}", exit_code.unwrap_or(-1), method.as_str(), stderr);
widgets::show_crash_dialog(&window_ref, &app_name, exit_code, &stderr);
}
Ok(launcher::LaunchResult::Failed(msg)) => {
log::error!("Failed to launch: {}", msg);
let toast = adw::Toast::builder()
.title(&format!("Could not launch: {}", msg))
.timeout(5)
.build();
toast_overlay.add_toast(toast);
}
Err(_) => {
log::error!("Launch task panicked");
}
}
});
});
}
self.add_action(&launch_action);
// Check for updates action (per-app)
let check_update_action = gio::SimpleAction::new("check-update", param_type);
{
let window_weak = self.downgrade();
check_update_action.connect_activate(move |_, param| {
let Some(window) = window_weak.upgrade() 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();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || {
let bg_db = Database::open().expect("DB open failed");
if let Ok(Some(record)) = bg_db.get_appimage_by_id(record_id) {
let appimage_path = std::path::Path::new(&record.path);
if !appimage_path.exists() {
return false;
}
let (_type_label, raw_info, check_result) = updater::check_appimage_for_update(
appimage_path,
record.app_version.as_deref(),
);
if raw_info.is_some() {
bg_db.update_update_info(record_id, raw_info.as_deref(), None).ok();
}
if let Some(result) = check_result {
if result.update_available {
if let Some(ref version) = result.latest_version {
bg_db.set_update_available(record_id, Some(version), result.download_url.as_deref()).ok();
return true;
}
} else {
bg_db.clear_update_available(record_id).ok();
}
}
}
false
}).await;
match result {
Ok(true) => toast_overlay.add_toast(adw::Toast::new("Update available!")),
Ok(false) => toast_overlay.add_toast(adw::Toast::new("Already up to date")),
Err(_) => toast_overlay.add_toast(adw::Toast::new("Update check failed")),
}
});
});
}
self.add_action(&check_update_action);
// Scan for vulnerabilities (per-app)
let scan_security_action = gio::SimpleAction::new("scan-security", param_type);
{
let window_weak = self.downgrade();
scan_security_action.connect_activate(move |_, param| {
let Some(window) = window_weak.upgrade() 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();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || {
let bg_db = Database::open().expect("DB open failed");
if let Ok(Some(record)) = bg_db.get_appimage_by_id(record_id) {
let appimage_path = std::path::Path::new(&record.path);
let scan_result = security::scan_and_store(&bg_db, record_id, appimage_path);
return Some(scan_result.total_cves());
}
None
}).await;
match result {
Ok(Some(total)) => {
if total == 0 {
toast_overlay.add_toast(adw::Toast::new("No vulnerabilities found"));
} else {
let msg = format!("Found {} CVE{}", total, if total == 1 { "" } else { "s" });
toast_overlay.add_toast(adw::Toast::new(&msg));
}
// Send desktop notifications for new CVE findings if enabled
let settings = gio::Settings::new(APP_ID);
if settings.boolean("security-notifications") {
let threshold = settings.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");
notification::check_and_notify(&bg_db, &threshold);
})
.await;
});
}
}
_ => toast_overlay.add_toast(adw::Toast::new("Security scan failed")),
}
});
});
}
self.add_action(&scan_security_action);
// Toggle integration
let toggle_integration_action = gio::SimpleAction::new("toggle-integration", param_type);
{
let window_weak = self.downgrade();
toggle_integration_action.connect_activate(move |_, param| {
let Some(window) = window_weak.upgrade() else { return };
let Some(record_id) = param.and_then(|p| p.get::<i64>()) else { return };
let db = window.database().clone();
let toast_overlay = window.imp().toast_overlay.get().unwrap().clone();
if let Ok(Some(record)) = db.get_appimage_by_id(record_id) {
if record.integrated {
integrator::remove_integration(&record).ok();
db.set_integrated(record_id, false, None).ok();
toast_overlay.add_toast(adw::Toast::new("Integration removed"));
} else {
match integrator::integrate(&record) {
Ok(result) => {
let desktop_path = result.desktop_file_path.to_string_lossy().to_string();
db.set_integrated(record_id, true, Some(&desktop_path)).ok();
toast_overlay.add_toast(adw::Toast::new("Integrated into desktop menu"));
}
Err(e) => {
log::error!("Integration failed: {}", e);
toast_overlay.add_toast(adw::Toast::new("Integration failed"));
}
}
}
// Refresh library view
let lib_view = window.imp().library_view.get().unwrap();
match db.get_all_appimages() {
Ok(records) => lib_view.populate(records),
Err(_) => {}
}
}
});
}
self.add_action(&toggle_integration_action);
// Open containing folder
let open_folder_action = gio::SimpleAction::new("open-folder", param_type);
{
let window_weak = self.downgrade();
open_folder_action.connect_activate(move |_, param| {
let Some(window) = window_weak.upgrade() else { return };
let Some(record_id) = param.and_then(|p| p.get::<i64>()) else { return };
let db = window.database().clone();
if let Ok(Some(record)) = db.get_appimage_by_id(record_id) {
let file = gio::File::for_path(&record.path);
let file_launcher = gtk::FileLauncher::new(Some(&file));
file_launcher.open_containing_folder(gtk::Window::NONE, None::<&gio::Cancellable>, |_| {});
}
});
}
self.add_action(&open_folder_action);
// Copy path to clipboard
let copy_path_action = gio::SimpleAction::new("copy-path", param_type);
{
let window_weak = self.downgrade();
copy_path_action.connect_activate(move |_, param| {
let Some(window) = window_weak.upgrade() else { return };
let Some(record_id) = param.and_then(|p| p.get::<i64>()) else { return };
let db = window.database().clone();
let toast_overlay = window.imp().toast_overlay.get().unwrap().clone();
if let Ok(Some(record)) = db.get_appimage_by_id(record_id) {
let display = gtk::prelude::WidgetExt::display(&window);
let clipboard = display.clipboard();
clipboard.set_text(&record.path);
toast_overlay.add_toast(adw::Toast::new("Path copied to clipboard"));
}
});
}
self.add_action(&copy_path_action);
// Keyboard shortcuts
if let Some(app) = self.application() {
let gtk_app = app.downcast_ref::<gtk::Application>().unwrap();
gtk_app.set_accels_for_action("win.scan", &["<Control>r", "F5"]);
gtk_app.set_accels_for_action("win.search", &["<Control>f"]);
gtk_app.set_accels_for_action("win.preferences", &["<Control>comma"]);
gtk_app.set_accels_for_action("win.dashboard", &["<Control>d"]);
gtk_app.set_accels_for_action("win.check-updates", &["<Control>u"]);
gtk_app.set_accels_for_action("win.show-shortcuts", &["<Control>question"]);
}
}
fn load_initial_data(&self) {
let db = self.database();
let library_view = self.imp().library_view.get().unwrap();
match db.get_all_appimages() {
Ok(records) if !records.is_empty() => {
library_view.populate(records);
}
_ => {
// Empty database - show empty state
library_view.set_state(LibraryState::Empty);
}
}
// Scan on startup if enabled in preferences
if self.settings().boolean("auto-scan-on-startup") {
self.trigger_scan();
}
// Start watching scan directories for new AppImage files
self.start_file_watcher();
// Auto-cleanup old backups based on retention setting
let retention_days = self.settings().int("backup-retention-days") as u32;
glib::spawn_future_local(async move {
let _ = gio::spawn_blocking(move || {
let bg_db = Database::open().expect("Failed to open database");
match crate::core::backup::auto_cleanup_old_backups(&bg_db, retention_days) {
Ok(removed) if removed > 0 => {
log::info!("Auto-cleaned {} old backup(s) (retention: {} days)", removed, retention_days);
}
Err(e) => log::warn!("Backup auto-cleanup failed: {}", e),
_ => {}
}
})
.await;
});
// Run background security scan and notify if auto-security-scan is enabled
let settings_sec = self.settings().clone();
if settings_sec.boolean("auto-security-scan") {
let threshold = settings_sec.string("security-notification-threshold").to_string();
glib::spawn_future_local(async move {
let _ = gio::spawn_blocking(move || {
let bg_db = Database::open().expect("Failed to open database");
let notifications = notification::scan_and_notify(&bg_db, &threshold);
for n in &notifications {
log::info!(
"CVE notification sent: app={} (id={}), severity={}, count={}",
n.app_name, n.appimage_id, n.severity, n.cve_count,
);
}
})
.await;
});
}
// Check for orphaned desktop entries in the background
let toast_overlay = self.imp().toast_overlay.get().unwrap().clone();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(|| {
orphan::detect_orphans().len()
})
.await;
if let Ok(count) = result {
if count > 0 {
let msg = ni18n_f(
"{} orphaned desktop entry found. Use 'Clean' to remove it.",
"{} orphaned desktop entries found. Use 'Clean' to remove them.",
count as u32,
&[("{}", &count.to_string())],
);
let toast = adw::Toast::builder()
.title(&msg)
.timeout(5)
.button_label(&i18n("Clean"))
.action_name("win.clean-orphans")
.build();
toast_overlay.add_toast(toast);
}
}
});
}
fn trigger_scan(&self) {
let library_view = self.imp().library_view.get().unwrap();
library_view.set_state(LibraryState::Loading);
let settings = self.settings();
let mut dirs: Vec<String> = settings
.strv("scan-directories")
.iter()
.map(|s| s.to_string())
.collect();
// Include system-wide AppImage directory if it exists
let system_dir = crate::config::SYSTEM_APPIMAGE_DIR;
if std::path::Path::new(system_dir).is_dir() && !dirs.iter().any(|d| d == system_dir) {
dirs.push(system_dir.to_string());
}
let toast_overlay = self.imp().toast_overlay.get().unwrap().clone();
let window_weak = self.downgrade();
// 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);
let mut new_count = 0i32;
let total = discovered.len() as i32;
// Clean stale DB records for files that no longer exist
let removed = bg_db.remove_missing_appimages().unwrap_or_default();
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
.get_appimage_by_path(&d.path.to_string_lossy())
.ok()
.flatten();
let modified = d.modified_time
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|dur| {
chrono::DateTime::from_timestamp(dur.as_secs() as i64, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
});
// 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 analysis_done = ex.analysis_status.as_deref() == Some("complete");
let has_icon = ex.icon_path.is_some();
if size_unchanged && mtime_unchanged && analysis_done && has_icon {
skipped_count += 1;
continue;
}
}
let id = bg_db.upsert_appimage(
&d.path.to_string_lossy(),
&d.filename,
Some(d.appimage_type.as_i32()),
d.size_bytes as i64,
d.is_executable,
modified.as_deref(),
).unwrap_or(0);
if existing.is_none() {
new_count += 1;
}
// 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 phase 1: {} files, {} new, {} removed, {} skipped (unchanged), took {}ms",
total, new_count, removed_count, skipped_count, start.elapsed().as_millis()
);
let duration = start.elapsed().as_millis() as i64;
bg_db.log_scan(
"manual",
&dirs.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
total,
new_count,
removed_count,
duration,
).ok();
(total, new_count, needs_analysis)
})
.await;
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();
match db.get_all_appimages() {
Ok(records) => lib_view.populate(records),
Err(_) => lib_view.set_state(LibraryState::Empty),
}
}
let msg = match new_count {
0 if total == 0 => i18n("No AppImages found"),
0 => format!("{} {}", total, i18n("AppImages up to date")),
1 => i18n("Found 1 new AppImage"),
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
let running = analysis::running_count();
if running > 0 {
log::info!("Analyzing {} AppImage(s) in background ({} already running)", needs_analysis.len(), running);
}
if !needs_analysis.is_empty() {
let pending = Rc::new(std::cell::Cell::new(needs_analysis.len()));
let refresh_timer: Rc<std::cell::Cell<Option<glib::SourceId>>> =
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 refresh_timer_clear = refresh_timer.clone();
let timer_id = glib::timeout_add_local_once(
std::time::Duration::from_millis(300),
move || {
// Clear the stored SourceId so nobody tries to remove a fired timer
refresh_timer_clear.set(None);
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));
});
}
}
}
});
}
fn start_file_watcher(&self) {
let settings = self.settings();
let dirs: Vec<std::path::PathBuf> = settings
.strv("scan-directories")
.iter()
.map(|s| discovery::expand_tilde(&s.to_string()))
.collect();
if dirs.is_empty() {
return;
}
// Use an atomic flag to communicate across the thread boundary.
// The watcher callback (on a background thread) sets the flag,
// and a glib timer on the main thread polls and dispatches.
let changed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let changed_watcher = changed.clone();
let handle = watcher::start_watcher(dirs, move |event| {
match &event {
watcher::WatchEvent::Changed(paths) => {
log::info!("File watcher: {} path(s) changed: {:?}", paths.len(), paths);
}
}
changed_watcher.store(true, std::sync::atomic::Ordering::Relaxed);
});
if let Some(h) = handle {
self.imp().watcher_handle.replace(Some(h));
// Poll the flag every second from the main thread.
// Returns Break when the window is gone to stop the timer.
let window_weak = self.downgrade();
glib::timeout_add_local(std::time::Duration::from_secs(1), move || {
let Some(window) = window_weak.upgrade() else {
return glib::ControlFlow::Break;
};
if changed.swap(false, std::sync::atomic::Ordering::Relaxed) {
window.trigger_scan();
}
glib::ControlFlow::Continue
});
}
}
fn show_shortcuts_dialog(&self) {
let dialog = adw::Dialog::builder()
.title("Keyboard Shortcuts")
.content_width(400)
.content_height(420)
.build();
let toolbar = adw::ToolbarView::new();
let header = adw::HeaderBar::new();
toolbar.add_top_bar(&header);
let scrolled = gtk::ScrolledWindow::builder()
.vexpand(true)
.build();
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(18)
.margin_top(18)
.margin_bottom(18)
.margin_start(18)
.margin_end(18)
.build();
// Navigation group
let nav_group = adw::PreferencesGroup::builder()
.title("Navigation")
.build();
nav_group.add(&shortcut_row("Ctrl+F", "Search"));
nav_group.add(&shortcut_row("Ctrl+D", "Dashboard"));
nav_group.add(&shortcut_row("Ctrl+,", "Preferences"));
content.append(&nav_group);
// Actions group
let actions_group = adw::PreferencesGroup::builder()
.title("Actions")
.build();
actions_group.add(&shortcut_row("Ctrl+R / F5", "Scan for AppImages"));
actions_group.add(&shortcut_row("Ctrl+U", "Check for updates"));
content.append(&actions_group);
// Application group
let app_group = adw::PreferencesGroup::builder()
.title("Application")
.build();
app_group.add(&shortcut_row("Ctrl+?", "Keyboard shortcuts"));
app_group.add(&shortcut_row("Ctrl+Q", "Quit"));
content.append(&app_group);
scrolled.set_child(Some(&content));
toolbar.set_content(Some(&scrolled));
dialog.set_child(Some(&toolbar));
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.width(), self.height());
settings
.set_int("window-width", width)
.expect("Failed to save window width");
settings
.set_int("window-height", height)
.expect("Failed to save window height");
settings
.set_boolean("window-maximized", self.is_maximized())
.expect("Failed to save maximized state");
}
fn restore_window_state(&self) {
let settings = self.settings();
let width = settings.int("window-width");
let height = settings.int("window-height");
let maximized = settings.boolean("window-maximized");
self.set_default_size(width, height);
if maximized {
self.maximize();
}
}
}