Files
driftwood/src/ui/detail_view.rs
lashman f89aafca6a Add GitHub metadata enrichment for catalog apps
Enrich catalog apps with GitHub API data (stars, version, downloads,
release date) via two strategies: background drip for repo-level info
and on-demand fetch when opening a detail page.

- Add github_enrichment module with API calls, asset filtering, and
  architecture auto-detection for AppImage downloads
- DB migrations v14/v15 for GitHub metadata and release asset columns
- Extract github_owner/repo from feed links during catalog sync
- Display colored stat cards (stars, version, downloads, released) on
  detail pages with on-demand enrichment
- Show stars and version on browse tiles and featured carousel cards
- Replace install button with SplitButton dropdown when multiple arch
  assets available, preferring detected architecture
- Disable install button until enrichment completes to prevent stale
  AppImageHub URL downloads
- Keep enrichment banner visible on catalog page until truly complete,
  showing paused state when rate-limited
- Add GitHub token and auto-enrich toggle to preferences
2026-02-28 16:49:13 +02:00

2809 lines
102 KiB
Rust

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<Database>) -> 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 (2-tab layout: About + Details)
let about_page = build_overview_tab(record, db);
view_stack.add_titled(&about_page, Some("about"), "About");
view_stack.page(&about_page).set_icon_name(Some("info-symbolic"));
// Details tab combines System + Security + Storage
let system_content = build_system_tab(record, db, &toast_overlay);
let security_content = build_security_tab(record, db);
let storage_content = build_storage_tab(record, db, &toast_overlay);
let details_page = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.build();
details_page.append(&system_content);
details_page.append(&security_content);
details_page.append(&storage_content);
view_stack.add_titled(&details_page, Some("details"), "Details");
view_stack.page(&details_page).set_icon_name(Some("applications-system-symbolic"));
// Restore last-used tab from GSettings (map old tab names to new ones)
let settings = gio::Settings::new(crate::config::APP_ID);
let saved_tab = settings.string("detail-tab");
let mapped_tab = match saved_tab.as_str() {
"overview" | "about" => "about",
"system" | "security" | "storage" | "details" => "details",
_ => "about",
};
view_stack.set_visible_child_name(mapped_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();
let first_run_prompted = record.first_run_prompted;
let db_perm = db.clone();
launch_button.connect_clicked(move |btn| {
// Check if first-run permission dialog should be shown
if !first_run_prompted {
let btn_ref = btn.clone();
let path = path.clone();
let app_name = app_name_launch.clone();
let app_name_dialog = app_name.clone();
let db_launch = db_launch.clone();
let toast_ref = toast_launch.clone();
let launch_args_raw = launch_args_raw.clone();
let db_perm = db_perm.clone();
crate::ui::permission_dialog::show_permission_dialog(
btn,
&app_name_dialog,
record_id,
&db_perm,
move || {
do_launch(
&btn_ref, record_id, &path, &app_name,
launcher::parse_launch_args(launch_args_raw.as_deref()),
&db_launch, &toast_ref,
);
},
);
return;
}
let launch_args = launcher::parse_launch_args(launch_args_raw.as_deref());
do_launch(
btn, record_id, &path, &app_name_launch,
launch_args, &db_launch, &toast_launch,
);
});
header.pack_start(&launch_button);
// Uninstall button
let uninstall_button = gtk::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Uninstall this AppImage")
.build();
uninstall_button.add_css_class("flat");
uninstall_button.update_property(&[
gtk::accessible::Property::Label("Uninstall application"),
]);
let record_for_uninstall = record.clone();
let db_uninstall = db.clone();
let toast_uninstall = toast_overlay.clone();
let is_integrated_header = record.integrated;
uninstall_button.connect_clicked(move |_btn| {
let fp = footprint::get_footprint(&db_uninstall, record_for_uninstall.id, record_for_uninstall.size_bytes as u64);
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();
show_uninstall_dialog(
&toast_uninstall,
&record_for_uninstall,
&db_uninstall,
is_integrated_header,
&fp_paths,
);
});
header.pack_end(&uninstall_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.add_css_class("flat");
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<String> = [
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<Database>) -> 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<std::cell::RefCell<Vec<Option<gtk::gdk::Texture>>>> =
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::<gtk::Window>() {
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<String>)] = &[
("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::<gtk::Window>().ok());
launcher.launch(
window.as_ref(),
None::<&gtk::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(&widgets::relative_time(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::<Vec<serde_json::Value>>(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(&widgets::relative_time(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::<Vec<String>>(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("Verified by developer")
.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(&widgets::relative_time(&record.first_seen))
.build();
info_group.add(&seen_row);
let scanned_row = adw::ActionRow::builder()
.title("Last checked")
.subtitle(&widgets::relative_time(&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);
// "You might also like" - similar apps from the user's library
if let Some(ref cats) = record.categories {
if let Ok(similar) = db.find_similar_apps(cats, record.id, 4) {
if !similar.is_empty() {
let similar_group = adw::PreferencesGroup::builder()
.title("You might also like")
.build();
for (id, name, icon_path) in &similar {
let row = adw::ActionRow::builder()
.title(name.as_str())
.activatable(true)
.build();
let icon = widgets::app_icon(
icon_path.as_deref(),
name,
32,
);
row.add_prefix(&icon);
// Store the record ID in the widget name for navigation
row.set_widget_name(&format!("similar-{}", id));
similar_group.add(&row);
}
inner.append(&similar_group);
}
}
}
clamp.set_child(Some(&inner));
tab.append(&clamp);
tab
}
// ---------------------------------------------------------------------------
// Tab 2: System - integration, compatibility, sandboxing
// ---------------------------------------------------------------------------
fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, 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("Window class (advanced)")
.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);
// System-wide install toggle
if record.integrated {
let syswide_row = adw::SwitchRow::builder()
.title("Install system-wide")
.subtitle(if record.system_wide {
"Installed for all users in /opt/driftwood-apps/"
} else {
"Make available to all users on this computer"
})
.active(record.system_wide)
.tooltip_text(
"Copies the AppImage and its shortcut to system directories \
so all users on this computer can access it. Requires \
administrator privileges."
)
.build();
let record_sw = record.clone();
let db_sw = db.clone();
let toast_sw = toast_overlay.clone();
let record_id_sw = record.id;
syswide_row.connect_active_notify(move |row| {
if row.is_active() {
match integrator::install_system_wide(&record_sw, &db_sw) {
Ok(()) => {
toast_sw.add_toast(adw::Toast::new("Installed system-wide"));
}
Err(e) => {
log::error!("System-wide install failed: {}", e);
toast_sw.add_toast(adw::Toast::new("System-wide install failed"));
row.set_active(false);
}
}
} else {
match integrator::remove_system_wide(&db_sw, record_id_sw) {
Ok(()) => {
toast_sw.add_toast(adw::Toast::new("System-wide install removed"));
}
Err(e) => {
log::error!("Failed to remove system-wide install: {}", e);
toast_sw.add_toast(adw::Toast::new("Failed to remove system-wide install"));
row.set_active(true);
}
}
}
});
integration_group.add(&syswide_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(&current_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("Opens these file types")
.description("File types this app can handle. Set as default to always open them 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);
}
}
// Default Application group
if let Some(ref cats) = record.categories {
let caps = integrator::detect_default_capabilities(cats);
if !caps.is_empty() && record.integrated {
let default_group = adw::PreferencesGroup::builder()
.title("Default Application")
.description(
"Set this app as your system default for tasks it can handle."
)
.build();
let app_id = integrator::make_app_id(
record.app_name.as_deref().unwrap_or(&record.filename),
);
for cap in &caps {
let row = adw::ActionRow::builder()
.title(cap.label())
.build();
let set_btn = gtk::Button::builder()
.label("Set Default")
.valign(gtk::Align::Center)
.build();
set_btn.add_css_class("flat");
let db_def = db.clone();
let record_id = record.id;
let app_id_clone = app_id.clone();
let cap_clone = cap.clone();
let toast_def = toast_overlay.clone();
set_btn.connect_clicked(move |btn| {
match integrator::set_default_app(
&db_def, record_id, &app_id_clone, &cap_clone,
) {
Ok(()) => {
toast_def.add_toast(adw::Toast::new(
&format!("Set as default {}", cap_clone.label().to_lowercase()),
));
btn.set_sensitive(false);
btn.set_label("Default");
}
Err(e) => {
log::error!("Failed to set default app: {}", e);
toast_def.add_toast(adw::Toast::new("Failed to set default"));
}
}
});
row.add_suffix(&set_btn);
default_group.add(&row);
}
inner.append(&default_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(&copy_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(&copy_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<Database>) -> 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();
// Verification group
let verify_group = adw::PreferencesGroup::builder()
.title("Verification")
.description("Check the integrity and authenticity of this AppImage")
.build();
// Signature status
let sig_label = if record.has_signature { "Signature present" } else { "No signature" };
let sig_badge_class = if record.has_signature { "success" } else { "neutral" };
let sig_row = adw::ActionRow::builder()
.title("Verified by developer")
.subtitle(sig_label)
.build();
let sig_badge = widgets::status_badge(sig_label, sig_badge_class);
sig_badge.set_valign(gtk::Align::Center);
sig_row.add_suffix(&sig_badge);
verify_group.add(&sig_row);
// SHA256 hash
if let Some(ref hash) = record.sha256 {
let sha_row = adw::ActionRow::builder()
.title("File checksum")
.subtitle(hash)
.subtitle_selectable(true)
.build();
verify_group.add(&sha_row);
}
// Verification status
let verify_status = record.verification_status.as_deref().unwrap_or("not_checked");
let verify_row = adw::ActionRow::builder()
.title("Verification")
.subtitle(match verify_status {
"signed_valid" => "Signed and verified",
"signed_invalid" => "Signature present but invalid",
"checksum_match" => "Checksum verified",
"checksum_mismatch" => "Checksum mismatch - file may be corrupted",
_ => "Not verified",
})
.build();
let verify_badge = widgets::status_badge(
match verify_status {
"signed_valid" | "checksum_match" => "Verified",
"signed_invalid" | "checksum_mismatch" => "Failed",
_ => "Unchecked",
},
match verify_status {
"signed_valid" | "checksum_match" => "success",
"signed_invalid" | "checksum_mismatch" => "error",
_ => "neutral",
},
);
verify_badge.set_valign(gtk::Align::Center);
verify_row.add_suffix(&verify_badge);
verify_group.add(&verify_row);
// Manual SHA256 verification input
let sha_input = adw::EntryRow::builder()
.title("Verify SHA256")
.show_apply_button(true)
.build();
sha_input.set_tooltip_text(Some("Paste an expected SHA256 hash to verify this file"));
let record_path = record.path.clone();
let record_id_v = record.id;
let db_verify = db.clone();
sha_input.connect_apply(move |row| {
let expected = row.text().to_string();
if expected.is_empty() {
return;
}
let path = record_path.clone();
let db_ref = db_verify.clone();
let row_ref = row.clone();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || {
let p = std::path::Path::new(&path);
crate::core::verification::verify_sha256(p, &expected)
})
.await;
if let Ok(status) = result {
let label = status.label();
row_ref.set_title(&format!("Verify SHA256 - {}", label));
db_ref.set_verification_status(record_id_v, status.as_str()).ok();
}
});
});
verify_group.add(&sha_input);
// Check signature button
let check_sig_row = adw::ActionRow::builder()
.title("Check embedded signature")
.subtitle("Verify GPG signature if present")
.activatable(true)
.build();
let sig_arrow = gtk::Image::from_icon_name("go-next-symbolic");
sig_arrow.set_valign(gtk::Align::Center);
check_sig_row.add_suffix(&sig_arrow);
let record_path_sig = record.path.clone();
let record_id_sig = record.id;
let db_sig = db.clone();
check_sig_row.connect_activated(move |row| {
row.set_sensitive(false);
row.set_subtitle("Checking...");
let path = record_path_sig.clone();
let db_ref = db_sig.clone();
let row_ref = row.clone();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || {
let p = std::path::Path::new(&path);
crate::core::verification::check_embedded_signature(p)
})
.await;
if let Ok(status) = result {
row_ref.set_subtitle(&status.label());
db_ref.set_verification_status(record_id_sig, status.as_str()).ok();
let result_badge = widgets::status_badge(
match status.badge_class() {
"success" => "Verified",
"error" => "Failed",
_ => "Unknown",
},
status.badge_class(),
);
result_badge.set_valign(gtk::Align::Center);
row_ref.add_suffix(&result_badge);
}
row_ref.set_sensitive(true);
});
});
verify_group.add(&check_sig_row);
inner.append(&verify_group);
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<Database>,
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 record.is_portable {
let location_label = if let Some(ref mp) = record.mount_point {
format!("Portable ({})", mp)
} else {
"Portable (removable media)".to_string()
};
let location_row = adw::ActionRow::builder()
.title("Location")
.subtitle(&location_label)
.build();
let badge = widgets::status_badge("Portable", "info");
badge.set_valign(gtk::Align::Center);
location_row.add_suffix(&badge);
size_group.add(&location_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(&copy_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.
pub fn show_screenshot_lightbox(
parent: &gtk::Window,
textures: &Rc<std::cell::RefCell<Vec<Option<gtk::gdk::Texture>>>>,
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: &gtk::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));
}
}
});
}
pub fn show_uninstall_dialog(
toast_overlay: &adw::ToastOverlay,
record: &AppImageRecord,
db: &Rc<Database>,
is_integrated: bool,
data_paths: &[(String, String, u64)],
) {
show_uninstall_dialog_with_callback(toast_overlay, record, db, is_integrated, data_paths, None);
}
pub fn show_uninstall_dialog_with_callback(
toast_overlay: &adw::ToastOverlay,
record: &AppImageRecord,
db: &Rc<Database>,
is_integrated: bool,
data_paths: &[(String, String, u64)],
on_complete: Option<Box<dyn FnOnce() + 'static>>,
) {
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();
let on_complete = std::cell::Cell::new(on_complete);
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"));
// Run the completion callback if provided
if let Some(cb) = on_complete.take() {
cb();
}
// 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));
}
fn do_launch(
btn: &gtk::Button,
record_id: i64,
path: &str,
app_name: &str,
launch_args: Vec<String>,
db: &Rc<Database>,
toast_overlay: &adw::ToastOverlay,
) {
btn.set_sensitive(false);
let btn_ref = btn.clone();
let path = path.to_string();
let app_name = app_name.to_string();
let db_launch = db.clone();
let toast_ref = toast_overlay.clone();
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());
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::<String>(),
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");
}
}
});
}