use adw::prelude::*; use std::cell::Cell; use std::rc::Rc; use gtk::gio; use crate::core::database::{AppImageRecord, Database}; use crate::core::footprint; use crate::core::fuse::{self, FuseStatus}; use crate::core::integrator; 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) .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(); // Banner: App identity (not a boxed group) content.append(&build_banner(record)); // Group 1: System Integration (Desktop Integration + Runtime Compatibility + Sandboxing) content.append(&build_system_integration_group(record, db)); // Group 2: Updates & Usage content.append(&build_updates_usage_group(record, db)); // Group 3: Security & Storage content.append(&build_security_storage_group(record, db, &toast_overlay)); clamp.set_child(Some(&content)); let scrolled = gtk::ScrolledWindow::builder() .child(&clamp) .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(); 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 { 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); } } }); 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(&toast_overlay)); adw::NavigationPage::builder() .title(name) .tag("detail") .child(&toolbar) .build() } /// 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(); banner.add_css_class("detail-banner"); banner.set_accessible_role(gtk::AccessibleRole::Banner); let name = record.app_name.as_deref().unwrap_or(&record.filename); // 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(); let name_label = gtk::Label::builder() .label(name) .css_classes(["title-1"]) .halign(gtk::Align::Start) .build(); text_col.append(&name_label); // 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(); text_col.append(&meta_label); } // Description if let Some(ref desc) = record.description { if !desc.is_empty() { let desc_label = gtk::Label::builder() .label(desc) .css_classes(["body"]) .halign(gtk::Align::Start) .wrap(true) .xalign(0.0) .build(); text_col.append(&desc_label); } } // 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")); } 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())); } } 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 } /// 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(); // --- Desktop Integration --- 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(); 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() { 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); // 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) .subtitle_selectable(true) .build(); row.add_css_class("monospace"); group.add(&row); } } // --- Runtime Compatibility --- 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)) .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); group.add(&wayland_row); // 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(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_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); group.add(&fuse_row); // 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 { 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", } } /// 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(); // --- 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(display_label) .build(); group.add(&row); } else { let row = adw::ActionRow::builder() .title("Update method") .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); group.add(&row); } 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); group.add(&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); group.add(&row); } } if let Some(ref checked) = record.update_checked { let row = adw::ActionRow::builder() .title("Last checked") .subtitle(checked) .build(); group.add(&row); } // --- 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(); group.add(&launches_row); if let Some(ref last) = stats.last_launched { let row = adw::ActionRow::builder() .title("Last launched") .subtitle(last) .build(); group.add(&row); } group } /// 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(); // --- 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(); 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 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; 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(); 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); // 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 { Some(1) => "Type 1", Some(2) => "Type 2", _ => "Unknown", }; let type_row = adw::ActionRow::builder() .title("AppImage type") .subtitle(type_str) .tooltip_text("Type 1 uses ISO9660, Type 2 uses SquashFS") .build(); group.add(&type_row); // Executable let exec_row = adw::ActionRow::builder() .title("Executable") .subtitle(if record.is_executable { "Yes" } else { "No" }) .build(); group.add(&exec_row); // SHA256 with copy button if let Some(ref hash) = record.sha256 { let hash_row = adw::ActionRow::builder() .title("SHA256 checksum") .subtitle(hash) .subtitle_selectable(true) .tooltip_text("Cryptographic hash for verifying file integrity") .build(); 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 let seen_row = adw::ActionRow::builder() .title("First seen") .subtitle(&record.first_seen) .build(); group.add(&seen_row); // Last scanned let scanned_row = adw::ActionRow::builder() .title("Last scanned") .subtitle(&record.last_scanned) .build(); group.add(&scanned_row); // Notes if let Some(ref notes) = record.notes { if !notes.is_empty() { let row = adw::ActionRow::builder() .title("Notes") .subtitle(notes) .build(); group.add(&row); } } group }