From 0aeb44555462d32957cdca9bb946a40960aec743 Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 27 Feb 2026 10:04:46 +0200 Subject: [PATCH] Add WCAG tooltips, plain language, busy states, and banner role to detail view --- src/ui/detail_view.rs | 797 ++++++++++++++++++++++++++++++------------ 1 file changed, 576 insertions(+), 221 deletions(-) diff --git a/src/ui/detail_view.rs b/src/ui/detail_view.rs index 73d15eb..a9b4c77 100644 --- a/src/ui/detail_view.rs +++ b/src/ui/detail_view.rs @@ -1,16 +1,27 @@ use adw::prelude::*; +use std::cell::Cell; use std::rc::Rc; +use gtk::gio; + use crate::core::database::{AppImageRecord, Database}; -use crate::core::fuse::FuseStatus; +use crate::core::footprint; +use crate::core::fuse::{self, FuseStatus}; use crate::core::integrator; -use crate::core::launcher; -use crate::core::wayland::WaylandStatus; +use crate::core::launcher::{self, SandboxMode}; +use crate::core::security; +use crate::core::updater; +use crate::core::wayland::{self, WaylandStatus}; +use super::integration_dialog; +use super::update_dialog; use super::widgets; pub fn build_detail_page(record: &AppImageRecord, db: &Rc) -> adw::NavigationPage { let name = record.app_name.as_deref().unwrap_or(&record.filename); + // Toast overlay for copy actions + let toast_overlay = adw::ToastOverlay::new(); + // Scrollable content with clamp let clamp = adw::Clamp::builder() .maximum_size(800) @@ -26,23 +37,17 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc) -> adw::Nav .margin_end(18) .build(); - // Section 1: App Identity - content.append(&build_identity_section(record)); + // Banner: App identity (not a boxed group) + content.append(&build_banner(record)); - // Section 2: Desktop Integration - content.append(&build_integration_section(record, db)); + // Group 1: System Integration (Desktop Integration + Runtime Compatibility + Sandboxing) + content.append(&build_system_integration_group(record, db)); - // Section 3: Runtime Compatibility (Wayland + FUSE) - content.append(&build_runtime_section(record)); + // Group 2: Updates & Usage + content.append(&build_updates_usage_group(record, db)); - // 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)); + // Group 3: Security & Storage + content.append(&build_security_storage_group(record, db, &toast_overlay)); clamp.set_child(Some(&content)); let scrolled = gtk::ScrolledWindow::builder() @@ -50,13 +55,19 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc) -> adw::Nav .vexpand(true) .build(); + toast_overlay.set_child(Some(&scrolled)); + // Header bar with per-app actions let header = adw::HeaderBar::new(); let launch_button = gtk::Button::builder() .label("Launch") + .tooltip_text("Launch this AppImage") .build(); launch_button.add_css_class("suggested-action"); + launch_button.update_property(&[ + gtk::accessible::Property::Label("Launch application"), + ]); let record_id = record.id; let path = record.path.clone(); let db_launch = db.clone(); @@ -71,8 +82,45 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc) -> adw::Nav &[], ); match result { - launcher::LaunchResult::Started { .. } => { - log::info!("Launched AppImage: {}", path); + launcher::LaunchResult::Started { child, method } => { + let pid = child.id(); + log::info!("Launched AppImage: {} (PID: {}, method: {})", path, pid, method.as_str()); + + // Run post-launch Wayland runtime analysis after a short delay + let db_wayland = db_launch.clone(); + let path_clone = path.clone(); + glib::spawn_future_local(async move { + // Wait 3 seconds for the process to initialize + glib::timeout_future(std::time::Duration::from_secs(3)).await; + + let analysis_result = gio::spawn_blocking(move || { + wayland::analyze_running_process(pid) + }).await; + + match analysis_result { + Ok(Ok(analysis)) => { + let status_label = analysis.status_label(); + let status_str = analysis.as_status_str(); + log::info!( + "Runtime Wayland analysis for {} (PID {}): {} (wayland_socket={}, x11={}, env_vars={})", + path_clone, analysis.pid, status_label, + analysis.has_wayland_socket, + analysis.has_x11_connection, + analysis.env_vars.len(), + ); + // Store the runtime analysis result in the database + db_wayland.update_runtime_wayland_status( + record_id, status_str, + ).ok(); + } + Ok(Err(e)) => { + log::debug!("Runtime analysis failed for PID {}: {}", pid, e); + } + Err(_) => { + log::debug!("Runtime analysis task failed for PID {}", pid); + } + } + }); } launcher::LaunchResult::Failed(msg) => { log::error!("Failed to launch: {}", msg); @@ -81,9 +129,24 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc) -> adw::Nav }); header.pack_end(&launch_button); + // Check for Update button + let update_button = gtk::Button::builder() + .icon_name("software-update-available-symbolic") + .tooltip_text("Check for updates") + .build(); + update_button.update_property(&[ + gtk::accessible::Property::Label("Check for updates"), + ]); + let record_for_update = record.clone(); + let db_update = db.clone(); + update_button.connect_clicked(move |btn| { + update_dialog::show_update_dialog(btn, &record_for_update, &db_update); + }); + header.pack_end(&update_button); + let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&header); - toolbar.set_content(Some(&scrolled)); + toolbar.set_content(Some(&toast_overlay)); adw::NavigationPage::builder() .title(name) @@ -92,105 +155,105 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc) -> adw::Nav .build() } -fn build_identity_section(record: &AppImageRecord) -> gtk::Box { - let section = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(8) +/// Rich banner at top: large icon + app name + version + badges +fn build_banner(record: &AppImageRecord) -> gtk::Box { + let banner = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(16) .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); + banner.add_css_class("detail-banner"); + banner.set_accessible_role(gtk::AccessibleRole::Banner); let name = record.app_name.as_deref().unwrap_or(&record.filename); - // Icon + name row - let name_row = adw::ActionRow::builder() - .title(name) + // Large icon (64x64) + let icon = widgets::app_icon(record.icon_path.as_deref(), name, 64); + icon.set_valign(gtk::Align::Start); + banner.append(&icon); + + // Text column + let text_col = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(4) + .valign(gtk::Align::Center) .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); + let name_label = gtk::Label::builder() + .label(name) + .css_classes(["title-1"]) + .halign(gtk::Align::Start) + .build(); + text_col.append(&name_label); - // Version - if let Some(ref version) = record.app_version { - let row = adw::ActionRow::builder() - .title("Version") - .subtitle(version) + // Version + architecture inline + let meta_parts: Vec = [ + record.app_version.as_deref().map(|v| v.to_string()), + record.architecture.as_deref().map(|a| a.to_string()), + ] + .iter() + .filter_map(|p| p.clone()) + .collect(); + + if !meta_parts.is_empty() { + let meta_label = gtk::Label::builder() + .label(&meta_parts.join(" - ")) + .css_classes(["dimmed"]) + .halign(gtk::Align::Start) .build(); - list_box.append(&row); + text_col.append(&meta_label); } // Description if let Some(ref desc) = record.description { if !desc.is_empty() { - let row = adw::ActionRow::builder() - .title("Description") - .subtitle(desc) + let desc_label = gtk::Label::builder() + .label(desc) + .css_classes(["body"]) + .halign(gtk::Align::Start) + .wrap(true) + .xalign(0.0) .build(); - list_box.append(&row); + text_col.append(&desc_label); } } - // Architecture - if let Some(ref arch) = record.architecture { - let row = adw::ActionRow::builder() - .title("Architecture") - .subtitle(arch) - .build(); - list_box.append(&row); + // Key status badges inline + let badge_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(6) + .margin_top(4) + .build(); + + if record.integrated { + badge_box.append(&widgets::status_badge("Integrated", "success")); } - // 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); + if let Some(ref ws) = record.wayland_status { + let status = WaylandStatus::from_str(ws); + if status != WaylandStatus::Unknown { + badge_box.append(&widgets::status_badge(status.label(), status.badge_class())); } } - section.append(&list_box); - section + if let (Some(ref latest), Some(ref current)) = (&record.latest_version, &record.app_version) { + if crate::core::updater::version_is_newer(latest, current) { + badge_box.append(&widgets::status_badge("Update available", "info")); + } + } + + text_col.append(&badge_box); + banner.append(&text_col); + banner } -fn build_integration_section(record: &AppImageRecord, db: &Rc) -> gtk::Box { - let section = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(8) +/// Group 1: System Integration (Desktop Integration + Runtime + Sandboxing) +fn build_system_integration_group(record: &AppImageRecord, db: &Rc) -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder() + .title("System Integration") + .description("Desktop integration, runtime compatibility, and sandboxing") .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); - + // --- Desktop Integration --- let switch_row = adw::SwitchRow::builder() .title("Add to application menu") .subtitle("Creates a .desktop file and installs the icon") @@ -200,64 +263,50 @@ fn build_integration_section(record: &AppImageRecord, db: &Rc) -> gtk: let record_id = record.id; let record_clone = record.clone(); let db_ref = db.clone(); + let db_dialog = db.clone(); + let record_dialog = record.clone(); + let suppress = Rc::new(Cell::new(false)); + let suppress_ref = suppress.clone(); switch_row.connect_active_notify(move |row| { + if suppress_ref.get() { + return; + } 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); - } - } + let row_clone = row.clone(); + let suppress_inner = suppress_ref.clone(); + integration_dialog::show_integration_dialog( + row, + &record_dialog, + &db_dialog, + move |success| { + if !success { + suppress_inner.set(true); + row_clone.set_active(false); + suppress_inner.set(false); + } + }, + ); } else { integrator::remove_integration(&record_clone).ok(); db_ref.set_integrated(record_id, false, None).ok(); } }); + group.add(&switch_row); - list_box.append(&switch_row); - - // Show desktop file path if integrated + // 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"]) + .subtitle_selectable(true) .build(); - list_box.append(&row); + row.add_css_class("monospace"); + group.add(&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 + // --- Runtime Compatibility --- let wayland_status = record .wayland_status .as_deref() @@ -267,30 +316,158 @@ fn build_runtime_section(record: &AppImageRecord) -> gtk::Box { let wayland_row = adw::ActionRow::builder() .title("Wayland") .subtitle(wayland_description(&wayland_status)) + .tooltip_text("Display protocol for Linux desktops") .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); + group.add(&wayland_row); - // FUSE status + // Wayland analyze button - runs toolkit detection on demand + let analyze_row = adw::ActionRow::builder() + .title("Analyze toolkit") + .subtitle("Inspect bundled libraries to detect UI toolkit") + .activatable(true) + .build(); + let analyze_icon = gtk::Image::from_icon_name("system-search-symbolic"); + analyze_icon.set_valign(gtk::Align::Center); + analyze_row.add_suffix(&analyze_icon); + + let record_path_wayland = record.path.clone(); + analyze_row.connect_activated(move |row| { + row.set_sensitive(false); + row.update_state(&[gtk::accessible::State::Busy(true)]); + row.set_subtitle("Analyzing..."); + let row_clone = row.clone(); + let path = record_path_wayland.clone(); + glib::spawn_future_local(async move { + let result = gio::spawn_blocking(move || { + let appimage_path = std::path::Path::new(&path); + wayland::analyze_appimage(appimage_path) + }) + .await; + + row_clone.set_sensitive(true); + row_clone.update_state(&[gtk::accessible::State::Busy(false)]); + match result { + Ok(analysis) => { + let toolkit_label = analysis.toolkit.label(); + let lib_count = analysis.libraries_found.len(); + row_clone.set_subtitle(&format!( + "Toolkit: {} ({} libraries scanned)", + toolkit_label, lib_count, + )); + } + Err(_) => { + row_clone.set_subtitle("Analysis failed"); + } + } + }); + }); + group.add(&analyze_row); + + // Runtime Wayland status (from post-launch analysis) + if let Some(ref runtime_status) = record.runtime_wayland_status { + let runtime_row = adw::ActionRow::builder() + .title("Runtime display protocol") + .subtitle(runtime_status) + .build(); + if let Some(ref checked) = record.runtime_wayland_checked { + let info = gtk::Label::builder() + .label(checked) + .css_classes(["dimmed", "caption"]) + .valign(gtk::Align::Center) + .build(); + runtime_row.add_suffix(&info); + } + group.add(&runtime_row); + } + + let fuse_system = fuse::detect_system_fuse(); let fuse_status = record .fuse_status .as_deref() .map(FuseStatus::from_str) - .unwrap_or(FuseStatus::MissingLibfuse2); + .unwrap_or(fuse_system.status.clone()); let fuse_row = adw::ActionRow::builder() .title("FUSE") .subtitle(fuse_description(&fuse_status)) + .tooltip_text("Filesystem in Userspace - required for mounting AppImages") .build(); - let fuse_badge = widgets::status_badge(fuse_status.label(), fuse_status.badge_class()); + let fuse_badge = widgets::status_badge_with_icon( + if fuse_status.is_functional() { "emblem-ok-symbolic" } else { "dialog-warning-symbolic" }, + 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); + group.add(&fuse_row); - section.append(&list_box); - section + // Per-app FUSE launch method + let appimage_path = std::path::Path::new(&record.path); + let app_fuse_status = fuse::determine_app_fuse_status(&fuse_system, appimage_path); + let launch_method_row = adw::ActionRow::builder() + .title("Launch method") + .subtitle(app_fuse_status.label()) + .build(); + let launch_badge = widgets::status_badge( + fuse_system.status.as_str(), + app_fuse_status.badge_class(), + ); + launch_badge.set_valign(gtk::Align::Center); + launch_method_row.add_suffix(&launch_badge); + group.add(&launch_method_row); + + // --- Sandboxing --- + let current_mode = record + .sandbox_mode + .as_deref() + .map(SandboxMode::from_str) + .unwrap_or(SandboxMode::None); + + let firejail_available = launcher::has_firejail(); + + let sandbox_subtitle = if firejail_available { + format!("Current mode: {}", current_mode.label()) + } else { + "Firejail is not installed".to_string() + }; + + let firejail_row = adw::SwitchRow::builder() + .title("Firejail sandbox") + .subtitle(&sandbox_subtitle) + .tooltip_text("Linux application sandboxing tool") + .active(current_mode == SandboxMode::Firejail) + .sensitive(firejail_available) + .build(); + + let record_id = record.id; + let db_ref = db.clone(); + firejail_row.connect_active_notify(move |row| { + let mode = if row.is_active() { + SandboxMode::Firejail + } else { + SandboxMode::None + }; + if let Err(e) = db_ref.update_sandbox_mode(record_id, Some(mode.as_str())) { + log::warn!("Failed to update sandbox mode: {}", e); + } + }); + group.add(&firejail_row); + + if !firejail_available { + let info_row = adw::ActionRow::builder() + .title("Install Firejail") + .subtitle("sudo apt install firejail") + .build(); + let badge = widgets::status_badge("Missing", "warning"); + badge.set_valign(gtk::Align::Center); + info_row.add_suffix(&badge); + group.add(&info_row); + } + + group } fn wayland_description(status: &WaylandStatus) -> &'static str { @@ -313,42 +490,34 @@ fn fuse_description(status: &FuseStatus) -> &'static str { } } -fn build_updates_section(record: &AppImageRecord) -> gtk::Box { - let section = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(8) +/// Group 2: Updates & Usage +fn build_updates_usage_group(record: &AppImageRecord, db: &Rc) -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder() + .title("Updates & Usage") + .description("Update status and launch statistics") .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 + // --- Updates --- if let Some(ref update_type) = record.update_type { + let display_label = updater::parse_update_info(update_type) + .map(|ut| ut.type_label_display()) + .unwrap_or("Unknown format"); let row = adw::ActionRow::builder() .title("Update method") - .subtitle(update_type) + .subtitle(display_label) .build(); - list_box.append(&row); + group.add(&row); } else { let row = adw::ActionRow::builder() .title("Update method") - .subtitle("No update information embedded") + .subtitle("This app cannot check for updates automatically") .build(); let badge = widgets::status_badge("None", "neutral"); badge.set_valign(gtk::Align::Center); row.add_suffix(&badge); - list_box.append(&row); + group.add(&row); } - // Latest version / update status if let Some(ref latest) = record.latest_version { let is_newer = record .app_version @@ -369,7 +538,7 @@ fn build_updates_section(record: &AppImageRecord) -> gtk::Box { let badge = widgets::status_badge("Update", "info"); badge.set_valign(gtk::Align::Center); row.add_suffix(&badge); - list_box.append(&row); + group.add(&row); } else { let row = adw::ActionRow::builder() .title("Status") @@ -378,91 +547,272 @@ fn build_updates_section(record: &AppImageRecord) -> gtk::Box { let badge = widgets::status_badge("Latest", "success"); badge.set_valign(gtk::Align::Center); row.add_suffix(&badge); - list_box.append(&row); + group.add(&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); + group.add(&row); } - section.append(&list_box); - section -} - -fn build_usage_section(record: &AppImageRecord, db: &Rc) -> 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); - + // --- Usage Statistics --- 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); + group.add(&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); + group.add(&row); } - section.append(&list_box); - section + group } -fn build_file_details_section(record: &AppImageRecord) -> gtk::Box { - let section = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(8) +/// Group 3: Security & Storage (Security + Disk Footprint + File Details) +fn build_security_storage_group( + record: &AppImageRecord, + db: &Rc, + toast_overlay: &adw::ToastOverlay, +) -> adw::PreferencesGroup { + let group = adw::PreferencesGroup::builder() + .title("Security & Storage") + .description("Vulnerability scanning, disk footprint, and file details") .build(); - let heading = gtk::Label::builder() - .label("File Details") - .css_classes(["heading"]) - .halign(gtk::Align::Start) + // --- Security --- + let libs = db.get_bundled_libraries(record.id).unwrap_or_default(); + let summary = db.get_cve_summary(record.id).unwrap_or_default(); + + if libs.is_empty() { + let row = adw::ActionRow::builder() + .title("Security scan") + .subtitle("Not yet scanned for vulnerabilities") + .build(); + let badge = widgets::status_badge("Not scanned", "neutral"); + badge.set_valign(gtk::Align::Center); + row.add_suffix(&badge); + group.add(&row); + } else { + let lib_row = adw::ActionRow::builder() + .title("Bundled libraries") + .subtitle(&libs.len().to_string()) + .build(); + group.add(&lib_row); + + if summary.total() == 0 { + let row = adw::ActionRow::builder() + .title("Vulnerabilities") + .subtitle("No known vulnerabilities") + .build(); + let badge = widgets::status_badge("Clean", "success"); + badge.set_valign(gtk::Align::Center); + row.add_suffix(&badge); + group.add(&row); + } else { + let row = adw::ActionRow::builder() + .title("Vulnerabilities") + .subtitle(&format!("{} found", summary.total())) + .build(); + let badge = widgets::status_badge(summary.max_severity(), summary.badge_class()); + badge.set_valign(gtk::Align::Center); + row.add_suffix(&badge); + group.add(&row); + } + } + + // Scan button + let scan_row = adw::ActionRow::builder() + .title("Scan this AppImage") + .subtitle("Check bundled libraries for known CVEs") + .activatable(true) .build(); - section.append(&heading); + let scan_icon = gtk::Image::from_icon_name("security-medium-symbolic"); + scan_icon.set_valign(gtk::Align::Center); + scan_row.add_suffix(&scan_icon); - let list_box = gtk::ListBox::new(); - list_box.add_css_class("boxed-list"); - list_box.set_selection_mode(gtk::SelectionMode::None); + let record_id = record.id; + let record_path = record.path.clone(); + scan_row.connect_activated(move |row| { + row.set_sensitive(false); + row.update_state(&[gtk::accessible::State::Busy(true)]); + row.set_subtitle("Scanning..."); + let row_clone = row.clone(); + let path = record_path.clone(); + glib::spawn_future_local(async move { + let result = gio::spawn_blocking(move || { + let bg_db = Database::open().expect("Failed to open database"); + let appimage_path = std::path::Path::new(&path); + security::scan_and_store(&bg_db, record_id, appimage_path) + }) + .await; - // Path + row_clone.set_sensitive(true); + row_clone.update_state(&[gtk::accessible::State::Busy(false)]); + match result { + Ok(scan_result) => { + let total = scan_result.total_cves(); + if total == 0 { + row_clone.set_subtitle("No vulnerabilities found"); + } else { + row_clone.set_subtitle(&format!( + "Found {} CVE{}", total, if total == 1 { "" } else { "s" } + )); + } + } + Err(_) => { + row_clone.set_subtitle("Scan failed"); + } + } + }); + }); + group.add(&scan_row); + + // --- Disk Footprint --- + let fp = footprint::get_footprint(db, record.id, record.size_bytes as u64); + + let appimage_row = adw::ActionRow::builder() + .title("AppImage file size") + .subtitle(&widgets::format_size(record.size_bytes)) + .build(); + group.add(&appimage_row); + + if !fp.paths.is_empty() { + let data_total = fp.data_total(); + if data_total > 0 { + let total_row = adw::ActionRow::builder() + .title("Total disk footprint") + .subtitle(&format!( + "{} (AppImage) + {} (data) = {}", + widgets::format_size(record.size_bytes), + widgets::format_size(data_total as i64), + widgets::format_size(fp.total_size() as i64), + )) + .build(); + group.add(&total_row); + } + } + + // Discover button + let discover_row = adw::ActionRow::builder() + .title("Discover data paths") + .subtitle("Search for config, data, and cache directories") + .activatable(true) + .build(); + let discover_icon = gtk::Image::from_icon_name("folder-saved-search-symbolic"); + discover_icon.set_valign(gtk::Align::Center); + discover_row.add_suffix(&discover_icon); + + let record_clone = record.clone(); + let record_id = record.id; + discover_row.connect_activated(move |row| { + row.set_sensitive(false); + row.set_subtitle("Discovering..."); + let row_clone = row.clone(); + let rec = record_clone.clone(); + glib::spawn_future_local(async move { + let result = gio::spawn_blocking(move || { + let bg_db = Database::open().expect("Failed to open database"); + footprint::discover_and_store(&bg_db, record_id, &rec); + footprint::get_footprint(&bg_db, record_id, rec.size_bytes as u64) + }) + .await; + + row_clone.set_sensitive(true); + match result { + Ok(fp) => { + let count = fp.paths.len(); + if count == 0 { + row_clone.set_subtitle("No associated paths found"); + } else { + row_clone.set_subtitle(&format!( + "Found {} path{} ({})", + count, + if count == 1 { "" } else { "s" }, + widgets::format_size(fp.data_total() as i64), + )); + } + } + Err(_) => { + row_clone.set_subtitle("Discovery failed"); + } + } + }); + }); + group.add(&discover_row); + + // Individual discovered paths with type icons and confidence badges + for dp in &fp.paths { + if dp.exists { + let row = adw::ActionRow::builder() + .title(dp.path_type.label()) + .subtitle(&*dp.path.to_string_lossy()) + .subtitle_selectable(true) + .build(); + let icon = gtk::Image::from_icon_name(dp.path_type.icon_name()); + icon.set_pixel_size(16); + row.add_prefix(&icon); + let conf_badge = widgets::status_badge( + dp.confidence.as_str(), + dp.confidence.badge_class(), + ); + conf_badge.set_valign(gtk::Align::Center); + row.add_suffix(&conf_badge); + let size_label = gtk::Label::builder() + .label(&widgets::format_size(dp.size_bytes as i64)) + .css_classes(["dimmed", "caption"]) + .valign(gtk::Align::Center) + .build(); + row.add_suffix(&size_label); + group.add(&row); + } + } + + // --- File Details --- + // Path with copy button let path_row = adw::ActionRow::builder() .title("Path") .subtitle(&record.path) .subtitle_selectable(true) .build(); - list_box.append(&path_row); + let copy_path_btn = widgets::copy_button(&record.path, Some(toast_overlay)); + copy_path_btn.set_valign(gtk::Align::Center); + path_row.add_suffix(©_path_btn); - // Size - let size_row = adw::ActionRow::builder() - .title("Size") - .subtitle(&widgets::format_size(record.size_bytes)) - .build(); - list_box.append(&size_row); + // Open folder button + let folder_path = std::path::Path::new(&record.path) + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + if !folder_path.is_empty() { + let open_folder_btn = gtk::Button::builder() + .icon_name("folder-open-symbolic") + .tooltip_text("Open containing folder") + .valign(gtk::Align::Center) + .build(); + open_folder_btn.add_css_class("flat"); + open_folder_btn.update_property(&[ + gtk::accessible::Property::Label("Open containing folder"), + ]); + let folder = folder_path.clone(); + open_folder_btn.connect_clicked(move |_| { + let file = gio::File::for_path(&folder); + let launcher = gtk::FileLauncher::new(Some(&file)); + launcher.open_containing_folder(gtk::Window::NONE, None::<&gio::Cancellable>, |_| {}); + }); + path_row.add_suffix(&open_folder_btn); + } + group.add(&path_row); // Type let type_str = match record.appimage_type { @@ -473,24 +823,30 @@ fn build_file_details_section(record: &AppImageRecord) -> gtk::Box { let type_row = adw::ActionRow::builder() .title("AppImage type") .subtitle(type_str) + .tooltip_text("Type 1 uses ISO9660, Type 2 uses SquashFS") .build(); - list_box.append(&type_row); + group.add(&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); + group.add(&exec_row); - // SHA256 + // SHA256 with copy button if let Some(ref hash) = record.sha256 { let hash_row = adw::ActionRow::builder() - .title("SHA256") + .title("SHA256 checksum") .subtitle(hash) .subtitle_selectable(true) + .tooltip_text("Cryptographic hash for verifying file integrity") .build(); - list_box.append(&hash_row); + hash_row.add_css_class("monospace"); + let copy_hash_btn = widgets::copy_button(hash, Some(toast_overlay)); + copy_hash_btn.set_valign(gtk::Align::Center); + hash_row.add_suffix(©_hash_btn); + group.add(&hash_row); } // First seen @@ -498,14 +854,14 @@ fn build_file_details_section(record: &AppImageRecord) -> gtk::Box { .title("First seen") .subtitle(&record.first_seen) .build(); - list_box.append(&seen_row); + group.add(&seen_row); // Last scanned let scanned_row = adw::ActionRow::builder() .title("Last scanned") .subtitle(&record.last_scanned) .build(); - list_box.append(&scanned_row); + group.add(&scanned_row); // Notes if let Some(ref notes) = record.notes { @@ -514,10 +870,9 @@ fn build_file_details_section(record: &AppImageRecord) -> gtk::Box { .title("Notes") .subtitle(notes) .build(); - list_box.append(&row); + group.add(&row); } } - section.append(&list_box); - section + group }