Files
driftwood/src/window.rs
lashman fa28955919 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.
2026-02-26 23:04:27 +02:00

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, &section2);
let section3 = gio::Menu::new();
section3.append(Some("About Driftwood"), Some("app.about"));
menu.append_section(None, &section3);
// Library view (contains header bar, search, grid/list, empty state)
let library_view = LibraryView::new(&menu);
// Navigation view
let navigation_view = adw::NavigationView::new();
navigation_view.push(&library_view.page);
// 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();
}
}
}