use adw::prelude::*; use std::cell::Cell; use std::io::Read as _; use std::rc::Rc; use gtk::gio; use crate::core::backup; 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::notification; 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(); // ViewStack for tabbed content (transitions disabled for instant switching). // vhomogeneous=false so the stack sizes to the visible child only, // preventing shorter tabs from having excess scrollable empty space. let view_stack = adw::ViewStack::new(); view_stack.set_vhomogeneous(false); view_stack.set_enable_transitions(false); // Build tab pages let overview_page = build_overview_tab(record, db); view_stack.add_titled(&overview_page, Some("overview"), "Overview"); view_stack.page(&overview_page).set_icon_name(Some("info-symbolic")); let system_page = build_system_tab(record, db, &toast_overlay); view_stack.add_titled(&system_page, Some("system"), "System"); view_stack.page(&system_page).set_icon_name(Some("system-run-symbolic")); let security_page = build_security_tab(record, db); view_stack.add_titled(&security_page, Some("security"), "Security"); view_stack.page(&security_page).set_icon_name(Some("security-medium-symbolic")); let storage_page = build_storage_tab(record, db, &toast_overlay); view_stack.add_titled(&storage_page, Some("storage"), "Storage"); view_stack.page(&storage_page).set_icon_name(Some("drive-harddisk-symbolic")); // Restore last-used tab from GSettings let settings = gio::Settings::new(crate::config::APP_ID); let saved_tab = settings.string("detail-tab"); if view_stack.child_by_name(&saved_tab).is_some() { view_stack.set_visible_child_name(&saved_tab); } // Persist tab choice on switch view_stack.connect_visible_child_name_notify(move |stack| { if let Some(name) = stack.visible_child_name() { settings.set_string("detail-tab", &name).ok(); } }); // Banner scrolls with content (not sticky) so tall banners don't eat space let scroll_content = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .build(); scroll_content.append(&build_banner(record)); scroll_content.append(&view_stack); let scrolled = gtk::ScrolledWindow::builder() .child(&scroll_content) .vexpand(true) .build(); toast_overlay.set_child(Some(&scrolled)); // Header bar with ViewSwitcher as title widget (standard GNOME pattern) let header = adw::HeaderBar::new(); let switcher = adw::ViewSwitcher::builder() .stack(&view_stack) .policy(adw::ViewSwitcherPolicy::Wide) .build(); header.set_title_widget(Some(&switcher)); // Launch button 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 app_name_launch = record.app_name.clone().unwrap_or_else(|| record.filename.clone()); let launch_args_raw = record.launch_args.clone(); let db_launch = db.clone(); let toast_launch = toast_overlay.clone(); launch_button.connect_clicked(move |btn| { btn.set_sensitive(false); let btn_ref = btn.clone(); let path = path.clone(); let app_name = app_name_launch.clone(); let db_launch = db_launch.clone(); let toast_ref = toast_launch.clone(); let launch_args = launcher::parse_launch_args(launch_args_raw.as_deref()); glib::spawn_future_local(async move { let path_bg = path.clone(); let result = gio::spawn_blocking(move || { let appimage_path = std::path::Path::new(&path_bg); launcher::launch_appimage( &Database::open().expect("DB open"), record_id, appimage_path, "gui_detail", &launch_args, &[], ) }).await; btn_ref.set_sensitive(true); match result { Ok(launcher::LaunchResult::Started { pid, method }) => { log::info!("Launched: {} (PID: {}, method: {})", path, pid, method.as_str()); // App survived startup - do Wayland analysis after a delay let db_wayland = db_launch.clone(); let path_clone = path.clone(); glib::spawn_future_local(async move { glib::timeout_future(std::time::Duration::from_secs(3)).await; let analysis_result = gio::spawn_blocking(move || { wayland::analyze_running_process(pid) }).await; if let Ok(Ok(analysis)) = analysis_result { let status_str = analysis.as_status_str(); log::info!( "Runtime Wayland: {} -> {} (pid={}, env: {:?})", path_clone, analysis.status_label(), analysis.pid, analysis.env_vars, ); db_wayland.update_runtime_wayland_status(record_id, status_str).ok(); } }); } Ok(launcher::LaunchResult::Crashed { exit_code, stderr, method }) => { log::error!("App crashed on launch (exit {}, method: {}): {}", exit_code.unwrap_or(-1), method.as_str(), stderr); widgets::show_crash_dialog(&btn_ref, &app_name, exit_code, &stderr); if let Some(app) = gtk::gio::Application::default() { notification::send_system_notification( &app, &format!("crash-{}", record_id), &format!("{} crashed", app_name), &stderr.chars().take(200).collect::(), gtk::gio::NotificationPriority::Urgent, ); } } Ok(launcher::LaunchResult::Failed(msg)) => { log::error!("Failed to launch: {}", msg); let toast = adw::Toast::builder() .title(&format!("Could not launch: {}", msg)) .timeout(5) .build(); toast_ref.add_toast(toast); } Err(_) => { log::error!("Launch task panicked"); } } }); }); 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() } // --------------------------------------------------------------------------- // Banner // --------------------------------------------------------------------------- fn build_banner(record: &AppImageRecord) -> gtk::Box { let banner = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(16) .margin_start(18) .margin_end(18) .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 with drop shadow let icon = widgets::app_icon(record.icon_path.as_deref(), name, 96); icon.set_valign(gtk::Align::Start); icon.add_css_class("icon-dropshadow"); 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(); badge_box.append(&widgets::integration_badge(record.integrated)); 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 } // --------------------------------------------------------------------------- // Tab 1: Overview - about, description, links, updates, releases, usage, // capabilities, file info // --------------------------------------------------------------------------- fn build_overview_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { let tab = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(24) .margin_top(18) .margin_bottom(24) .margin_start(18) .margin_end(18) .build(); let clamp = adw::Clamp::builder() .maximum_size(800) .tightening_threshold(600) .build(); let inner = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(24) .build(); // ----------------------------------------------------------------------- // About section // ----------------------------------------------------------------------- let has_about_data = record.appstream_id.is_some() || record.generic_name.is_some() || record.developer.is_some() || record.license.is_some() || record.project_group.is_some(); if has_about_data { let about_group = adw::PreferencesGroup::builder() .title("About") .build(); if let Some(ref id) = record.appstream_id { let row = adw::ActionRow::builder() .title("App ID") .subtitle(id) .subtitle_selectable(true) .build(); about_group.add(&row); } if let Some(ref gn) = record.generic_name { let row = adw::ActionRow::builder() .title("Type") .subtitle(gn) .build(); about_group.add(&row); } if let Some(ref dev) = record.developer { let row = adw::ActionRow::builder() .title("Developer") .subtitle(dev) .build(); about_group.add(&row); } if let Some(ref lic) = record.license { let row = adw::ActionRow::builder() .title("License") .subtitle(lic) .tooltip_text("The license that governs how this app can be used and shared") .build(); about_group.add(&row); } if let Some(ref pg) = record.project_group { let row = adw::ActionRow::builder() .title("Project") .subtitle(pg) .build(); about_group.add(&row); } inner.append(&about_group); } // ----------------------------------------------------------------------- // Description section // ----------------------------------------------------------------------- if let Some(ref desc) = record.appstream_description { if !desc.is_empty() { let desc_group = adw::PreferencesGroup::builder() .title("Description") .build(); let label = gtk::Label::builder() .label(desc) .wrap(true) .xalign(0.0) .css_classes(["body"]) .selectable(true) .margin_top(8) .margin_bottom(8) .margin_start(12) .margin_end(12) .build(); desc_group.add(&label); inner.append(&desc_group); } } // ----------------------------------------------------------------------- // Screenshots section - async image loading from URLs, click to lightbox // ----------------------------------------------------------------------- if let Some(ref urls_str) = record.screenshot_urls { let urls: Vec<&str> = urls_str.lines().filter(|u| !u.is_empty()).collect(); if !urls.is_empty() { let screenshots_group = adw::PreferencesGroup::builder() .title("Screenshots") .build(); let carousel = adw::Carousel::builder() .hexpand(true) .allow_scroll_wheel(true) .allow_mouse_drag(true) .build(); carousel.set_height_request(300); let dots = adw::CarouselIndicatorDots::builder() .carousel(&carousel) .build(); // Store textures for lightbox access let textures: Rc>>> = Rc::new(std::cell::RefCell::new(vec![None; urls.len()])); for (idx, url) in urls.iter().enumerate() { let picture = gtk::Picture::builder() .content_fit(gtk::ContentFit::Contain) .height_request(300) .build(); if let Some(cursor) = gtk::gdk::Cursor::from_name("pointer", None) { picture.set_cursor(Some(&cursor)); } picture.set_can_shrink(true); // Placeholder spinner while loading let overlay = gtk::Overlay::builder().child(&picture).build(); let spinner = adw::Spinner::builder() .width_request(32) .height_request(32) .halign(gtk::Align::Center) .valign(gtk::Align::Center) .build(); overlay.add_overlay(&spinner); // Don't let overlays steal focus (prevents scroll jump on dialog close) overlay.set_focusable(false); overlay.set_focus_on_click(false); // Click handler for lightbox let textures_click = textures.clone(); let click = gtk::GestureClick::new(); click.connect_released(move |gesture, _, _, _| { let textures_ref = textures_click.borrow(); if textures_ref.iter().any(|t| t.is_some()) { if let Some(widget) = gesture.widget() { if let Some(root) = gtk::prelude::WidgetExt::root(&widget) { if let Ok(window) = root.downcast::() { show_screenshot_lightbox( &window, &textures_click, idx, ); } } } } }); overlay.add_controller(click); carousel.append(&overlay); // Load image asynchronously let url_owned = url.to_string(); let picture_ref = picture.clone(); let spinner_ref = spinner.clone(); let textures_load = textures.clone(); glib::spawn_future_local(async move { let result = gio::spawn_blocking(move || { let mut response = ureq::get(&url_owned) .header("User-Agent", "Driftwood-AppImage-Manager/0.1") .call() .ok()?; let mut buf = Vec::new(); response.body_mut().as_reader().read_to_end(&mut buf).ok()?; Some(buf) }) .await; spinner_ref.set_visible(false); if let Ok(Some(data)) = result { let gbytes = glib::Bytes::from(&data); if let Ok(texture) = gtk::gdk::Texture::from_bytes(&gbytes) { picture_ref.set_paintable(Some(&texture)); if let Some(slot) = textures_load.borrow_mut().get_mut(idx) { *slot = Some(texture); } } } }); } let carousel_box = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(8) .margin_top(8) .margin_bottom(8) .build(); carousel_box.append(&carousel); if urls.len() > 1 { carousel_box.append(&dots); } screenshots_group.add(&carousel_box); inner.append(&screenshots_group); } } // ----------------------------------------------------------------------- // Links section // ----------------------------------------------------------------------- // Use source_url as fallback for vcs_url (auto-detected from update_info) let source_code_url = record.vcs_url.clone().or_else(|| record.source_url.clone()); let has_links = record.homepage_url.is_some() || record.bugtracker_url.is_some() || record.donation_url.is_some() || record.help_url.is_some() || source_code_url.is_some(); if has_links { let links_group = adw::PreferencesGroup::builder() .title("Links") .build(); let link_entries: &[(&str, &str, &Option)] = &[ ("Homepage", "web-browser-symbolic", &record.homepage_url), ("Report a problem", "bug-symbolic", &record.bugtracker_url), ("Source code", "code-symbolic", &source_code_url), ("Documentation", "help-browser-symbolic", &record.help_url), ("Donate", "emblem-favorite-symbolic", &record.donation_url), ]; for (title, icon_name, url_opt) in link_entries { if let Some(ref url) = url_opt { let row = adw::ActionRow::builder() .title(*title) .subtitle(url) .activatable(true) .build(); let icon = gtk::Image::from_icon_name("external-link-symbolic"); icon.set_valign(gtk::Align::Center); row.add_suffix(&icon); // Start with the fallback icon, then try to load favicon let prefix_icon = gtk::Image::from_icon_name(*icon_name); prefix_icon.set_valign(gtk::Align::Center); prefix_icon.set_pixel_size(16); row.add_prefix(&prefix_icon); fetch_favicon_async(url, &prefix_icon); let url_clone = url.clone(); row.connect_activated(move |row| { let launcher = gtk::UriLauncher::new(&url_clone); let window = row .root() .and_then(|r| r.downcast::().ok()); launcher.launch( window.as_ref(), None::<>k::gio::Cancellable>, |_| {}, ); }); links_group.add(&row); } } inner.append(&links_group); } // ----------------------------------------------------------------------- // Updates section // ----------------------------------------------------------------------- let updates_group = adw::PreferencesGroup::builder() .title("Updates") .description("Keep this app up to date by checking for new versions.") .build(); 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(&format!( "This app checks for updates using: {}", display_label )) .tooltip_text( "Driftwood can check for newer versions of this app automatically. \ The developer has included information about where updates are published." ) .build(); updates_group.add(&row); } else { let row = adw::ActionRow::builder() .title("Update method") .subtitle( "This app does not include update information. \ You will need to check for new versions manually." ) .tooltip_text( "AppImages can include built-in update information that tells Driftwood \ where to check for newer versions. This one doesn't have any, so you'll \ need to download updates yourself from wherever you got the app." ) .build(); let badge = widgets::status_badge("Manual only", "neutral"); badge.set_valign(gtk::Align::Center); row.add_suffix(&badge); updates_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!( "A newer version is available: {} (you have {})", latest, record.app_version.as_deref().unwrap_or("unknown"), ); 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); updates_group.add(&row); } else { let row = adw::ActionRow::builder() .title("Version status") .subtitle("You are running the latest version.") .build(); let badge = widgets::status_badge("Latest", "success"); badge.set_valign(gtk::Align::Center); row.add_suffix(&badge); updates_group.add(&row); } } if let Some(ref checked) = record.update_checked { let row = adw::ActionRow::builder() .title("Last checked") .subtitle(checked) .build(); updates_group.add(&row); } inner.append(&updates_group); // ----------------------------------------------------------------------- // Release History section // ----------------------------------------------------------------------- if let Some(ref release_json) = record.release_history { if let Ok(releases) = serde_json::from_str::>(release_json) { if !releases.is_empty() { let release_group = adw::PreferencesGroup::builder() .title("Release History") .description("Recent versions of this application.") .build(); for release in releases.iter().take(10) { let version = release .get("version") .and_then(|v| v.as_str()) .unwrap_or("?"); let date = release .get("date") .and_then(|v| v.as_str()) .unwrap_or(""); let desc = release.get("description").and_then(|v| v.as_str()); let title = if date.is_empty() { format!("v{}", version) } else { format!("v{} - {}", version, date) }; if let Some(desc_text) = desc { let row = adw::ExpanderRow::builder() .title(&title) .subtitle("Click to see changes") .build(); let label = gtk::Label::builder() .label(desc_text) .wrap(true) .xalign(0.0) .css_classes(["body"]) .margin_top(8) .margin_bottom(8) .margin_start(12) .margin_end(12) .build(); let label_row = gtk::ListBoxRow::builder() .activatable(false) .selectable(false) .child(&label) .build(); row.add_row(&label_row); release_group.add(&row); } else { let row = adw::ActionRow::builder() .title(&title) .build(); release_group.add(&row); } } inner.append(&release_group); } } } // ----------------------------------------------------------------------- // Usage section // ----------------------------------------------------------------------- let usage_group = adw::PreferencesGroup::builder() .title("Usage") .build(); 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(); usage_group.add(&launches_row); if let Some(ref last) = stats.last_launched { let row = adw::ActionRow::builder() .title("Last launched") .subtitle(last) .build(); usage_group.add(&row); } inner.append(&usage_group); // ----------------------------------------------------------------------- // Capabilities section // ----------------------------------------------------------------------- let has_capabilities = record.keywords.is_some() || record.mime_types.is_some() || record.content_rating.is_some() || record.desktop_actions.is_some(); if has_capabilities { let cap_group = adw::PreferencesGroup::builder() .title("Features") .build(); if let Some(ref kw) = record.keywords { if !kw.is_empty() { let row = adw::ActionRow::builder() .title("Keywords") .subtitle(kw) .build(); cap_group.add(&row); } } if let Some(ref mt) = record.mime_types { if !mt.is_empty() { let row = adw::ActionRow::builder() .title("Supported file types") .subtitle(mt) .build(); cap_group.add(&row); } } if let Some(ref cr) = record.content_rating { let row = adw::ActionRow::builder() .title("Content rating") .subtitle(cr) .tooltip_text( "An age rating for the app's content, similar to game ratings.", ) .build(); cap_group.add(&row); } if let Some(ref actions_json) = record.desktop_actions { if let Ok(actions) = serde_json::from_str::>(actions_json) { if !actions.is_empty() { let row = adw::ActionRow::builder() .title("Quick actions") .subtitle(&actions.join(", ")) .tooltip_text( "Additional actions available from the right-click menu \ when this app is added to your app menu.", ) .build(); cap_group.add(&row); } } } inner.append(&cap_group); } // ----------------------------------------------------------------------- // File Information section // ----------------------------------------------------------------------- let info_group = adw::PreferencesGroup::builder() .title("File Information") .build(); let type_str = match record.appimage_type { Some(1) => "Type 1 - older format, still widely supported", Some(2) => "Type 2 - modern, compressed format", _ => "Unknown type", }; let type_row = adw::ActionRow::builder() .title("Package format") .subtitle(type_str) .tooltip_text( "AppImages come in two formats. Type 1 is the older format. \ Type 2 is the current standard - it uses compression for smaller \ files and faster loading." ) .build(); info_group.add(&type_row); let exec_row = adw::ActionRow::builder() .title("Ready to run") .subtitle(if record.is_executable { "Yes - this file is ready to launch" } else { "No - will be fixed automatically when launched" }) .tooltip_text( "Whether the file has the permissions needed to run. \ If not, Driftwood sets this up automatically the first time you launch it." ) .build(); info_group.add(&exec_row); // Digital signature status let sig_row = adw::ActionRow::builder() .title("Digital signature") .subtitle(if record.has_signature { "Signed by the developer" } else { "Not signed" }) .tooltip_text( "This app was signed by its developer, which helps verify \ it hasn't been tampered with since it was published." ) .build(); let sig_badge = if record.has_signature { widgets::status_badge("Signed", "success") } else { widgets::status_badge("Unsigned", "neutral") }; sig_badge.set_valign(gtk::Align::Center); sig_row.add_suffix(&sig_badge); info_group.add(&sig_row); let seen_row = adw::ActionRow::builder() .title("First seen") .subtitle(&record.first_seen) .build(); info_group.add(&seen_row); let scanned_row = adw::ActionRow::builder() .title("Last checked") .subtitle(&record.last_scanned) .build(); info_group.add(&scanned_row); if let Some(ref notes) = record.notes { if !notes.is_empty() { let row = adw::ActionRow::builder() .title("Notes") .subtitle(notes) .build(); info_group.add(&row); } } inner.append(&info_group); clamp.set_child(Some(&inner)); tab.append(&clamp); tab } // --------------------------------------------------------------------------- // Tab 2: System - integration, compatibility, sandboxing // --------------------------------------------------------------------------- fn build_system_tab(record: &AppImageRecord, db: &Rc, toast_overlay: &adw::ToastOverlay) -> gtk::Box { let tab = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(24) .margin_top(18) .margin_bottom(24) .margin_start(18) .margin_end(18) .build(); let clamp = adw::Clamp::builder() .maximum_size(800) .tightening_threshold(600) .build(); let inner = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(24) .build(); // Desktop Integration group let integration_group = adw::PreferencesGroup::builder() .title("App Menu") .description( "Add this app to your launcher so you can find it \ like any other installed app." ) .build(); let switch_row = adw::SwitchRow::builder() .title("Add to application menu") .subtitle("Creates a shortcut and installs the app icon") .active(record.integrated) .tooltip_text( "This makes the app appear in your Activities menu and app launcher, \ just like a regular installed app. It creates a shortcut file and \ copies the app's icon to your system." ) .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::undo_all_modifications(&db_ref, record_id).ok(); integrator::remove_integration(&record_clone).ok(); db_ref.set_integrated(record_id, false, None).ok(); } }); integration_group.add(&switch_row); if record.integrated { if let Some(ref desktop_file) = record.desktop_file { let row = adw::ActionRow::builder() .title("Shortcut file") .subtitle(desktop_file) .subtitle_selectable(true) .build(); row.add_css_class("property"); integration_group.add(&row); } } // Autostart toggle let autostart_row = adw::SwitchRow::builder() .title("Start at login") .subtitle("Launch this app automatically when you log in") .active(record.autostart) .tooltip_text( "Creates an autostart entry so this app launches \ when you log in to your desktop." ) .build(); let record_autostart = record.clone(); let db_autostart = db.clone(); let toast_autostart = toast_overlay.clone(); let record_id_as = record.id; autostart_row.connect_active_notify(move |row| { if row.is_active() { match integrator::enable_autostart(&db_autostart, &record_autostart) { Ok(path) => { log::info!("Autostart enabled: {}", path.display()); toast_autostart.add_toast(adw::Toast::new("Will start at login")); } Err(e) => { log::error!("Failed to enable autostart: {}", e); toast_autostart.add_toast(adw::Toast::new("Failed to enable autostart")); } } } else { integrator::disable_autostart(&db_autostart, record_id_as).ok(); toast_autostart.add_toast(adw::Toast::new("Autostart disabled")); } }); integration_group.add(&autostart_row); // StartupWMClass row with editable override let wm_class_row = adw::EntryRow::builder() .title("StartupWMClass") .text(record.startup_wm_class.as_deref().unwrap_or("")) .show_apply_button(true) .build(); let db_wm = db.clone(); let record_id_wm = record.id; let toast_wm = toast_overlay.clone(); wm_class_row.connect_apply(move |row| { let text = row.text().to_string(); let value = if text.is_empty() { None } else { Some(text.as_str()) }; match db_wm.set_startup_wm_class(record_id_wm, value) { Ok(()) => toast_wm.add_toast(adw::Toast::new("WM class updated")), Err(e) => { log::error!("Failed to set WM class: {}", e); toast_wm.add_toast(adw::Toast::new("Failed to update WM class")); } } }); integration_group.add(&wm_class_row); inner.append(&integration_group); // Version Rollback group if let Some(ref prev_path) = record.previous_version_path { let prev = std::path::Path::new(prev_path); if prev.exists() { let rollback_group = adw::PreferencesGroup::builder() .title("Version Rollback") .description("A previous version is available from the last update.") .build(); let rollback_row = adw::ActionRow::builder() .title("Previous version available") .subtitle(prev_path.as_str()) .build(); let rollback_btn = gtk::Button::builder() .label("Rollback") .valign(gtk::Align::Center) .css_classes(["destructive-action"]) .build(); rollback_row.add_suffix(&rollback_btn); let current_path = record.path.clone(); let prev_path_owned = prev_path.clone(); let record_id_rb = record.id; let db_rb = db.clone(); let toast_rb = toast_overlay.clone(); rollback_btn.connect_clicked(move |btn| { let current = std::path::Path::new(¤t_path); let prev = std::path::Path::new(&prev_path_owned); let temp_path = current.with_extension("AppImage.rollback-tmp"); // Swap: current -> tmp, prev -> current, tmp -> prev let result = std::fs::rename(current, &temp_path) .and_then(|_| std::fs::rename(prev, current)) .and_then(|_| std::fs::rename(&temp_path, prev)); match result { Ok(()) => { db_rb.set_previous_version(record_id_rb, Some(&prev_path_owned)).ok(); toast_rb.add_toast(adw::Toast::new("Rolled back to previous version")); btn.set_sensitive(false); } Err(e) => { log::error!("Rollback failed: {}", e); toast_rb.add_toast(adw::Toast::new("Rollback failed")); } } }); rollback_group.add(&rollback_row); inner.append(&rollback_group); } } // File type associations group if let Some(ref mime_str) = record.mime_types { let types: Vec<&str> = mime_str.split(';').filter(|s| !s.is_empty()).collect(); if !types.is_empty() { let mime_group = adw::PreferencesGroup::builder() .title("File Type Associations") .description("MIME types this app can handle. Set as default to open these files with this app.") .build(); let app_id = integrator::make_app_id( record.app_name.as_deref().unwrap_or(&record.filename), ); for mime_type in &types { let row = adw::ActionRow::builder() .title(*mime_type) .build(); let set_btn = gtk::Button::builder() .label("Set Default") .valign(gtk::Align::Center) .build(); set_btn.add_css_class("flat"); let db_mime = db.clone(); let record_id = record.id; let app_id_clone = app_id.clone(); let mime = mime_type.to_string(); let toast_mime = toast_overlay.clone(); set_btn.connect_clicked(move |btn| { match integrator::set_mime_default( &db_mime, record_id, &app_id_clone, &mime, ) { Ok(()) => { toast_mime.add_toast(adw::Toast::new( &format!("Set as default for {}", mime), )); btn.set_sensitive(false); btn.set_label("Default"); } Err(e) => { log::error!("Failed to set MIME default: {}", e); toast_mime.add_toast(adw::Toast::new("Failed to set default")); } } }); row.add_suffix(&set_btn); mime_group.add(&row); } inner.append(&mime_group); } } // Runtime Compatibility group let compat_group = adw::PreferencesGroup::builder() .title("Compatibility") .description( "How well this app works with your system. \ Most issues can be fixed with a quick install." ) .build(); let wayland_status = record .wayland_status .as_deref() .map(WaylandStatus::from_str) .unwrap_or(WaylandStatus::Unknown); let wayland_row = adw::ActionRow::builder() .title("Display compatibility") .subtitle(wayland_user_explanation(&wayland_status)) .tooltip_text( "Wayland is the modern display system on Linux. Apps built for the \ older system (X11) still work, but native Wayland apps look sharper, \ especially on high-resolution screens." ) .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); compat_group.add(&wayland_row); // Analyze toolkit button let analyze_row = adw::ActionRow::builder() .title("Detect app framework") .subtitle("Check which technology this app is built with") .activatable(true) .tooltip_text( "Apps are built with different frameworks (like GTK, Qt, or Electron). \ Knowing the framework helps predict how well the app works with \ your display system." ) .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!( "Built with: {}", toolkit_label, )); } Err(_) => { row_clone.set_subtitle("Analysis failed - could not read the app's contents"); } } }); }); compat_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("Last display mode") .subtitle(&format!( "When this app was last launched, it used: {}", runtime_status )) .tooltip_text( "How the app connected to your display the last time it was launched." ) .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); } compat_group.add(&runtime_row); } // FUSE status - always use live system detection (the stored fuse_status // is per-app AppImageFuseStatus, not the system-level FuseStatus) let fuse_system = fuse::detect_system_fuse(); let fuse_status = fuse_system.status.clone(); let fuse_row = adw::ActionRow::builder() .title("App mounting") .subtitle(fuse_user_explanation(&fuse_status)) .tooltip_text( "FUSE lets apps like AppImages run directly without unpacking first. \ Without it, apps still work but take a little longer to start." ) .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); if let Some(cmd) = fuse_install_command(&fuse_status) { let copy_btn = widgets::copy_button(cmd, Some(toast_overlay)); copy_btn.set_valign(gtk::Align::Center); copy_btn.set_tooltip_text(Some("Copy install command to clipboard")); fuse_row.add_suffix(©_btn); } compat_group.add(&fuse_row); // Per-app 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("Startup method") .subtitle(&format!( "This app will launch using: {}", app_fuse_status.label() )) .tooltip_text( "AppImages can start two ways: mounting (fast, instant startup) or \ unpacking to a temporary folder first (slower, but works everywhere). \ The method is chosen automatically based on your system." ) .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); compat_group.add(&launch_method_row); inner.append(&compat_group); // Sandboxing group let sandbox_group = adw::PreferencesGroup::builder() .title("App Isolation") .description( "Restrict what this app can access on your system \ for extra security." ) .build(); 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!("Currently: {}", current_mode.display_label()) } else { "Not available yet. Install with one command using the button below.".to_string() }; let firejail_row = adw::SwitchRow::builder() .title("Isolate this app") .subtitle(&sandbox_subtitle) .active(current_mode == SandboxMode::Firejail) .sensitive(firejail_available) .tooltip_text( "Sandboxing restricts what an app can access - files, network, \ devices, etc. Even if an app has a security issue, it can't \ freely access your personal data." ) .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); } }); sandbox_group.add(&firejail_row); if !firejail_available { let firejail_cmd = "sudo apt install firejail"; let info_row = adw::ActionRow::builder() .title("Install app isolation") .subtitle("Install with one command") .build(); let badge = widgets::status_badge("Missing", "warning"); badge.set_valign(gtk::Align::Center); info_row.add_suffix(&badge); let copy_btn = widgets::copy_button(firejail_cmd, Some(toast_overlay)); copy_btn.set_valign(gtk::Align::Center); copy_btn.set_tooltip_text(Some("Copy install command to clipboard")); info_row.add_suffix(©_btn); sandbox_group.add(&info_row); } inner.append(&sandbox_group); clamp.set_child(Some(&inner)); tab.append(&clamp); tab } // --------------------------------------------------------------------------- // Tab 3: Security - vulnerability scanning and integrity // --------------------------------------------------------------------------- fn build_security_tab(record: &AppImageRecord, db: &Rc) -> gtk::Box { let tab = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(24) .margin_top(18) .margin_bottom(24) .margin_start(18) .margin_end(18) .build(); let clamp = adw::Clamp::builder() .maximum_size(800) .tightening_threshold(600) .build(); let inner = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(24) .build(); let group = adw::PreferencesGroup::builder() .title("Security Check") .description( "Check this app for known security issues." ) .build(); 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( "This app has not been scanned yet. Use the button below \ to check for known 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("Included components") .subtitle(&format!( "{} components found inside this app", libs.len() )) .tooltip_text( "Apps bundle their own copies of system components (libraries). \ These can sometimes contain known security issues if they're outdated." ) .build(); group.add(&lib_row); if summary.total() == 0 { let row = adw::ActionRow::builder() .title("Vulnerabilities") .subtitle("No known security issues found.") .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!( "{} known issue{} found. Consider updating this app if a newer version is available.", summary.total(), if summary.total() == 1 { "" } else { "s" }, )) .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("Run security check") .subtitle("Check for known security issues in this app") .activatable(true) .tooltip_text( "This checks the components inside this app against a public database \ of known security issues to see if any are outdated or vulnerable." ) .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 - this may take a moment..."); 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 - looking good!"); } else { row_clone.set_subtitle(&format!( "Found {} known issue{}. Check for app updates.", total, if total == 1 { "" } else { "s" }, )); } } Err(_) => { row_clone.set_subtitle("Check failed - could not read the app's contents"); } } }); }); group.add(&scan_row); inner.append(&group); // Integrity group if record.sha256.is_some() { let integrity_group = adw::PreferencesGroup::builder() .title("Integrity") .description("Verify that the file has not been modified or corrupted.") .build(); if let Some(ref hash) = record.sha256 { let hash_row = adw::ActionRow::builder() .title("File fingerprint") .subtitle(hash) .subtitle_selectable(true) .tooltip_text( "A unique code (SHA256 checksum) generated from the file's contents. \ If the file changes in any way, this code changes too. You can compare \ it against the developer's published fingerprint to verify nothing \ was altered." ) .build(); hash_row.add_css_class("property"); integrity_group.add(&hash_row); } inner.append(&integrity_group); } clamp.set_child(Some(&inner)); tab.append(&clamp); tab } // --------------------------------------------------------------------------- // Tab 4: Storage - disk usage, data paths, file location // --------------------------------------------------------------------------- fn build_storage_tab( record: &AppImageRecord, db: &Rc, toast_overlay: &adw::ToastOverlay, ) -> gtk::Box { let tab = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(24) .margin_top(18) .margin_bottom(24) .margin_start(18) .margin_end(18) .build(); let clamp = adw::Clamp::builder() .maximum_size(800) .tightening_threshold(600) .build(); let inner = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(24) .build(); // Disk usage group let size_group = adw::PreferencesGroup::builder() .title("Disk Usage") .description( "Disk space used by this app, including any configuration, \ cache, or data files it may have created." ) .build(); let fp = footprint::get_footprint(db, record.id, record.size_bytes as u64); let appimage_row = adw::ActionRow::builder() .title("AppImage file") .subtitle(&widgets::format_size(record.size_bytes)) .build(); size_group.add(&appimage_row); if !fp.paths.is_empty() { let categories = [ ("Configuration", fp.config_size), ("Application data", fp.data_size), ("Cache", fp.cache_size), ("State", fp.state_size), ("Other", fp.other_size), ]; for (label, size) in &categories { if *size > 0 { let row = adw::ActionRow::builder() .title(*label) .subtitle(&widgets::format_size(*size as i64)) .build(); size_group.add(&row); } } let data_total = fp.data_total(); if data_total > 0 { let total_row = adw::ActionRow::builder() .title("Total disk usage") .subtitle(&format!( "{} (AppImage) + {} (app data) = {}", widgets::format_size(record.size_bytes), widgets::format_size(data_total as i64), widgets::format_size(fp.total_size() as i64), )) .build(); size_group.add(&total_row); } } inner.append(&size_group); // Data paths group let paths_group = adw::PreferencesGroup::builder() .title("App Data") .description("Settings, cache, and data this app may have saved to your system.") .build(); // Discover button let discover_row = adw::ActionRow::builder() .title("Find app data") .subtitle("Search for files this app has saved") .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("Searching..."); 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 saved data found"); } else { row_clone.set_subtitle(&format!( "Found {} path{} using {}", count, if count == 1 { "" } else { "s" }, widgets::format_size(fp.data_total() as i64), )); } } Err(_) => { row_clone.set_subtitle("Search failed"); } } }); }); paths_group.add(&discover_row); // Individual discovered paths 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); paths_group.add(&row); } } inner.append(&paths_group); // Backups group inner.append(&build_backup_group(record.id, toast_overlay)); // Uninstall group let uninstall_group = adw::PreferencesGroup::builder() .title("Uninstall") .description("Remove this AppImage and optionally clean up its data") .build(); let uninstall_btn = gtk::Button::builder() .label("Uninstall AppImage...") .halign(gtk::Align::Start) .margin_top(6) .margin_bottom(6) .build(); uninstall_btn.add_css_class("destructive-action"); uninstall_btn.add_css_class("pill"); let record_uninstall = record.clone(); let db_uninstall = db.clone(); let toast_uninstall = toast_overlay.clone(); let fp_paths: Vec<(String, String, u64)> = fp.paths.iter() .filter(|p| p.exists) .map(|p| ( p.path.to_string_lossy().to_string(), p.path_type.label().to_string(), p.size_bytes, )) .collect(); let is_integrated = record.integrated; uninstall_btn.connect_clicked(move |_btn| { show_uninstall_dialog( &toast_uninstall, &record_uninstall, &db_uninstall, is_integrated, &fp_paths, ); }); uninstall_group.add(&adw::ActionRow::builder() .child(&uninstall_btn) .build()); inner.append(&uninstall_group); // File location group let location_group = adw::PreferencesGroup::builder() .title("File Location") .build(); let path_row = adw::ActionRow::builder() .title("File location") .subtitle(&record.path) .subtitle_selectable(true) .build(); path_row.add_css_class("property"); 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); } location_group.add(&path_row); inner.append(&location_group); clamp.set_child(Some(&inner)); tab.append(&clamp); tab } fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw::PreferencesGroup { let group = adw::PreferencesGroup::builder() .title("Backups") .description("Save and restore this app's settings and data files") .build(); // Fetch existing backups let db = Database::open().ok(); let backups = db.as_ref() .map(|d| backup::list_backups(d, Some(record_id))) .unwrap_or_default(); if backups.is_empty() { let empty_row = adw::ActionRow::builder() .title("No backups yet") .subtitle("Create a backup to save this app's settings and data") .build(); let empty_icon = gtk::Image::from_icon_name("document-open-symbolic"); empty_icon.set_valign(gtk::Align::Center); empty_icon.add_css_class("dim-label"); empty_row.add_prefix(&empty_icon); group.add(&empty_row); } else { for b in &backups { log::debug!( "Listing backup id={} for appimage_id={} at {}", b.id, b.appimage_id, b.archive_path, ); let expander = adw::ExpanderRow::builder() .title(&b.created_at) .subtitle(&format!( "v{} - {} - {} file{}", b.app_version.as_deref().unwrap_or("unknown"), widgets::format_size(b.archive_size), b.path_count, if b.path_count == 1 { "" } else { "s" }, )) .build(); // Exists/missing badge using icon + text (not color-only) let badge = if b.exists { let bx = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(4) .valign(gtk::Align::Center) .build(); let icon = gtk::Image::from_icon_name("emblem-ok-symbolic"); icon.add_css_class("success"); let label = gtk::Label::new(Some("Exists")); label.add_css_class("caption"); label.add_css_class("success"); bx.append(&icon); bx.append(&label); bx } else { let bx = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(4) .valign(gtk::Align::Center) .build(); let icon = gtk::Image::from_icon_name("dialog-warning-symbolic"); icon.add_css_class("warning"); let label = gtk::Label::new(Some("Missing")); label.add_css_class("caption"); label.add_css_class("warning"); bx.append(&icon); bx.append(&label); bx }; expander.add_suffix(&badge); // Restore row let restore_row = adw::ActionRow::builder() .title("Restore") .subtitle("Restore settings and data from this backup") .activatable(true) .tooltip_text("Overwrite current settings with this backup") .build(); let restore_icon = gtk::Image::from_icon_name("edit-undo-symbolic"); restore_icon.set_valign(gtk::Align::Center); restore_row.add_prefix(&restore_icon); restore_row.update_property(&[ gtk::accessible::Property::Label("Restore this backup"), ]); let archive_path = b.archive_path.clone(); let toast_restore = toast_overlay.clone(); restore_row.connect_activated(move |row| { row.set_sensitive(false); row.set_subtitle("Restoring..."); let row_clone = row.clone(); let path = archive_path.clone(); let toast = toast_restore.clone(); glib::spawn_future_local(async move { let result = gio::spawn_blocking(move || { backup::restore_backup(std::path::Path::new(&path)) }) .await; row_clone.set_sensitive(true); match result { Ok(Ok(res)) => { let skip_note = if res.paths_skipped > 0 { format!(" ({} skipped)", res.paths_skipped) } else { String::new() }; row_clone.set_subtitle(&format!( "Restored {} path{}{}", res.paths_restored, if res.paths_restored == 1 { "" } else { "s" }, skip_note, )); let toast_msg = format!( "Restored {} path{}{}", res.paths_restored, if res.paths_restored == 1 { "" } else { "s" }, skip_note, ); toast.add_toast(adw::Toast::new(&toast_msg)); log::info!( "Backup restored: app={}, paths_restored={}, paths_skipped={}", res.manifest.app_name, res.paths_restored, res.paths_skipped, ); } _ => { row_clone.set_subtitle("Restore failed"); toast.add_toast(adw::Toast::new("Failed to restore backup")); } } }); }); expander.add_row(&restore_row); // Delete row let delete_row = adw::ActionRow::builder() .title("Delete") .subtitle("Permanently remove this backup") .activatable(true) .tooltip_text("Delete this backup archive from disk") .build(); let delete_icon = gtk::Image::from_icon_name("edit-delete-symbolic"); delete_icon.set_valign(gtk::Align::Center); delete_row.add_prefix(&delete_icon); delete_row.update_property(&[ gtk::accessible::Property::Label("Delete this backup"), ]); let backup_id = b.id; let toast_delete = toast_overlay.clone(); let group_ref = group.clone(); let expander_ref = expander.clone(); delete_row.connect_activated(move |row| { row.set_sensitive(false); row.set_subtitle("Deleting..."); let row_clone = row.clone(); let toast = toast_delete.clone(); let group_del = group_ref.clone(); let expander_del = expander_ref.clone(); glib::spawn_future_local(async move { let result = gio::spawn_blocking(move || { let bg_db = Database::open().expect("Failed to open database"); backup::delete_backup(&bg_db, backup_id) }) .await; match result { Ok(Ok(())) => { group_del.remove(&expander_del); toast.add_toast(adw::Toast::new("Backup deleted")); } _ => { row_clone.set_sensitive(true); row_clone.set_subtitle("Delete failed"); toast.add_toast(adw::Toast::new("Failed to delete backup")); } } }); }); expander.add_row(&delete_row); group.add(&expander); } } // Create backup row (always shown at bottom) let create_row = adw::ActionRow::builder() .title("Create backup") .subtitle("Save a snapshot of this app's settings and data") .activatable(true) .tooltip_text("Create a new backup of this app's configuration files") .build(); let create_icon = gtk::Image::from_icon_name("list-add-symbolic"); create_icon.set_valign(gtk::Align::Center); create_row.add_prefix(&create_icon); create_row.update_property(&[ gtk::accessible::Property::Label("Create a new backup"), ]); let toast_create = toast_overlay.clone(); create_row.connect_activated(move |row| { row.set_sensitive(false); row.set_subtitle("Creating backup..."); let row_clone = row.clone(); let toast = toast_create.clone(); glib::spawn_future_local(async move { let result = gio::spawn_blocking(move || { let bg_db = Database::open().expect("Failed to open database"); backup::create_backup(&bg_db, record_id) }) .await; row_clone.set_sensitive(true); match result { Ok(Ok(path)) => { let filename = path.file_name() .and_then(|n| n.to_str()) .unwrap_or("backup"); row_clone.set_subtitle(&format!("Created {}", filename)); toast.add_toast(adw::Toast::new("Backup created")); } Ok(Err(backup::BackupError::NoPaths)) => { row_clone.set_subtitle("Try discovering app data first"); toast.add_toast(adw::Toast::new("No data paths found to back up")); } _ => { row_clone.set_subtitle("Backup failed"); toast.add_toast(adw::Toast::new("Failed to create backup")); } } }); }); group.add(&create_row); group } // --------------------------------------------------------------------------- // User-friendly explanations // --------------------------------------------------------------------------- fn wayland_user_explanation(status: &WaylandStatus) -> &'static str { match status { WaylandStatus::Native => "Fully compatible - the best experience on your system.", WaylandStatus::XWayland => "Works through a compatibility layer. May appear slightly \ blurry on high-resolution screens.", WaylandStatus::Possible => "Might work well. Try launching it to find out.", WaylandStatus::X11Only => "Built for an older display system. It will run automatically, \ but you may notice minor visual quirks.", WaylandStatus::Unknown => "Not yet determined. Launch the app or use 'Detect app framework' to check.", } } fn fuse_user_explanation(status: &FuseStatus) -> &'static str { match status { FuseStatus::FullyFunctional => "Everything is set up - apps start instantly.", FuseStatus::Fuse3Only => "A small system component is missing. Most apps will still work, \ but some may need it. Copy the install command to fix this.", FuseStatus::NoFusermount => "A system component is missing, so apps will take a little longer \ to start. They'll still work fine.", FuseStatus::NoDevFuse => "Your system doesn't support instant app mounting. Apps will unpack \ before starting, which takes a bit longer.", FuseStatus::MissingLibfuse2 => "A small system component is needed for fast startup. \ Copy the install command to fix this.", } } /// Return an install command for a FUSE status that needs one, or None. fn fuse_install_command(status: &FuseStatus) -> Option<&'static str> { match status { FuseStatus::Fuse3Only | FuseStatus::MissingLibfuse2 => { Some("sudo apt install libfuse2") } _ => None, } } /// Show a screenshot in a fullscreen lightbox window with prev/next navigation. /// Uses a separate gtk::Window to avoid parent scroll position interference. fn show_screenshot_lightbox( parent: >k::Window, textures: &Rc>>>, initial_index: usize, ) { let current = Rc::new(std::cell::Cell::new(initial_index)); let textures = textures.clone(); let count = textures.borrow().len(); let has_multiple = count > 1; // Fullscreen modal window with dark background let win = gtk::Window::builder() .transient_for(parent) .modal(true) .decorated(false) .default_width(parent.width()) .default_height(parent.height()) .build(); win.add_css_class("lightbox"); // Image with generous margins so there's a dark border to click on let picture = gtk::Picture::builder() .content_fit(gtk::ContentFit::Contain) .halign(gtk::Align::Center) .valign(gtk::Align::Center) .margin_start(72) .margin_end(72) .margin_top(56) .margin_bottom(56) .build(); picture.set_can_shrink(true); // Set initial texture { let t = textures.borrow(); if let Some(Some(ref tex)) = t.get(initial_index) { picture.set_paintable(Some(tex)); } } // Navigation buttons let prev_btn = gtk::Button::builder() .icon_name("go-previous-symbolic") .css_classes(["circular", "osd", "lightbox-nav"]) .valign(gtk::Align::Center) .halign(gtk::Align::Start) .margin_start(16) .build(); let next_btn = gtk::Button::builder() .icon_name("go-next-symbolic") .css_classes(["circular", "osd", "lightbox-nav"]) .valign(gtk::Align::Center) .halign(gtk::Align::End) .margin_end(16) .build(); // Counter label (e.g. "2 / 5") let counter = gtk::Label::builder() .label(&format!("{} / {}", initial_index + 1, count)) .css_classes(["lightbox-counter"]) .halign(gtk::Align::Center) .valign(gtk::Align::End) .margin_bottom(16) .build(); // Close button (top-right) let close_btn = gtk::Button::builder() .icon_name("window-close-symbolic") .css_classes(["circular", "osd"]) .halign(gtk::Align::End) .valign(gtk::Align::Start) .margin_top(16) .margin_end(16) .build(); // Build overlay: picture as child, buttons + counter as overlays let overlay = gtk::Overlay::builder() .child(&picture) .hexpand(true) .vexpand(true) .build(); overlay.add_overlay(&close_btn); if has_multiple { overlay.add_overlay(&prev_btn); overlay.add_overlay(&next_btn); overlay.add_overlay(&counter); } win.set_child(Some(&overlay)); // --- Click outside image to close --- // Picture's gesture claims clicks on the image, preventing close. let pic_gesture = gtk::GestureClick::new(); pic_gesture.connect_released(|gesture, _, _, _| { gesture.set_state(gtk::EventSequenceState::Claimed); }); picture.add_controller(pic_gesture); // Window gesture fires for clicks on the dark margin area. { let win_ref = win.clone(); let bg_gesture = gtk::GestureClick::new(); bg_gesture.connect_released(move |_, _, _, _| { win_ref.close(); }); win.add_controller(bg_gesture); } // --- Close button --- { let win_ref = win.clone(); close_btn.connect_clicked(move |_| { win_ref.close(); }); } // --- Navigation --- let make_navigate = |direction: i32| { let textures_ref = textures.clone(); let current_ref = current.clone(); let picture_ref = picture.clone(); let counter_ref = counter.clone(); move || { let idx = current_ref.get(); let new_idx = if direction < 0 { if idx == 0 { count - 1 } else { idx - 1 } } else { if idx + 1 >= count { 0 } else { idx + 1 } }; let t = textures_ref.borrow(); if let Some(Some(ref tex)) = t.get(new_idx) { picture_ref.set_paintable(Some(tex)); current_ref.set(new_idx); counter_ref.set_label(&format!("{} / {}", new_idx + 1, count)); } } }; if has_multiple { let go_prev = make_navigate(-1); prev_btn.connect_clicked(move |_| go_prev()); let go_next = make_navigate(1); next_btn.connect_clicked(move |_| go_next()); } // --- Keyboard: Escape to close, Left/Right to navigate --- { let win_ref = win.clone(); let go_prev_key = make_navigate(-1); let go_next_key = make_navigate(1); let key_ctl = gtk::EventControllerKey::new(); key_ctl.connect_key_pressed(move |_, key, _, _| { match key { gtk::gdk::Key::Escape => { win_ref.close(); return glib::Propagation::Stop; } gtk::gdk::Key::Left if has_multiple => { go_prev_key(); return glib::Propagation::Stop; } gtk::gdk::Key::Right if has_multiple => { go_next_key(); return glib::Propagation::Stop; } _ => {} } glib::Propagation::Proceed }); win.add_controller(key_ctl); } win.present(); } /// Fetch a favicon for a URL and set it on an image widget. fn fetch_favicon_async(url: &str, image: >k::Image) { // Extract domain from URL for favicon service let domain = url .trim_start_matches("https://") .trim_start_matches("http://") .split('/') .next() .unwrap_or("") .to_string(); if domain.is_empty() { return; } let image_ref = image.clone(); glib::spawn_future_local(async move { let favicon_url = format!( "https://www.google.com/s2/favicons?domain={}&sz=32", domain ); let result = gio::spawn_blocking(move || { let mut response = ureq::get(&favicon_url) .header("User-Agent", "Driftwood-AppImage-Manager/0.1") .call() .ok()?; let mut buf = Vec::new(); response.body_mut().as_reader().read_to_end(&mut buf).ok()?; if buf.len() > 100 { Some(buf) } else { None } }) .await; if let Ok(Some(data)) = result { let gbytes = glib::Bytes::from(&data); if let Ok(texture) = gtk::gdk::Texture::from_bytes(&gbytes) { image_ref.set_paintable(Some(&texture)); } } }); } fn show_uninstall_dialog( toast_overlay: &adw::ToastOverlay, record: &AppImageRecord, db: &Rc, is_integrated: bool, data_paths: &[(String, String, u64)], ) { let name = record.app_name.as_deref().unwrap_or(&record.filename); let dialog = adw::AlertDialog::builder() .heading(&format!("Uninstall {}?", name)) .body("Select what to remove:") .build(); dialog.add_response("cancel", "Cancel"); dialog.add_response("uninstall", "Uninstall"); dialog.set_response_appearance("uninstall", adw::ResponseAppearance::Destructive); dialog.set_default_response(Some("cancel")); dialog.set_close_response("cancel"); let extra = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(8) .margin_top(12) .build(); // Checkbox: AppImage file (always checked, not unchecked - it's the main thing) let appimage_check = gtk::CheckButton::builder() .label(&format!("AppImage file ({})", widgets::format_size(record.size_bytes))) .active(true) .build(); extra.append(&appimage_check); // Checkbox: Desktop integration let integration_check = if is_integrated { let check = gtk::CheckButton::builder() .label("Remove desktop integration") .active(true) .build(); extra.append(&check); Some(check) } else { None }; // Checkboxes for each discovered data path let mut path_checks: Vec<(gtk::CheckButton, String)> = Vec::new(); for (path, label, size) in data_paths { let check = gtk::CheckButton::builder() .label(&format!("{} - {} ({})", label, path, widgets::format_size(*size as i64))) .active(true) .build(); extra.append(&check); path_checks.push((check, path.clone())); } dialog.set_extra_child(Some(&extra)); let record_id = record.id; let record_path = record.path.clone(); let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); dialog.connect_response(Some("uninstall"), move |_dlg, _response| { // Remove integration if checked if let Some(ref check) = integration_check { if check.is_active() { integrator::undo_all_modifications(&db_ref, record_id).ok(); if let Ok(Some(rec)) = db_ref.get_appimage_by_id(record_id) { integrator::remove_integration(&rec).ok(); } } } // Remove checked data paths for (check, path) in &path_checks { if check.is_active() { let p = std::path::Path::new(path); if p.is_dir() { std::fs::remove_dir_all(p).ok(); } else if p.is_file() { std::fs::remove_file(p).ok(); } } } // Remove AppImage file if checked if appimage_check.is_active() { std::fs::remove_file(&record_path).ok(); } // Remove from database db_ref.remove_appimage(record_id).ok(); toast_ref.add_toast(adw::Toast::new("AppImage uninstalled")); // Navigate back (the detail view is now stale) if let Some(nav) = toast_ref.ancestor(adw::NavigationView::static_type()) { let nav: adw::NavigationView = nav.downcast().unwrap(); nav.pop(); } }); dialog.present(Some(toast_overlay)); }