- Bring all UI widgets to WCAG 2.2 AAA conformance across all views - Add accessible labels, roles, descriptions, and announcements - Bump focus outlines to 3px, target sizes to 44px AAA minimum - Fix announce()/announce_result() to walk widget tree via parent() - Add AT-SPI accessibility audit script (tools/a11y-audit.py) that checks SC 4.1.2, 1.1.1, 1.3.1, 2.1.1, 2.5.5, 2.5.8, 2.4.8, 2.4.9, 2.4.10, 2.1.3 with JSON report output for CI - Clean up project structure, archive old plan documents
2247 lines
95 KiB
Rust
2247 lines
95 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::backup;
|
|
use crate::core::database::Database;
|
|
use crate::core::discovery;
|
|
use crate::core::footprint;
|
|
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, i18n_f, ni18n_f};
|
|
use crate::ui::catalog_view;
|
|
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::updates_view;
|
|
use crate::ui::widgets;
|
|
|
|
mod imp {
|
|
use super::*;
|
|
|
|
pub struct DriftwoodWindow {
|
|
pub settings: OnceCell<gio::Settings>,
|
|
pub toast_overlay: OnceCell<adw::ToastOverlay>,
|
|
pub view_stack: OnceCell<adw::ViewStack>,
|
|
pub installed_nav: 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>>,
|
|
pub enrichment_banner: OnceCell<gtk::Box>,
|
|
}
|
|
|
|
impl Default for DriftwoodWindow {
|
|
fn default() -> Self {
|
|
Self {
|
|
settings: OnceCell::new(),
|
|
toast_overlay: OnceCell::new(),
|
|
view_stack: OnceCell::new(),
|
|
installed_nav: OnceCell::new(),
|
|
library_view: OnceCell::new(),
|
|
database: OnceCell::new(),
|
|
drop_overlay: OnceCell::new(),
|
|
drop_revealer: OnceCell::new(),
|
|
watcher_handle: std::cell::RefCell::new(None),
|
|
enrichment_banner: OnceCell::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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;
|
|
}
|
|
|
|
/// Find a child widget by its widget name (breadth-first).
|
|
fn find_child_by_name(parent: &impl IsA<gtk::Widget>, name: &str) -> Option<gtk::Widget> {
|
|
let parent_widget = parent.upcast_ref::<gtk::Widget>();
|
|
let mut child = parent_widget.first_child();
|
|
while let Some(c) = child {
|
|
if c.widget_name() == name {
|
|
return Some(c);
|
|
}
|
|
if let Some(found) = find_child_by_name(&c, name) {
|
|
return Some(found);
|
|
}
|
|
child = c.next_sibling();
|
|
}
|
|
None
|
|
}
|
|
|
|
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();
|
|
accel_label.update_property(&[gtk::accessible::Property::Label(
|
|
&format!("Keyboard shortcut: {}", accel),
|
|
)]);
|
|
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) {
|
|
// Set initial window title for screen readers (WCAG 2.4.2)
|
|
self.set_title(Some("Driftwood"));
|
|
|
|
// Build the hamburger menu model (slim - tabs handle catalog/updates/scan)
|
|
let menu = gio::Menu::new();
|
|
menu.append(Some(&i18n("Dashboard")), Some("win.dashboard"));
|
|
|
|
let section2 = gio::Menu::new();
|
|
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"));
|
|
section2.append(Some(&i18n("Export App List")), Some("win.export-app-list"));
|
|
section2.append(Some(&i18n("Import App List")), Some("win.import-app-list"));
|
|
menu.append_section(None, §ion2);
|
|
|
|
let section3 = gio::Menu::new();
|
|
section3.append(Some(&i18n("Preferences")), Some("win.preferences"));
|
|
section3.append(Some(&i18n("Keyboard Shortcuts")), Some("win.show-shortcuts"));
|
|
section3.append(Some(&i18n("About Driftwood")), Some("app.about"));
|
|
menu.append_section(None, §ion3);
|
|
|
|
// Library view (contains header bar, search, grid/list, empty state)
|
|
let library_view = LibraryView::new(&menu);
|
|
|
|
// Installed view: NavigationView for drill-down (detail, dashboard, etc.)
|
|
let installed_nav = adw::NavigationView::new();
|
|
installed_nav.push(&library_view.page);
|
|
|
|
// Catalog view (has its own internal NavigationView for drill-down)
|
|
let (catalog_nav, enrichment_banner) = catalog_view::build_catalog_page(self.database());
|
|
|
|
// Updates view
|
|
let updates_toolbar = updates_view::build_updates_view(self.database());
|
|
|
|
// ViewStack with 3 top-level pages
|
|
let view_stack = adw::ViewStack::new();
|
|
view_stack.set_vexpand(true);
|
|
|
|
let installed_vs_page = view_stack.add_titled(&installed_nav, Some("installed"), &i18n("Installed"));
|
|
installed_vs_page.set_icon_name(Some("view-grid-symbolic"));
|
|
|
|
let catalog_vs_page = view_stack.add_titled(&catalog_nav, Some("catalog"), &i18n("Catalog"));
|
|
catalog_vs_page.set_icon_name(Some("system-software-install-symbolic"));
|
|
|
|
let updates_vs_page = view_stack.add_titled(&updates_toolbar, Some("updates"), &i18n("Updates"));
|
|
updates_vs_page.set_icon_name(Some("software-update-available-symbolic"));
|
|
|
|
// ViewSwitcherBar at the bottom for tab navigation
|
|
let view_switcher_bar = adw::ViewSwitcherBar::builder()
|
|
.stack(&view_stack)
|
|
.reveal(true)
|
|
.build();
|
|
|
|
// Toast overlay wraps only the ViewStack so toasts appear above the tab bar
|
|
let toast_overlay = adw::ToastOverlay::new();
|
|
toast_overlay.set_child(Some(&view_stack));
|
|
|
|
// Main content box: toast-wrapped ViewStack + bottom switcher bar
|
|
let main_box = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.build();
|
|
main_box.append(&toast_overlay);
|
|
main_box.append(&view_switcher_bar);
|
|
|
|
// 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)
|
|
.accessible_role(gtk::AccessibleRole::Presentation)
|
|
.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 an AppImage file (.AppImage) here, or click to browse your files"))
|
|
.css_classes(["body", "dimmed"])
|
|
.halign(gtk::Align::Center)
|
|
.wrap(true)
|
|
.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)
|
|
.focusable(true)
|
|
.accessible_role(gtk::AccessibleRole::Button)
|
|
.build();
|
|
drop_zone_card.add_css_class("drop-zone-card");
|
|
drop_zone_card.set_cursor_from_name(Some("pointer"));
|
|
drop_zone_card.update_property(&[gtk::accessible::Property::Label("Browse files to add an AppImage")]);
|
|
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);
|
|
}
|
|
|
|
// Keyboard activation: Enter/Space on the card opens file picker
|
|
{
|
|
let window_weak = self.downgrade();
|
|
let key_ctrl = gtk::EventControllerKey::new();
|
|
key_ctrl.connect_key_pressed(move |_ctrl, key, _code, _mods| {
|
|
if matches!(key, gtk::gdk::Key::Return | gtk::gdk::Key::KP_Enter | gtk::gdk::Key::space) {
|
|
if let Some(window) = window_weak.upgrade() {
|
|
window.open_file_picker();
|
|
}
|
|
glib::Propagation::Stop
|
|
} else {
|
|
glib::Propagation::Proceed
|
|
}
|
|
});
|
|
drop_zone_card.add_controller(key_ctrl);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
// Escape key dismisses the overlay (WCAG 2.1.1 keyboard access)
|
|
{
|
|
let overlay_ref = drop_overlay_content.clone();
|
|
let revealer_ref = drop_revealer.clone();
|
|
let window_weak = self.downgrade();
|
|
let key_ctrl = gtk::EventControllerKey::new();
|
|
key_ctrl.connect_key_pressed(move |_ctrl, key, _code, _mods| {
|
|
if key == gtk::gdk::Key::Escape && overlay_ref.is_visible() {
|
|
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); },
|
|
);
|
|
// Return focus to main content
|
|
if let Some(window) = window_weak.upgrade() {
|
|
if let Some(vs) = window.imp().view_stack.get() {
|
|
if let Some(child) = vs.visible_child() {
|
|
child.grab_focus();
|
|
}
|
|
}
|
|
}
|
|
glib::Propagation::Stop
|
|
} else {
|
|
glib::Propagation::Proceed
|
|
}
|
|
});
|
|
drop_overlay_content.add_controller(key_ctrl);
|
|
}
|
|
|
|
// Overlay wraps main content so the drop indicator sits on top
|
|
let overlay = gtk::Overlay::new();
|
|
overlay.set_child(Some(&main_box));
|
|
overlay.add_overlay(&drop_overlay_content);
|
|
|
|
// --- 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(widgets::error_toast(&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
|
|
});
|
|
}
|
|
|
|
overlay.add_controller(drop_target);
|
|
|
|
self.set_content(Some(&overlay));
|
|
|
|
// Wire up card/row activation to push detail view (or toggle selection)
|
|
{
|
|
let nav = installed_nav.clone();
|
|
let db = self.database().clone();
|
|
let window_weak = self.downgrade();
|
|
library_view.connect_grid_activated(move |record_id| {
|
|
if let Some(window) = window_weak.upgrade() {
|
|
let lib_view = window.imp().library_view.get().unwrap();
|
|
if lib_view.in_selection_mode() {
|
|
lib_view.toggle_selection(record_id);
|
|
return;
|
|
}
|
|
}
|
|
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 = installed_nav.clone();
|
|
let db = self.database().clone();
|
|
let window_weak = self.downgrade();
|
|
library_view.connect_list_activated(move |record_id| {
|
|
if let Some(window) = window_weak.upgrade() {
|
|
let lib_view = window.imp().library_view.get().unwrap();
|
|
if lib_view.in_selection_mode() {
|
|
lib_view.toggle_selection(record_id);
|
|
return;
|
|
}
|
|
}
|
|
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();
|
|
installed_nav.connect_popped(move |_nav, _page| {
|
|
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),
|
|
}
|
|
|
|
// Return focus to the visible content (WCAG 2.4.3)
|
|
if let Some(vs) = window.imp().view_stack.get() {
|
|
if let Some(child) = vs.visible_child() {
|
|
child.grab_focus();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Update window title when navigating to sub-pages (WCAG 2.4.8 Location)
|
|
{
|
|
let window_weak = self.downgrade();
|
|
installed_nav.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)));
|
|
}
|
|
}
|
|
// Move focus to the pushed page (WCAG 2.4.3)
|
|
if let Some(visible) = nav.visible_page() {
|
|
visible.grab_focus();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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()
|
|
.view_stack
|
|
.set(view_stack)
|
|
.expect("ViewStack already set");
|
|
self.imp()
|
|
.installed_nav
|
|
.set(installed_nav)
|
|
.expect("InstalledNav already set");
|
|
if self.imp().library_view.set(library_view).is_err() {
|
|
panic!("LibraryView already set");
|
|
}
|
|
self.imp()
|
|
.enrichment_banner
|
|
.set(enrichment_banner)
|
|
.expect("EnrichmentBanner 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().installed_nav.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(None);
|
|
})
|
|
.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(widgets::info_toast(&msg));
|
|
}
|
|
_ => {
|
|
toast_ref.add_toast(widgets::error_toast(&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(widgets::info_toast(&i18n("All AppImages are up to date")));
|
|
}
|
|
Ok(n) => {
|
|
let msg = ni18n_f(
|
|
"{count} update available",
|
|
"{count} updates available",
|
|
n as u32,
|
|
&[("{count}", &n.to_string())],
|
|
);
|
|
toast_ref.add_toast(widgets::info_toast(&msg));
|
|
}
|
|
Err(_) => {
|
|
toast_ref.add_toast(widgets::error_toast(&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().installed_nav.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();
|
|
|
|
// Catalog browser action - switches to catalog tab
|
|
let catalog_action = gio::ActionEntry::builder("catalog")
|
|
.activate(|window: &Self, _, _| {
|
|
let view_stack = window.imp().view_stack.get().unwrap();
|
|
view_stack.set_visible_child_name("catalog");
|
|
if let Some(child) = view_stack.visible_child() {
|
|
child.grab_focus();
|
|
}
|
|
})
|
|
.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);
|
|
// Move focus into the drop zone card (WCAG 2.4.3)
|
|
if let Some(card) = revealer.child() {
|
|
card.grab_focus();
|
|
}
|
|
}
|
|
// Announce to screen readers
|
|
widgets::announce(overlay, "Drop zone opened. Drop an AppImage file or press Enter to browse.");
|
|
}
|
|
})
|
|
.build();
|
|
|
|
// Export app list action
|
|
let export_action = gio::ActionEntry::builder("export-app-list")
|
|
.activate(|window: &Self, _, _| {
|
|
window.show_export_dialog();
|
|
})
|
|
.build();
|
|
|
|
// Import app list action
|
|
let import_action = gio::ActionEntry::builder("import-app-list")
|
|
.activate(|window: &Self, _, _| {
|
|
window.show_import_dialog();
|
|
})
|
|
.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,
|
|
catalog_action,
|
|
shortcuts_action,
|
|
show_drop_hint_action,
|
|
export_action,
|
|
import_action,
|
|
]);
|
|
|
|
// Sort library action (parameterized with sort mode string)
|
|
let sort_action = gio::SimpleAction::new("sort-library", Some(glib::VariantTy::STRING));
|
|
{
|
|
let window_weak = self.downgrade();
|
|
sort_action.connect_activate(move |_, param| {
|
|
let Some(window) = window_weak.upgrade() else { return };
|
|
let Some(mode_str) = param.and_then(|p| p.get::<String>()) else { return };
|
|
let lib_view = window.imp().library_view.get().unwrap();
|
|
let sort_mode = match mode_str.as_str() {
|
|
"recent" => crate::ui::library_view::SortMode::RecentlyAdded,
|
|
"size" => crate::ui::library_view::SortMode::Size,
|
|
_ => crate::ui::library_view::SortMode::NameAsc,
|
|
};
|
|
lib_view.set_sort_mode(sort_mode);
|
|
|
|
// Announce sort change for screen readers (WCAG 4.1.3)
|
|
let sort_label = match mode_str.as_str() {
|
|
"recent" => "Sorted by recently added",
|
|
"size" => "Sorted by size",
|
|
_ => "Sorted by name",
|
|
};
|
|
if let Some(toast_overlay) = window.imp().toast_overlay.get() {
|
|
let toast = adw::Toast::builder()
|
|
.title(sort_label)
|
|
.timeout(2)
|
|
.build();
|
|
toast_overlay.add_toast(toast);
|
|
}
|
|
|
|
let settings_key = match mode_str.as_str() {
|
|
"recent" => "recently-added",
|
|
"size" => "size",
|
|
_ => "name",
|
|
};
|
|
let settings = gio::Settings::new(APP_ID);
|
|
settings.set_string("sort-mode", settings_key).ok();
|
|
});
|
|
}
|
|
self.add_action(&sort_action);
|
|
|
|
// --- Batch actions ---
|
|
let batch_integrate_action = gio::SimpleAction::new("batch-integrate", None);
|
|
{
|
|
let window_weak = self.downgrade();
|
|
batch_integrate_action.connect_activate(move |_, _| {
|
|
let Some(window) = window_weak.upgrade() else { return };
|
|
let lib_view = window.imp().library_view.get().unwrap();
|
|
let ids = lib_view.selected_ids();
|
|
if ids.is_empty() { return; }
|
|
let db = window.database().clone();
|
|
let toast_overlay = window.imp().toast_overlay.get().unwrap().clone();
|
|
let mut count = 0;
|
|
for id in &ids {
|
|
if let Ok(Some(record)) = db.get_appimage_by_id(*id) {
|
|
if !record.integrated {
|
|
if let Ok(result) = integrator::integrate_tracked(&record, &db) {
|
|
let desktop_path = result.desktop_file_path.to_string_lossy().to_string();
|
|
db.set_integrated(*id, true, Some(&desktop_path)).ok();
|
|
count += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
lib_view.exit_selection_mode();
|
|
if count > 0 {
|
|
toast_overlay.add_toast(widgets::info_toast(&ni18n_f("Integrated {} app", "Integrated {} apps", count as u32, &[("{}", &count.to_string())])));
|
|
if let Ok(records) = db.get_all_appimages() {
|
|
lib_view.populate(records);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
self.add_action(&batch_integrate_action);
|
|
|
|
let batch_delete_action = gio::SimpleAction::new("batch-delete", None);
|
|
{
|
|
let window_weak = self.downgrade();
|
|
batch_delete_action.connect_activate(move |_, _| {
|
|
let Some(window) = window_weak.upgrade() else { return };
|
|
let lib_view = window.imp().library_view.get().unwrap();
|
|
let ids = lib_view.selected_ids();
|
|
if ids.is_empty() { return; }
|
|
let db = window.database().clone();
|
|
let toast_overlay = window.imp().toast_overlay.get().unwrap().clone();
|
|
let mut count = 0;
|
|
for id in &ids {
|
|
if let Ok(Some(record)) = db.get_appimage_by_id(*id) {
|
|
if record.integrated {
|
|
integrator::undo_all_modifications(&db, *id).ok();
|
|
integrator::remove_integration(&record).ok();
|
|
}
|
|
std::fs::remove_file(&record.path).ok();
|
|
db.remove_appimage(*id).ok();
|
|
count += 1;
|
|
}
|
|
}
|
|
lib_view.exit_selection_mode();
|
|
if count > 0 {
|
|
toast_overlay.add_toast(widgets::info_toast(&ni18n_f("Deleted {} app", "Deleted {} apps", count as u32, &[("{}", &count.to_string())])));
|
|
if let Ok(records) = db.get_all_appimages() {
|
|
lib_view.populate(records);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
self.add_action(&batch_delete_action);
|
|
|
|
let update_all_action = gio::SimpleAction::new("update-all", None);
|
|
{
|
|
let window_weak = self.downgrade();
|
|
update_all_action.connect_activate(move |_, _| {
|
|
let Some(window) = window_weak.upgrade() else { return };
|
|
let db = window.database();
|
|
crate::ui::batch_update_dialog::show_batch_update_dialog(&window, db);
|
|
});
|
|
}
|
|
self.add_action(&update_all_action);
|
|
|
|
let fix_fuse_action = gio::SimpleAction::new("fix-fuse", None);
|
|
{
|
|
let window_weak = self.downgrade();
|
|
fix_fuse_action.connect_activate(move |_, _| {
|
|
let Some(window) = window_weak.upgrade() else { return };
|
|
crate::ui::fuse_wizard::show_fuse_wizard(&window);
|
|
});
|
|
}
|
|
self.add_action(&fix_fuse_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 { pid, method }) => {
|
|
log::info!("Launched: {} (PID: {}, method: {})", path_str, pid, 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);
|
|
toast_overlay.add_toast(widgets::error_toast(&i18n_f("Could not launch: {error}", &[("{error}", &msg)])));
|
|
}
|
|
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(source) = updater::detect_source_url(raw_info.as_deref()) {
|
|
bg_db.set_source_url(record_id, Some(&source)).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(widgets::info_toast(&i18n("Update available!"))),
|
|
Ok(false) => toast_overlay.add_toast(widgets::info_toast(&i18n("Already up to date"))),
|
|
Err(_) => toast_overlay.add_toast(widgets::error_toast(&i18n("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(widgets::info_toast(&i18n("No vulnerabilities found")));
|
|
} else {
|
|
let msg = ni18n_f("Found {} CVE", "Found {} CVEs", total as u32, &[("{}", &total.to_string())]);
|
|
toast_overlay.add_toast(widgets::info_toast(&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(widgets::error_toast(&i18n("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 {
|
|
// Remove integration immediately, offer undo toast
|
|
integrator::undo_all_modifications(&db, record_id).ok();
|
|
integrator::remove_integration(&record).ok();
|
|
db.set_integrated(record_id, false, None).ok();
|
|
|
|
let undo_toast = adw::Toast::builder()
|
|
.title(i18n("Removed from app menu"))
|
|
.button_label(i18n("Undo"))
|
|
.timeout(7)
|
|
.build();
|
|
|
|
let db_undo = db.clone();
|
|
let window_weak_undo = window.downgrade();
|
|
undo_toast.connect_button_clicked(move |_| {
|
|
let Some(win) = window_weak_undo.upgrade() else { return };
|
|
if let Ok(Some(rec)) = db_undo.get_appimage_by_id(record_id) {
|
|
match integrator::integrate_tracked(&rec, &db_undo) {
|
|
Ok(result) => {
|
|
let desktop_path = result.desktop_file_path.to_string_lossy().to_string();
|
|
db_undo.set_integrated(record_id, true, Some(&desktop_path)).ok();
|
|
}
|
|
Err(e) => log::error!("Re-integration failed: {}", e),
|
|
}
|
|
let lib_view = win.imp().library_view.get().unwrap();
|
|
if let Ok(records) = db_undo.get_all_appimages() {
|
|
lib_view.populate(records);
|
|
}
|
|
}
|
|
});
|
|
toast_overlay.add_toast(undo_toast);
|
|
} else {
|
|
match integrator::integrate_tracked(&record, &db) {
|
|
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(widgets::info_toast(&i18n("Integrated into desktop menu")));
|
|
}
|
|
Err(e) => {
|
|
log::error!("Integration failed: {}", e);
|
|
toast_overlay.add_toast(widgets::error_toast(&i18n("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(widgets::info_toast(&i18n("Path copied to clipboard")));
|
|
}
|
|
});
|
|
}
|
|
self.add_action(©_path_action);
|
|
|
|
// Uninstall action (from right-click context menu)
|
|
let uninstall_action = gio::SimpleAction::new("uninstall-appimage", param_type);
|
|
{
|
|
let window_weak = self.downgrade();
|
|
uninstall_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 fp = footprint::get_footprint(&db, record.id, record.size_bytes as u64);
|
|
let fp_paths: Vec<(String, String, u64)> = fp.paths.iter()
|
|
.filter(|p| p.exists)
|
|
.map(|p| (
|
|
p.path.to_string_lossy().to_string(),
|
|
p.path_type.label().to_string(),
|
|
p.size_bytes,
|
|
))
|
|
.collect();
|
|
let is_integrated = record.integrated;
|
|
let window_ref = window.clone();
|
|
let db_refresh = db.clone();
|
|
detail_view::show_uninstall_dialog_with_callback(
|
|
&toast_overlay,
|
|
&record,
|
|
&db,
|
|
is_integrated,
|
|
&fp_paths,
|
|
Some(Rc::new(move || {
|
|
// Refresh the library view after uninstall (or undo)
|
|
if let Some(lib_view) = window_ref.imp().library_view.get() {
|
|
if let Ok(records) = db_refresh.get_all_appimages() {
|
|
lib_view.populate(records);
|
|
}
|
|
}
|
|
})),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
self.add_action(&uninstall_action);
|
|
|
|
// View switching actions for keyboard shortcuts
|
|
let show_installed_action = gio::SimpleAction::new("show-installed", None);
|
|
{
|
|
let window_weak = self.downgrade();
|
|
show_installed_action.connect_activate(move |_, _| {
|
|
let Some(window) = window_weak.upgrade() else { return };
|
|
if let Some(vs) = window.imp().view_stack.get() {
|
|
vs.set_visible_child_name("installed");
|
|
if let Some(child) = vs.visible_child() {
|
|
child.grab_focus();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
self.add_action(&show_installed_action);
|
|
|
|
let show_catalog_action = gio::SimpleAction::new("show-catalog", None);
|
|
{
|
|
let window_weak = self.downgrade();
|
|
show_catalog_action.connect_activate(move |_, _| {
|
|
let Some(window) = window_weak.upgrade() else { return };
|
|
if let Some(vs) = window.imp().view_stack.get() {
|
|
vs.set_visible_child_name("catalog");
|
|
if let Some(child) = vs.visible_child() {
|
|
child.grab_focus();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
self.add_action(&show_catalog_action);
|
|
|
|
let show_updates_action = gio::SimpleAction::new("show-updates", None);
|
|
{
|
|
let window_weak = self.downgrade();
|
|
show_updates_action.connect_activate(move |_, _| {
|
|
let Some(window) = window_weak.upgrade() else { return };
|
|
if let Some(vs) = window.imp().view_stack.get() {
|
|
vs.set_visible_child_name("updates");
|
|
if let Some(child) = vs.visible_child() {
|
|
child.grab_focus();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
self.add_action(&show_updates_action);
|
|
|
|
// Command palette (Ctrl+K)
|
|
let palette_action = gio::SimpleAction::new("command-palette", None);
|
|
{
|
|
let window_weak = self.downgrade();
|
|
palette_action.connect_activate(move |_, _| {
|
|
let Some(window) = window_weak.upgrade() else { return };
|
|
window.show_command_palette();
|
|
});
|
|
}
|
|
self.add_action(&palette_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"]);
|
|
gtk_app.set_accels_for_action("win.show-installed", &["<Control>1"]);
|
|
gtk_app.set_accels_for_action("win.show-catalog", &["<Control>2"]);
|
|
gtk_app.set_accels_for_action("win.show-updates", &["<Control>3"]);
|
|
gtk_app.set_accels_for_action("win.command-palette", &["<Control>k"]);
|
|
}
|
|
}
|
|
|
|
fn load_initial_data(&self) {
|
|
let db = self.database();
|
|
let library_view = self.imp().library_view.get().unwrap();
|
|
|
|
// Ensure default catalog sources exist
|
|
crate::core::catalog::ensure_default_sources(db);
|
|
|
|
match db.get_all_appimages() {
|
|
Ok(records) if !records.is_empty() => {
|
|
library_view.populate(records);
|
|
}
|
|
_ => {
|
|
// Empty database - show empty state and default to Catalog tab
|
|
library_view.set_state(LibraryState::Empty);
|
|
if let Some(view_stack) = self.imp().view_stack.get() {
|
|
view_stack.set_visible_child_name("catalog");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Scan on startup if enabled in preferences
|
|
if self.settings().boolean("auto-scan-on-startup") {
|
|
let scan_toast = if let Some(toast_overlay) = self.imp().toast_overlay.get() {
|
|
let toast = adw::Toast::builder()
|
|
.title(&i18n("Scanning for apps in your configured folders..."))
|
|
.timeout(10)
|
|
.build();
|
|
toast_overlay.add_toast(toast.clone());
|
|
Some(toast)
|
|
} else {
|
|
None
|
|
};
|
|
self.trigger_scan(scan_toast);
|
|
}
|
|
|
|
// Start watching scan directories for new AppImage files
|
|
self.start_file_watcher();
|
|
|
|
// Auto-cleanup old backups based on retention setting
|
|
let retention_days = self.settings().int("backup-retention-days") as u32;
|
|
glib::spawn_future_local(async move {
|
|
let _ = gio::spawn_blocking(move || {
|
|
let bg_db = Database::open().expect("Failed to open database");
|
|
match crate::core::backup::auto_cleanup_old_backups(&bg_db, retention_days) {
|
|
Ok(removed) if removed > 0 => {
|
|
log::info!("Auto-cleaned {} old backup(s) (retention: {} days)", removed, retention_days);
|
|
}
|
|
Err(e) => log::warn!("Backup auto-cleanup failed: {}", e),
|
|
_ => {}
|
|
}
|
|
})
|
|
.await;
|
|
});
|
|
|
|
// Run background security scan and notify if auto-security-scan is enabled
|
|
let settings_sec = self.settings().clone();
|
|
if settings_sec.boolean("auto-security-scan") {
|
|
let threshold = settings_sec.string("security-notification-threshold").to_string();
|
|
glib::spawn_future_local(async move {
|
|
let _ = gio::spawn_blocking(move || {
|
|
let bg_db = Database::open().expect("Failed to open database");
|
|
let notifications = notification::scan_and_notify(&bg_db, &threshold);
|
|
for n in ¬ifications {
|
|
log::info!(
|
|
"CVE notification sent: app={} (id={}), severity={}, count={}",
|
|
n.app_name, n.appimage_id, n.severity, n.cve_count,
|
|
);
|
|
}
|
|
})
|
|
.await;
|
|
});
|
|
}
|
|
|
|
// Scheduled background update check
|
|
let settings_upd = self.settings().clone();
|
|
if settings_upd.boolean("auto-check-updates") {
|
|
let last_check = settings_upd.string("last-update-check").to_string();
|
|
let interval_hours = settings_upd.int("update-check-interval-hours") as i64;
|
|
let should_check = if last_check.is_empty() {
|
|
true
|
|
} else if let Ok(last) = chrono::NaiveDateTime::parse_from_str(&last_check, "%Y-%m-%d %H:%M:%S") {
|
|
let now = chrono::Utc::now().naive_utc();
|
|
let elapsed = now.signed_duration_since(last);
|
|
elapsed.num_hours() >= interval_hours
|
|
} else {
|
|
true
|
|
};
|
|
if should_check {
|
|
let settings_save = settings_upd.clone();
|
|
let update_toast = self.imp().toast_overlay.get().cloned();
|
|
glib::spawn_future_local(async move {
|
|
let result = gio::spawn_blocking(move || {
|
|
let bg_db = Database::open().expect("DB open failed");
|
|
update_dialog::batch_check_updates_detailed(&bg_db)
|
|
})
|
|
.await;
|
|
if let Ok((count, names)) = result {
|
|
log::info!("Background update check: {} updates available", count);
|
|
if count > 0 {
|
|
if let Some(toast_overlay) = update_toast {
|
|
let title = if names.len() <= 3 {
|
|
i18n_f("Updates available: {apps}", &[("{apps}", &names.join(", "))])
|
|
} else {
|
|
i18n_f("{count} app updates available ({apps}, ...)",
|
|
&[("{count}", &count.to_string()), ("{apps}", &names[..2].join(", "))])
|
|
};
|
|
let toast = adw::Toast::builder()
|
|
.title(&title)
|
|
.button_label(i18n("View"))
|
|
.action_name("win.show-updates")
|
|
.timeout(5)
|
|
.build();
|
|
toast_overlay.add_toast(toast);
|
|
}
|
|
}
|
|
}
|
|
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
|
settings_save.set_string("last-update-check", &now).ok();
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Update badge on Updates tab
|
|
self.refresh_update_badge();
|
|
|
|
// Background GitHub enrichment
|
|
self.start_background_enrichment();
|
|
}
|
|
|
|
fn start_background_enrichment(&self) {
|
|
let settings = self.settings().clone();
|
|
if !settings.boolean("catalog-auto-enrich") {
|
|
return;
|
|
}
|
|
|
|
let db = self.database().clone();
|
|
|
|
// Check if there are unenriched apps
|
|
let (enriched, total) = db.catalog_enrichment_progress().unwrap_or((0, 0));
|
|
if total == 0 || enriched >= total {
|
|
return;
|
|
}
|
|
|
|
let banner = self.imp().enrichment_banner.get().cloned();
|
|
let view_stack = self.imp().view_stack.get().cloned();
|
|
|
|
// Show banner initially
|
|
if let Some(ref b) = banner {
|
|
b.set_visible(true);
|
|
if let Some(label) = find_child_by_name(b, "enrich-label") {
|
|
if let Ok(l) = label.downcast::<gtk::Label>() {
|
|
l.set_label(&format!("Enriching app data from GitHub ({}/{})...", enriched, total));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Channel for progress updates
|
|
let (tx, rx) = std::sync::mpsc::channel::<(i64, i64, bool)>();
|
|
|
|
let token = settings.string("github-token").to_string();
|
|
|
|
// Background thread: runs batch enrichment
|
|
glib::spawn_future_local(async move {
|
|
let tx_c = tx.clone();
|
|
|
|
gio::spawn_blocking(move || {
|
|
let bg_db = match crate::core::database::Database::open() {
|
|
Ok(db) => db,
|
|
Err(_) => return,
|
|
};
|
|
|
|
loop {
|
|
let result = crate::core::github_enrichment::background_enrich_batch(
|
|
&bg_db,
|
|
&token,
|
|
20,
|
|
&|done, total| {
|
|
tx_c.send((done, total, false)).ok();
|
|
},
|
|
);
|
|
|
|
match result {
|
|
Ok((count, should_continue)) => {
|
|
if count == 0 || !should_continue {
|
|
// Done or rate limited
|
|
if let Ok((done, total)) = bg_db.catalog_enrichment_progress() {
|
|
tx_c.send((done, total, true)).ok();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::warn!("Background enrichment error: {}", e);
|
|
tx_c.send((0, 0, true)).ok();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}).await.ok();
|
|
});
|
|
|
|
// Poll progress on the UI thread.
|
|
// Timer keeps running until enrichment is truly complete (all apps enriched).
|
|
// When rate-limited, shows a paused message but keeps the banner visible.
|
|
let banner_ref = banner;
|
|
glib::timeout_add_local(std::time::Duration::from_millis(250), move || {
|
|
while let Ok((done, total, finished)) = rx.try_recv() {
|
|
if let Some(ref b) = banner_ref {
|
|
if finished {
|
|
if total == 0 || done >= total {
|
|
// Truly done - all apps enriched (or error with no data)
|
|
b.set_visible(false);
|
|
return glib::ControlFlow::Break;
|
|
}
|
|
// Rate limited or paused - keep banner visible with updated text
|
|
if let Some(spinner) = find_child_by_name(b, "enrich-spinner") {
|
|
if let Ok(s) = spinner.downcast::<gtk::Spinner>() {
|
|
s.set_spinning(false);
|
|
}
|
|
}
|
|
if let Some(label) = find_child_by_name(b, "enrich-label") {
|
|
if let Ok(l) = label.downcast::<gtk::Label>() {
|
|
l.set_label(&format!(
|
|
"Enriching paused - rate limit ({}/{} enriched)", done, total,
|
|
));
|
|
}
|
|
}
|
|
} else {
|
|
// Active progress update
|
|
if let Some(label) = find_child_by_name(b, "enrich-label") {
|
|
if let Ok(l) = label.downcast::<gtk::Label>() {
|
|
l.set_label(&format!(
|
|
"Enriching app data from GitHub ({}/{})...", done, total,
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Show banner only when on the catalog tab
|
|
if let Some(ref vs) = view_stack {
|
|
let on_catalog = vs.visible_child_name()
|
|
.map(|n| n == "catalog")
|
|
.unwrap_or(false);
|
|
if let Some(ref b) = banner_ref {
|
|
b.set_visible(on_catalog);
|
|
}
|
|
}
|
|
|
|
glib::ControlFlow::Continue
|
|
});
|
|
}
|
|
|
|
fn trigger_scan(&self, scan_toast: Option<adw::Toast>) {
|
|
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());
|
|
}
|
|
|
|
// Include removable media mount points if enabled
|
|
if settings.boolean("watch-removable-media") {
|
|
for mount in crate::core::portable::detect_removable_mounts() {
|
|
let mp = mount.mount_point.to_string_lossy().to_string();
|
|
if !dirs.iter().any(|d| d == &mp) {
|
|
log::info!("Including removable mount: {} ({}, {})", mp, mount.device, mount.fs_type);
|
|
dirs.push(mp);
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Detect portable/removable media
|
|
if crate::core::portable::is_path_on_removable(&d.path) {
|
|
let mounts = crate::core::portable::detect_removable_mounts();
|
|
let mount_point = mounts.iter()
|
|
.find(|m| d.path.starts_with(&m.mount_point))
|
|
.map(|m| m.mount_point.to_string_lossy().to_string());
|
|
bg_db.set_portable(id, true, mount_point.as_deref()).ok();
|
|
}
|
|
|
|
// 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 {
|
|
// Dismiss the "Scanning..." toast now that Phase 1 is done
|
|
if let Some(ref toast) = scan_toast {
|
|
toast.dismiss();
|
|
}
|
|
|
|
// 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(widgets::info_toast(&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 settings_scan = gio::Settings::new(crate::config::APP_ID);
|
|
let auto_integrate = settings_scan.boolean("auto-integrate");
|
|
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, auto_integrate);
|
|
})
|
|
.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));
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Update the badge number on the Updates tab in the ViewSwitcherBar.
|
|
fn refresh_update_badge(&self) {
|
|
let db = self.database();
|
|
let count = db.updatable_count();
|
|
if let Some(view_stack) = self.imp().view_stack.get() {
|
|
if let Some(child) = view_stack.child_by_name("updates") {
|
|
let page = view_stack.page(&child);
|
|
page.set_badge_number(count as u32);
|
|
page.set_needs_attention(count > 0);
|
|
}
|
|
}
|
|
// Also update the banner in the Installed view
|
|
if let Some(library_view) = self.imp().library_view.get() {
|
|
library_view.set_update_count(count);
|
|
}
|
|
}
|
|
|
|
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(None);
|
|
}
|
|
glib::ControlFlow::Continue
|
|
});
|
|
}
|
|
}
|
|
|
|
fn show_command_palette(&self) {
|
|
let db = self.database().clone();
|
|
|
|
let dialog = adw::Dialog::builder()
|
|
.title("Quick Launch")
|
|
.content_width(450)
|
|
.content_height(400)
|
|
.build();
|
|
|
|
let toolbar = adw::ToolbarView::new();
|
|
let header = adw::HeaderBar::new();
|
|
toolbar.add_top_bar(&header);
|
|
|
|
let content_box = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(8)
|
|
.margin_start(12)
|
|
.margin_end(12)
|
|
.margin_top(8)
|
|
.margin_bottom(8)
|
|
.build();
|
|
|
|
let search_entry = gtk::SearchEntry::builder()
|
|
.placeholder_text(&i18n("Type to search installed and catalog apps..."))
|
|
.hexpand(true)
|
|
.build();
|
|
content_box.append(&search_entry);
|
|
|
|
let scrolled = gtk::ScrolledWindow::builder()
|
|
.vexpand(true)
|
|
.build();
|
|
|
|
let results_list = gtk::ListBox::builder()
|
|
.selection_mode(gtk::SelectionMode::Single)
|
|
.css_classes(["boxed-list"])
|
|
.build();
|
|
scrolled.set_child(Some(&results_list));
|
|
content_box.append(&scrolled);
|
|
|
|
// Populate results based on search
|
|
let db_ref = db.clone();
|
|
let results_ref = results_list.clone();
|
|
let dialog_ref = dialog.clone();
|
|
let window_weak = self.downgrade();
|
|
|
|
let update_results = std::rc::Rc::new(move |query: &str| {
|
|
// Clear existing
|
|
while let Some(child) = results_ref.first_child() {
|
|
results_ref.remove(&child);
|
|
}
|
|
|
|
if query.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let q = query.to_lowercase();
|
|
|
|
// Search installed apps
|
|
let installed = db_ref.get_all_appimages().unwrap_or_default();
|
|
let mut count = 0;
|
|
for record in &installed {
|
|
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
|
if name.to_lowercase().contains(&q) && count < 10 {
|
|
let row = adw::ActionRow::builder()
|
|
.title(name)
|
|
.subtitle(&i18n("Installed - click to launch"))
|
|
.activatable(true)
|
|
.build();
|
|
let icon = widgets::app_icon(
|
|
record.icon_path.as_deref(), name, 32,
|
|
);
|
|
row.add_prefix(&icon);
|
|
row.add_suffix(&widgets::accessible_suffix_icon("media-playback-start-symbolic", &i18n("Launch")));
|
|
|
|
let record_id = record.id;
|
|
let dialog_c = dialog_ref.clone();
|
|
let window_w = window_weak.clone();
|
|
row.connect_activated(move |_| {
|
|
dialog_c.close();
|
|
if let Some(win) = window_w.upgrade() {
|
|
gio::prelude::ActionGroupExt::activate_action(
|
|
&win, "launch-appimage", Some(&record_id.to_variant()),
|
|
);
|
|
}
|
|
});
|
|
results_ref.append(&row);
|
|
count += 1;
|
|
}
|
|
}
|
|
|
|
// Search catalog apps
|
|
if let Ok(catalog_results) = db_ref.search_catalog(
|
|
query, None, 10, 0,
|
|
crate::core::database::CatalogSortOrder::PopularityDesc,
|
|
) {
|
|
for app in &catalog_results {
|
|
if count >= 15 { break; }
|
|
let row = adw::ActionRow::builder()
|
|
.title(&app.name)
|
|
.subtitle(&i18n("Catalog - click to view"))
|
|
.activatable(true)
|
|
.build();
|
|
let icon = widgets::app_icon(None, &app.name, 32);
|
|
row.add_prefix(&icon);
|
|
row.add_suffix(&widgets::accessible_suffix_icon("go-next-symbolic", &i18n("Open")));
|
|
|
|
let app_id = app.id;
|
|
let dialog_c = dialog_ref.clone();
|
|
let window_w = window_weak.clone();
|
|
let db_c = db_ref.clone();
|
|
row.connect_activated(move |_| {
|
|
dialog_c.close();
|
|
if let Some(win) = window_w.upgrade() {
|
|
// Switch to catalog tab
|
|
if let Some(vs) = win.imp().view_stack.get() {
|
|
vs.set_visible_child_name("catalog");
|
|
}
|
|
// Navigate to the app detail
|
|
if let Ok(Some(catalog_app)) = db_c.get_catalog_app(app_id) {
|
|
if let Some(toast) = win.imp().toast_overlay.get() {
|
|
let detail = crate::ui::catalog_detail::build_catalog_detail_page(
|
|
&catalog_app, &db_c, toast,
|
|
);
|
|
// Push onto the catalog NavigationView
|
|
// The catalog page is a NavigationView inside the ViewStack
|
|
if let Some(vs) = win.imp().view_stack.get() {
|
|
if let Some(child) = vs.child_by_name("catalog") {
|
|
if let Ok(nav) = child.downcast::<adw::NavigationView>() {
|
|
nav.push(&detail);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
results_ref.append(&row);
|
|
count += 1;
|
|
}
|
|
}
|
|
|
|
if count == 0 {
|
|
let row = adw::ActionRow::builder()
|
|
.title(&i18n("No results found"))
|
|
.sensitive(false)
|
|
.build();
|
|
results_ref.append(&row);
|
|
}
|
|
});
|
|
|
|
{
|
|
let update_fn = update_results.clone();
|
|
search_entry.connect_search_changed(move |entry| {
|
|
let query = entry.text().to_string();
|
|
update_fn(&query);
|
|
});
|
|
}
|
|
|
|
toolbar.set_content(Some(&content_box));
|
|
dialog.set_child(Some(&toolbar));
|
|
|
|
// Return focus to main content when command palette closes (WCAG 2.4.3)
|
|
let window_weak = self.downgrade();
|
|
dialog.connect_closed(move |_| {
|
|
if let Some(window) = window_weak.upgrade() {
|
|
if let Some(vs) = window.imp().view_stack.get() {
|
|
if let Some(child) = vs.visible_child() {
|
|
child.grab_focus();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
dialog.present(Some(self));
|
|
|
|
// Focus the search entry after presenting
|
|
search_entry.grab_focus();
|
|
}
|
|
|
|
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+1", "Installed"));
|
|
nav_group.add(&shortcut_row("Ctrl+2", "Catalog"));
|
|
nav_group.add(&shortcut_row("Ctrl+3", "Updates"));
|
|
nav_group.add(&shortcut_row("Ctrl+K", "Quick Launch"));
|
|
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));
|
|
|
|
// Return focus to main content when dialog closes (WCAG 2.4.3)
|
|
let window_weak = self.downgrade();
|
|
dialog.connect_closed(move |_| {
|
|
if let Some(window) = window_weak.upgrade() {
|
|
if let Some(vs) = window.imp().view_stack.get() {
|
|
if let Some(child) = vs.visible_child() {
|
|
child.grab_focus();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
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(widgets::error_toast(&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();
|
|
}
|
|
}
|
|
|
|
fn show_export_dialog(&self) {
|
|
let db = self.database().clone();
|
|
let toast_overlay = self.imp().toast_overlay.get().unwrap().clone();
|
|
|
|
let dialog = gtk::FileDialog::builder()
|
|
.title(i18n("Export App List"))
|
|
.initial_name("driftwood-apps.json")
|
|
.build();
|
|
|
|
let json_filter = gtk::FileFilter::new();
|
|
json_filter.set_name(Some("JSON files"));
|
|
json_filter.add_pattern("*.json");
|
|
let filters = gio::ListStore::new::<gtk::FileFilter>();
|
|
filters.append(&json_filter);
|
|
dialog.set_filters(Some(&filters));
|
|
|
|
let window = self.clone();
|
|
dialog.save(Some(&window), gio::Cancellable::NONE, move |result| {
|
|
if let Ok(file) = result {
|
|
if let Some(path) = file.path() {
|
|
match backup::export_app_list(&db, &path) {
|
|
Ok(count) => {
|
|
toast_overlay.add_toast(
|
|
widgets::info_toast(&ni18n_f("Exported {} app", "Exported {} apps", count as u32, &[("{}", &count.to_string())])),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
toast_overlay.add_toast(
|
|
widgets::error_toast(&i18n_f("Export failed: {error}", &[("{error}", &e.to_string())])),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn show_import_dialog(&self) {
|
|
let db = self.database().clone();
|
|
let toast_overlay = self.imp().toast_overlay.get().unwrap().clone();
|
|
|
|
let dialog = gtk::FileDialog::builder()
|
|
.title(i18n("Import App List"))
|
|
.build();
|
|
|
|
let json_filter = gtk::FileFilter::new();
|
|
json_filter.set_name(Some("JSON files"));
|
|
json_filter.add_pattern("*.json");
|
|
let filters = gio::ListStore::new::<gtk::FileFilter>();
|
|
filters.append(&json_filter);
|
|
dialog.set_filters(Some(&filters));
|
|
|
|
let window = self.clone();
|
|
dialog.open(Some(&window), gio::Cancellable::NONE, move |result| {
|
|
if let Ok(file) = result {
|
|
if let Some(path) = file.path() {
|
|
match backup::import_app_list(&db, &path) {
|
|
Ok(result) => {
|
|
let msg = if result.missing.is_empty() {
|
|
ni18n_f("Imported metadata for {} app", "Imported metadata for {} apps", result.matched as u32, &[("{}", &result.matched.to_string())])
|
|
} else {
|
|
i18n_f(
|
|
"Imported {matched} apps, {missing} not found",
|
|
&[
|
|
("{matched}", &result.matched.to_string()),
|
|
("{missing}", &result.missing.len().to_string()),
|
|
],
|
|
)
|
|
};
|
|
toast_overlay.add_toast(widgets::info_toast(&msg));
|
|
|
|
// Show missing apps dialog if any
|
|
if !result.missing.is_empty() {
|
|
let missing_text = result.missing.join("\n");
|
|
log::info!("Import - missing apps:\n{}", missing_text);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
toast_overlay.add_toast(
|
|
widgets::error_toast(&i18n_f("Import failed: {error}", &[("{error}", &e.to_string())])),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|