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:
lashman
2026-02-26 23:04:27 +02:00
parent 588b1b1525
commit fa28955919
33 changed files with 10401 additions and 0 deletions

523
src/ui/detail_view.rs Normal file
View File

@@ -0,0 +1,523 @@
use adw::prelude::*;
use std::rc::Rc;
use crate::core::database::{AppImageRecord, Database};
use crate::core::fuse::FuseStatus;
use crate::core::integrator;
use crate::core::launcher;
use crate::core::wayland::WaylandStatus;
use super::widgets;
pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::NavigationPage {
let name = record.app_name.as_deref().unwrap_or(&record.filename);
// Scrollable content with clamp
let clamp = adw::Clamp::builder()
.maximum_size(800)
.tightening_threshold(600)
.build();
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(24)
.margin_top(24)
.margin_bottom(24)
.margin_start(18)
.margin_end(18)
.build();
// Section 1: App Identity
content.append(&build_identity_section(record));
// Section 2: Desktop Integration
content.append(&build_integration_section(record, db));
// Section 3: Runtime Compatibility (Wayland + FUSE)
content.append(&build_runtime_section(record));
// Section 4: Updates
content.append(&build_updates_section(record));
// Section 5: Usage Statistics
content.append(&build_usage_section(record, db));
// Section 6: File Details
content.append(&build_file_details_section(record));
clamp.set_child(Some(&content));
let scrolled = gtk::ScrolledWindow::builder()
.child(&clamp)
.vexpand(true)
.build();
// Header bar with per-app actions
let header = adw::HeaderBar::new();
let launch_button = gtk::Button::builder()
.label("Launch")
.build();
launch_button.add_css_class("suggested-action");
let record_id = record.id;
let path = record.path.clone();
let db_launch = db.clone();
launch_button.connect_clicked(move |_| {
let appimage_path = std::path::Path::new(&path);
let result = launcher::launch_appimage(
&db_launch,
record_id,
appimage_path,
"gui_detail",
&[],
&[],
);
match result {
launcher::LaunchResult::Started { .. } => {
log::info!("Launched AppImage: {}", path);
}
launcher::LaunchResult::Failed(msg) => {
log::error!("Failed to launch: {}", msg);
}
}
});
header.pack_end(&launch_button);
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
toolbar.set_content(Some(&scrolled));
adw::NavigationPage::builder()
.title(name)
.tag("detail")
.child(&toolbar)
.build()
}
fn build_identity_section(record: &AppImageRecord) -> gtk::Box {
let section = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.build();
let heading = gtk::Label::builder()
.label("App Info")
.css_classes(["heading"])
.halign(gtk::Align::Start)
.build();
section.append(&heading);
let list_box = gtk::ListBox::new();
list_box.add_css_class("boxed-list");
list_box.set_selection_mode(gtk::SelectionMode::None);
let name = record.app_name.as_deref().unwrap_or(&record.filename);
// Icon + name row
let name_row = adw::ActionRow::builder()
.title(name)
.build();
if let Some(ref icon_path) = record.icon_path {
let path = std::path::Path::new(icon_path);
if path.exists() {
if let Ok(texture) = gtk::gdk::Texture::from_filename(path) {
let image = gtk::Image::builder()
.pixel_size(48)
.build();
image.set_paintable(Some(&texture));
name_row.add_prefix(&image);
}
}
}
list_box.append(&name_row);
// Version
if let Some(ref version) = record.app_version {
let row = adw::ActionRow::builder()
.title("Version")
.subtitle(version)
.build();
list_box.append(&row);
}
// Description
if let Some(ref desc) = record.description {
if !desc.is_empty() {
let row = adw::ActionRow::builder()
.title("Description")
.subtitle(desc)
.build();
list_box.append(&row);
}
}
// Architecture
if let Some(ref arch) = record.architecture {
let row = adw::ActionRow::builder()
.title("Architecture")
.subtitle(arch)
.build();
list_box.append(&row);
}
// Categories
if let Some(ref cats) = record.categories {
if !cats.is_empty() {
let row = adw::ActionRow::builder()
.title("Categories")
.subtitle(cats)
.build();
list_box.append(&row);
}
}
section.append(&list_box);
section
}
fn build_integration_section(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let section = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.build();
let heading = gtk::Label::builder()
.label("Desktop Integration")
.css_classes(["heading"])
.halign(gtk::Align::Start)
.build();
section.append(&heading);
let list_box = gtk::ListBox::new();
list_box.add_css_class("boxed-list");
list_box.set_selection_mode(gtk::SelectionMode::None);
let switch_row = adw::SwitchRow::builder()
.title("Add to application menu")
.subtitle("Creates a .desktop file and installs the icon")
.active(record.integrated)
.build();
let record_id = record.id;
let record_clone = record.clone();
let db_ref = db.clone();
switch_row.connect_active_notify(move |row| {
if row.is_active() {
match integrator::integrate(&record_clone) {
Ok(result) => {
db_ref
.set_integrated(
record_id,
true,
Some(&result.desktop_file_path.to_string_lossy()),
)
.ok();
}
Err(e) => {
log::error!("Integration failed: {}", e);
}
}
} else {
integrator::remove_integration(&record_clone).ok();
db_ref.set_integrated(record_id, false, None).ok();
}
});
list_box.append(&switch_row);
// Show desktop file path if integrated
if record.integrated {
if let Some(ref desktop_file) = record.desktop_file {
let row = adw::ActionRow::builder()
.title("Desktop file")
.subtitle(desktop_file)
.css_classes(["monospace"])
.build();
list_box.append(&row);
}
}
section.append(&list_box);
section
}
fn build_runtime_section(record: &AppImageRecord) -> gtk::Box {
let section = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.build();
let heading = gtk::Label::builder()
.label("Runtime Compatibility")
.css_classes(["heading"])
.halign(gtk::Align::Start)
.build();
section.append(&heading);
let list_box = gtk::ListBox::new();
list_box.add_css_class("boxed-list");
list_box.set_selection_mode(gtk::SelectionMode::None);
// Wayland status
let wayland_status = record
.wayland_status
.as_deref()
.map(WaylandStatus::from_str)
.unwrap_or(WaylandStatus::Unknown);
let wayland_row = adw::ActionRow::builder()
.title("Wayland")
.subtitle(wayland_description(&wayland_status))
.build();
let wayland_badge = widgets::status_badge(wayland_status.label(), wayland_status.badge_class());
wayland_badge.set_valign(gtk::Align::Center);
wayland_row.add_suffix(&wayland_badge);
list_box.append(&wayland_row);
// FUSE status
let fuse_status = record
.fuse_status
.as_deref()
.map(FuseStatus::from_str)
.unwrap_or(FuseStatus::MissingLibfuse2);
let fuse_row = adw::ActionRow::builder()
.title("FUSE")
.subtitle(fuse_description(&fuse_status))
.build();
let fuse_badge = widgets::status_badge(fuse_status.label(), fuse_status.badge_class());
fuse_badge.set_valign(gtk::Align::Center);
fuse_row.add_suffix(&fuse_badge);
list_box.append(&fuse_row);
section.append(&list_box);
section
}
fn wayland_description(status: &WaylandStatus) -> &'static str {
match status {
WaylandStatus::Native => "Runs natively on Wayland",
WaylandStatus::XWayland => "Runs via XWayland compatibility layer",
WaylandStatus::Possible => "May run on Wayland with additional flags",
WaylandStatus::X11Only => "X11 only - no Wayland support",
WaylandStatus::Unknown => "Could not determine Wayland compatibility",
}
}
fn fuse_description(status: &FuseStatus) -> &'static str {
match status {
FuseStatus::FullyFunctional => "FUSE mount available - native AppImage launch",
FuseStatus::Fuse3Only => "Only FUSE3 installed - may need libfuse2",
FuseStatus::NoFusermount => "fusermount binary not found",
FuseStatus::NoDevFuse => "/dev/fuse device not available",
FuseStatus::MissingLibfuse2 => "libfuse2 not installed",
}
}
fn build_updates_section(record: &AppImageRecord) -> gtk::Box {
let section = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.build();
let heading = gtk::Label::builder()
.label("Updates")
.css_classes(["heading"])
.halign(gtk::Align::Start)
.build();
section.append(&heading);
let list_box = gtk::ListBox::new();
list_box.add_css_class("boxed-list");
list_box.set_selection_mode(gtk::SelectionMode::None);
// Update info type
if let Some(ref update_type) = record.update_type {
let row = adw::ActionRow::builder()
.title("Update method")
.subtitle(update_type)
.build();
list_box.append(&row);
} else {
let row = adw::ActionRow::builder()
.title("Update method")
.subtitle("No update information embedded")
.build();
let badge = widgets::status_badge("None", "neutral");
badge.set_valign(gtk::Align::Center);
row.add_suffix(&badge);
list_box.append(&row);
}
// Latest version / update status
if let Some(ref latest) = record.latest_version {
let is_newer = record
.app_version
.as_deref()
.map(|current| crate::core::updater::version_is_newer(latest, current))
.unwrap_or(true);
if is_newer {
let subtitle = format!(
"{} -> {}",
record.app_version.as_deref().unwrap_or("unknown"),
latest
);
let row = adw::ActionRow::builder()
.title("Update available")
.subtitle(&subtitle)
.build();
let badge = widgets::status_badge("Update", "info");
badge.set_valign(gtk::Align::Center);
row.add_suffix(&badge);
list_box.append(&row);
} else {
let row = adw::ActionRow::builder()
.title("Status")
.subtitle("Up to date")
.build();
let badge = widgets::status_badge("Latest", "success");
badge.set_valign(gtk::Align::Center);
row.add_suffix(&badge);
list_box.append(&row);
}
}
// Last checked
if let Some(ref checked) = record.update_checked {
let row = adw::ActionRow::builder()
.title("Last checked")
.subtitle(checked)
.build();
list_box.append(&row);
}
section.append(&list_box);
section
}
fn build_usage_section(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let section = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.build();
let heading = gtk::Label::builder()
.label("Usage")
.css_classes(["heading"])
.halign(gtk::Align::Start)
.build();
section.append(&heading);
let list_box = gtk::ListBox::new();
list_box.add_css_class("boxed-list");
list_box.set_selection_mode(gtk::SelectionMode::None);
let stats = launcher::get_launch_stats(db, record.id);
let launches_row = adw::ActionRow::builder()
.title("Total launches")
.subtitle(&stats.total_launches.to_string())
.build();
list_box.append(&launches_row);
if let Some(ref last) = stats.last_launched {
let row = adw::ActionRow::builder()
.title("Last launched")
.subtitle(last)
.build();
list_box.append(&row);
}
section.append(&list_box);
section
}
fn build_file_details_section(record: &AppImageRecord) -> gtk::Box {
let section = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.build();
let heading = gtk::Label::builder()
.label("File Details")
.css_classes(["heading"])
.halign(gtk::Align::Start)
.build();
section.append(&heading);
let list_box = gtk::ListBox::new();
list_box.add_css_class("boxed-list");
list_box.set_selection_mode(gtk::SelectionMode::None);
// Path
let path_row = adw::ActionRow::builder()
.title("Path")
.subtitle(&record.path)
.subtitle_selectable(true)
.build();
list_box.append(&path_row);
// Size
let size_row = adw::ActionRow::builder()
.title("Size")
.subtitle(&widgets::format_size(record.size_bytes))
.build();
list_box.append(&size_row);
// Type
let type_str = match record.appimage_type {
Some(1) => "Type 1",
Some(2) => "Type 2",
_ => "Unknown",
};
let type_row = adw::ActionRow::builder()
.title("AppImage type")
.subtitle(type_str)
.build();
list_box.append(&type_row);
// Executable
let exec_row = adw::ActionRow::builder()
.title("Executable")
.subtitle(if record.is_executable { "Yes" } else { "No" })
.build();
list_box.append(&exec_row);
// SHA256
if let Some(ref hash) = record.sha256 {
let hash_row = adw::ActionRow::builder()
.title("SHA256")
.subtitle(hash)
.subtitle_selectable(true)
.build();
list_box.append(&hash_row);
}
// First seen
let seen_row = adw::ActionRow::builder()
.title("First seen")
.subtitle(&record.first_seen)
.build();
list_box.append(&seen_row);
// Last scanned
let scanned_row = adw::ActionRow::builder()
.title("Last scanned")
.subtitle(&record.last_scanned)
.build();
list_box.append(&scanned_row);
// Notes
if let Some(ref notes) = record.notes {
if !notes.is_empty() {
let row = adw::ActionRow::builder()
.title("Notes")
.subtitle(notes)
.build();
list_box.append(&row);
}
}
section.append(&list_box);
section
}