Files
driftwood/src/ui/detail_view.rs
lashman 8cf71ae858 Add download verification with signature and SHA256 support
New verification module with GPG signature checking and SHA256 hash
computation. Security tab shows verification status, embedded signature
check button, and manual SHA256 input for verifying downloads.
2026-02-28 00:05:43 +02:00

2556 lines
93 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
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::<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");
}
}
});
});
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<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(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(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("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<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("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(&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("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(&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("Digital signature")
.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("SHA256")
.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 !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.
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));
}
}
});
}
fn show_uninstall_dialog(
toast_overlay: &adw::ToastOverlay,
record: &AppImageRecord,
db: &Rc<Database>,
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));
}