Phase 1 - Application scaffolding: - GTK4/libadwaita application window with AdwNavigationView - GSettings-backed window state persistence - GResource-compiled CSS and schema - Library view with grid/list toggle, search, sorting, filtering - Detail view with file info, desktop integration controls - Preferences window with scan directories, theme, behavior settings - CLI with list, scan, integrate, remove, clean, inspect commands - AppImage discovery, metadata extraction, desktop integration - Orphaned desktop entry detection and cleanup - AppImage packaging script Phase 2 - Intelligence layer: - Database schema v2 with migration for status tracking columns - FUSE detection engine (libfuse2/3, fusermount, /dev/fuse, AppImageLauncher) - Wayland awareness engine (session type, toolkit detection, XWayland) - Update info parsing from AppImage ELF sections (.upd_info) - GitHub/GitLab Releases API integration for update checking - Update download with progress tracking and atomic apply - Launch wrapper with FUSE auto-detection and usage tracking - Duplicate and multi-version detection with recommendations - Dashboard with system health, library stats, disk usage - Update check dialog (single and batch) - Duplicate resolution dialog - Status badges on library cards and detail view - Extended CLI: status, check-updates, duplicates, launch commands 49 tests passing across all modules.
477 lines
17 KiB
Rust
477 lines
17 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::database::Database;
|
|
use crate::core::discovery;
|
|
use crate::core::inspector;
|
|
use crate::core::orphan;
|
|
use crate::ui::dashboard;
|
|
use crate::ui::detail_view;
|
|
use crate::ui::duplicate_dialog;
|
|
use crate::ui::library_view::{LibraryState, LibraryView};
|
|
use crate::ui::preferences;
|
|
use crate::ui::update_dialog;
|
|
|
|
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>>,
|
|
}
|
|
|
|
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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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;
|
|
}
|
|
|
|
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("Dashboard"), Some("win.dashboard"));
|
|
menu.append(Some("Preferences"), Some("win.preferences"));
|
|
|
|
let section2 = gio::Menu::new();
|
|
section2.append(Some("Scan for AppImages"), Some("win.scan"));
|
|
section2.append(Some("Check for Updates"), Some("win.check-updates"));
|
|
section2.append(Some("Find Duplicates"), Some("win.find-duplicates"));
|
|
menu.append_section(None, §ion2);
|
|
|
|
let section3 = gio::Menu::new();
|
|
section3.append(Some("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);
|
|
|
|
// Navigation view
|
|
let navigation_view = adw::NavigationView::new();
|
|
navigation_view.push(&library_view.page);
|
|
|
|
// Toast overlay wraps everything
|
|
let toast_overlay = adw::ToastOverlay::new();
|
|
toast_overlay.set_child(Some(&navigation_view));
|
|
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Store references
|
|
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!(
|
|
"Cleaned {} desktop entries, {} icons",
|
|
summary.entries_removed,
|
|
summary.icons_removed,
|
|
);
|
|
toast_ref.add_toast(adw::Toast::new(&msg));
|
|
}
|
|
_ => {
|
|
toast_ref.add_toast(adw::Toast::new("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();
|
|
let db = window.database().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("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("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();
|
|
|
|
self.add_action_entries([
|
|
dashboard_action,
|
|
preferences_action,
|
|
scan_action,
|
|
clean_action,
|
|
search_action,
|
|
check_updates_action,
|
|
find_duplicates_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"]);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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 = if count == 1 {
|
|
"1 orphaned desktop entry found. Use 'Clean' to remove it.".to_string()
|
|
} else {
|
|
format!("{} orphaned desktop entries found. Use 'Clean' to remove them.", count)
|
|
};
|
|
let toast = adw::Toast::builder()
|
|
.title(&msg)
|
|
.timeout(5)
|
|
.button_label("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 dirs: Vec<String> = settings
|
|
.strv("scan-directories")
|
|
.iter()
|
|
.map(|s| s.to_string())
|
|
.collect();
|
|
|
|
let toast_overlay = self.imp().toast_overlay.get().unwrap().clone();
|
|
let window_weak = self.downgrade();
|
|
|
|
// Run scan in a background thread (opens its own DB connection),
|
|
// then update UI on main thread using the window's DB.
|
|
glib::spawn_future_local(async move {
|
|
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;
|
|
|
|
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())
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
let needs_metadata = existing
|
|
.as_ref()
|
|
.map(|r| r.app_name.is_none())
|
|
.unwrap_or(true);
|
|
|
|
if needs_metadata {
|
|
if let Ok(metadata) = inspector::inspect_appimage(&d.path, &d.appimage_type) {
|
|
let categories = if metadata.categories.is_empty() {
|
|
None
|
|
} else {
|
|
Some(metadata.categories.join(";"))
|
|
};
|
|
bg_db.update_metadata(
|
|
id,
|
|
metadata.app_name.as_deref(),
|
|
metadata.app_version.as_deref(),
|
|
metadata.description.as_deref(),
|
|
metadata.developer.as_deref(),
|
|
categories.as_deref(),
|
|
metadata.architecture.as_deref(),
|
|
metadata.cached_icon_path.as_ref().map(|p| p.to_string_lossy()).as_deref(),
|
|
Some(&metadata.desktop_entry_content),
|
|
).ok();
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
0,
|
|
duration,
|
|
).ok();
|
|
|
|
(total, new_count)
|
|
})
|
|
.await;
|
|
|
|
if let Ok((total, new_count)) = result {
|
|
// Refresh the library view from the window's main-thread DB
|
|
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 => "No AppImages found".to_string(),
|
|
0 => format!("{} AppImages up to date", total),
|
|
1 => "Found 1 new AppImage".to_string(),
|
|
n => format!("Found {} new AppImages", n),
|
|
};
|
|
toast_overlay.add_toast(adw::Toast::new(&msg));
|
|
}
|
|
});
|
|
}
|
|
|
|
fn save_window_state(&self) {
|
|
let settings = self.settings();
|
|
let (width, height) = self.default_size();
|
|
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();
|
|
}
|
|
}
|
|
}
|