Implement Driftwood AppImage manager - Phases 1 and 2
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.
This commit is contained in:
476
src/window.rs
Normal file
476
src/window.rs
Normal file
@@ -0,0 +1,476 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user