1474 lines
53 KiB
Rust
1474 lines
53 KiB
Rust
use adw::prelude::*;
|
|
use std::cell::Cell;
|
|
use std::rc::Rc;
|
|
|
|
use gtk::gio;
|
|
|
|
use crate::core::database::{AppImageRecord, Database};
|
|
use crate::core::footprint;
|
|
use crate::core::fuse::{self, FuseStatus};
|
|
use crate::core::integrator;
|
|
use crate::core::launcher::{self, SandboxMode};
|
|
use crate::core::security;
|
|
use crate::core::updater;
|
|
use crate::core::wayland::{self, WaylandStatus};
|
|
use super::integration_dialog;
|
|
use super::update_dialog;
|
|
use super::widgets;
|
|
|
|
pub fn build_detail_page(record: &AppImageRecord, db: &Rc<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
|
|
let view_stack = adw::ViewStack::new();
|
|
|
|
// 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"));
|
|
|
|
// Scrollable view stack
|
|
let scrolled = gtk::ScrolledWindow::builder()
|
|
.child(&view_stack)
|
|
.vexpand(true)
|
|
.build();
|
|
|
|
// Main vertical layout: banner + scrolled tabs
|
|
let content = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.build();
|
|
content.append(&build_banner(record));
|
|
content.append(&scrolled);
|
|
|
|
toast_overlay.set_child(Some(&content));
|
|
|
|
// 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 db_launch = db.clone();
|
|
launch_button.connect_clicked(move |_| {
|
|
let appimage_path = std::path::Path::new(&path);
|
|
let result = launcher::launch_appimage(
|
|
&db_launch,
|
|
record_id,
|
|
appimage_path,
|
|
"gui_detail",
|
|
&[],
|
|
&[],
|
|
);
|
|
match result {
|
|
launcher::LaunchResult::Started { child, method } => {
|
|
let pid = child.id();
|
|
log::info!("Launched AppImage: {} (PID: {}, method: {})", path, pid, method.as_str());
|
|
|
|
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;
|
|
|
|
match analysis_result {
|
|
Ok(Ok(analysis)) => {
|
|
let status_label = analysis.status_label();
|
|
let status_str = analysis.as_status_str();
|
|
log::info!(
|
|
"Runtime Wayland analysis for {} (PID {}): {} (wayland_socket={}, x11={}, env_vars={})",
|
|
path_clone, analysis.pid, status_label,
|
|
analysis.has_wayland_socket,
|
|
analysis.has_x11_connection,
|
|
analysis.env_vars.len(),
|
|
);
|
|
db_wayland.update_runtime_wayland_status(
|
|
record_id, status_str,
|
|
).ok();
|
|
}
|
|
Ok(Err(e)) => {
|
|
log::debug!("Runtime analysis failed for PID {}: {}", pid, e);
|
|
}
|
|
Err(_) => {
|
|
log::debug!("Runtime analysis task failed for PID {}", pid);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
launcher::LaunchResult::Failed(msg) => {
|
|
log::error!("Failed to launch: {}", msg);
|
|
}
|
|
}
|
|
});
|
|
header.pack_end(&launch_button);
|
|
|
|
// Check for Update button
|
|
let update_button = gtk::Button::builder()
|
|
.icon_name("software-update-available-symbolic")
|
|
.tooltip_text("Check for updates")
|
|
.build();
|
|
update_button.update_property(&[
|
|
gtk::accessible::Property::Label("Check for updates"),
|
|
]);
|
|
let record_for_update = record.clone();
|
|
let db_update = db.clone();
|
|
update_button.connect_clicked(move |btn| {
|
|
update_dialog::show_update_dialog(btn, &record_for_update, &db_update);
|
|
});
|
|
header.pack_end(&update_button);
|
|
|
|
let toolbar = adw::ToolbarView::new();
|
|
toolbar.add_top_bar(&header);
|
|
toolbar.set_content(Some(&toast_overlay));
|
|
|
|
adw::NavigationPage::builder()
|
|
.title(name)
|
|
.tag("detail")
|
|
.child(&toolbar)
|
|
.build()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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();
|
|
|
|
if record.integrated {
|
|
badge_box.append(&widgets::status_badge("Integrated", "success"));
|
|
}
|
|
|
|
if let Some(ref ws) = record.wayland_status {
|
|
let status = WaylandStatus::from_str(ws);
|
|
if status != WaylandStatus::Unknown {
|
|
badge_box.append(&widgets::status_badge(status.label(), status.badge_class()));
|
|
}
|
|
}
|
|
|
|
if let (Some(ref latest), Some(ref current)) = (&record.latest_version, &record.app_version) {
|
|
if crate::core::updater::version_is_newer(latest, current) {
|
|
badge_box.append(&widgets::status_badge("Update available", "info"));
|
|
}
|
|
}
|
|
|
|
text_col.append(&badge_box);
|
|
banner.append(&text_col);
|
|
banner
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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("SPDX license identifier for this application")
|
|
.build();
|
|
about_group.add(&row);
|
|
}
|
|
|
|
if let Some(ref pg) = record.project_group {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Project group")
|
|
.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);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Links section
|
|
// -----------------------------------------------------------------------
|
|
let has_links = record.homepage_url.is_some()
|
|
|| record.bugtracker_url.is_some()
|
|
|| record.donation_url.is_some()
|
|
|| record.help_url.is_some()
|
|
|| record.vcs_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),
|
|
("Bug tracker", "bug-symbolic", &record.bugtracker_url),
|
|
("Source code", "code-symbolic", &record.vcs_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);
|
|
|
|
let prefix_icon = gtk::Image::from_icon_name(*icon_name);
|
|
prefix_icon.set_valign(gtk::Align::Center);
|
|
row.add_prefix(&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::<>k::gio::Cancellable>,
|
|
|_| {},
|
|
);
|
|
});
|
|
links_group.add(&row);
|
|
}
|
|
}
|
|
|
|
inner.append(&links_group);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Updates section
|
|
// -----------------------------------------------------------------------
|
|
let updates_group = adw::PreferencesGroup::builder()
|
|
.title("Updates")
|
|
.description("Keep this app up to date by checking for new versions.")
|
|
.build();
|
|
|
|
if let Some(ref update_type) = record.update_type {
|
|
let display_label = updater::parse_update_info(update_type)
|
|
.map(|ut| ut.type_label_display())
|
|
.unwrap_or("Unknown format");
|
|
let row = adw::ActionRow::builder()
|
|
.title("Update method")
|
|
.subtitle(&format!(
|
|
"This app checks for updates using: {}",
|
|
display_label
|
|
))
|
|
.tooltip_text(
|
|
"AppImages can include built-in update information that tells Driftwood \
|
|
where to check for newer versions. Common methods include GitHub releases, \
|
|
zsync (efficient delta updates), and direct download URLs."
|
|
)
|
|
.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 = adw::ActionRow::new();
|
|
label_row.set_child(Some(&label));
|
|
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("Capabilities")
|
|
.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(
|
|
"Content rating based on the OARS (Open Age Ratings Service) system",
|
|
)
|
|
.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("Desktop actions")
|
|
.subtitle(&actions.join(", "))
|
|
.tooltip_text(
|
|
"Additional actions available from the right-click menu \
|
|
when this app is integrated into the desktop",
|
|
)
|
|
.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 (ISO 9660) - older format, still widely supported",
|
|
Some(2) => "Type 2 (SquashFS) - modern format, most common today",
|
|
_ => "Unknown type",
|
|
};
|
|
let type_row = adw::ActionRow::builder()
|
|
.title("AppImage format")
|
|
.subtitle(type_str)
|
|
.tooltip_text(
|
|
"AppImages come in two formats. Type 1 uses an ISO 9660 filesystem \
|
|
(older, simpler). Type 2 uses SquashFS (modern, compressed, smaller \
|
|
files). Type 2 is the standard today and is what most AppImage tools \
|
|
produce."
|
|
)
|
|
.build();
|
|
info_group.add(&type_row);
|
|
|
|
let exec_row = adw::ActionRow::builder()
|
|
.title("Executable")
|
|
.subtitle(if record.is_executable {
|
|
"Yes - this file has execute permission"
|
|
} else {
|
|
"No - execute permission is missing. It will be set automatically when launched."
|
|
})
|
|
.build();
|
|
info_group.add(&exec_row);
|
|
|
|
// Digital signature status
|
|
let sig_row = adw::ActionRow::builder()
|
|
.title("Digital signature")
|
|
.subtitle(if record.has_signature {
|
|
"This AppImage contains a GPG signature"
|
|
} else {
|
|
"Not signed"
|
|
})
|
|
.tooltip_text(
|
|
"AppImages can be digitally signed by their author using GPG. \
|
|
A signature helps verify that the file hasn't been tampered with."
|
|
)
|
|
.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 scanned")
|
|
.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("Desktop Integration")
|
|
.description(
|
|
"Show this app in your Activities menu and app launcher, \
|
|
just like a regular installed application."
|
|
)
|
|
.build();
|
|
|
|
let switch_row = adw::SwitchRow::builder()
|
|
.title("Add to application menu")
|
|
.subtitle("Creates a .desktop entry and installs the app icon")
|
|
.active(record.integrated)
|
|
.tooltip_text(
|
|
"Desktop integration makes this AppImage appear in your Activities menu \
|
|
and app launcher, just like a regular installed app. It creates a .desktop \
|
|
file (a shortcut) and copies the app's icon to your system icon folder."
|
|
)
|
|
.build();
|
|
|
|
let record_id = record.id;
|
|
let record_clone = record.clone();
|
|
let db_ref = db.clone();
|
|
let db_dialog = db.clone();
|
|
let record_dialog = record.clone();
|
|
let suppress = Rc::new(Cell::new(false));
|
|
let suppress_ref = suppress.clone();
|
|
switch_row.connect_active_notify(move |row| {
|
|
if suppress_ref.get() {
|
|
return;
|
|
}
|
|
if row.is_active() {
|
|
let row_clone = row.clone();
|
|
let suppress_inner = suppress_ref.clone();
|
|
integration_dialog::show_integration_dialog(
|
|
row,
|
|
&record_dialog,
|
|
&db_dialog,
|
|
move |success| {
|
|
if !success {
|
|
suppress_inner.set(true);
|
|
row_clone.set_active(false);
|
|
suppress_inner.set(false);
|
|
}
|
|
},
|
|
);
|
|
} else {
|
|
integrator::remove_integration(&record_clone).ok();
|
|
db_ref.set_integrated(record_id, false, None).ok();
|
|
}
|
|
});
|
|
integration_group.add(&switch_row);
|
|
|
|
if record.integrated {
|
|
if let Some(ref desktop_file) = record.desktop_file {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Desktop file")
|
|
.subtitle(desktop_file)
|
|
.subtitle_selectable(true)
|
|
.build();
|
|
row.add_css_class("property");
|
|
integration_group.add(&row);
|
|
}
|
|
}
|
|
inner.append(&integration_group);
|
|
|
|
// Runtime Compatibility group
|
|
let compat_group = adw::PreferencesGroup::builder()
|
|
.title("Compatibility")
|
|
.description(
|
|
"How well this app works with your display server and filesystem. \
|
|
Most issues here can be resolved with a small package 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("Wayland display")
|
|
.subtitle(wayland_user_explanation(&wayland_status))
|
|
.tooltip_text(
|
|
"Wayland is the modern display system used by GNOME and most Linux desktops. \
|
|
It replaced the older X11 system. Apps built for X11 still work through \
|
|
a compatibility layer called XWayland, but native Wayland apps look \
|
|
sharper and perform better, 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("Analyze toolkit")
|
|
.subtitle("Inspect bundled libraries to detect which UI toolkit this app uses")
|
|
.activatable(true)
|
|
.tooltip_text(
|
|
"UI toolkits (like GTK, Qt, Electron) are the frameworks apps use to \
|
|
draw their windows and buttons. Knowing the toolkit helps predict Wayland \
|
|
compatibility - GTK4 apps are natively Wayland, while older Qt or Electron \
|
|
apps may need XWayland."
|
|
)
|
|
.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!(
|
|
"Detected: {} ({} libraries scanned)",
|
|
toolkit_label, lib_count,
|
|
));
|
|
}
|
|
Err(_) => {
|
|
row_clone.set_subtitle("Analysis failed - the AppImage may not be mountable");
|
|
}
|
|
}
|
|
});
|
|
});
|
|
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 observed protocol")
|
|
.subtitle(&format!(
|
|
"When this app was last launched, it used: {}",
|
|
runtime_status
|
|
))
|
|
.build();
|
|
if let Some(ref checked) = record.runtime_wayland_checked {
|
|
let info = gtk::Label::builder()
|
|
.label(checked)
|
|
.css_classes(["dimmed", "caption"])
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
runtime_row.add_suffix(&info);
|
|
}
|
|
compat_group.add(&runtime_row);
|
|
}
|
|
|
|
// FUSE status
|
|
let fuse_system = fuse::detect_system_fuse();
|
|
let fuse_status = record
|
|
.fuse_status
|
|
.as_deref()
|
|
.map(FuseStatus::from_str)
|
|
.unwrap_or(fuse_system.status.clone());
|
|
|
|
let fuse_row = adw::ActionRow::builder()
|
|
.title("FUSE (filesystem)")
|
|
.subtitle(fuse_user_explanation(&fuse_status))
|
|
.tooltip_text(
|
|
"FUSE (Filesystem in Userspace) lets AppImages mount themselves as \
|
|
virtual drives so they can run directly without extracting. Without it, \
|
|
AppImages still work but need to extract to a temp folder first, which \
|
|
is slower. Most systems have FUSE already, but some need libfuse2 installed."
|
|
)
|
|
.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(&format!("Copy '{}' to clipboard", cmd)));
|
|
fuse_row.add_suffix(©_btn);
|
|
}
|
|
compat_group.add(&fuse_row);
|
|
|
|
// Per-app launch method
|
|
let appimage_path = std::path::Path::new(&record.path);
|
|
let app_fuse_status = fuse::determine_app_fuse_status(&fuse_system, appimage_path);
|
|
let launch_method_row = adw::ActionRow::builder()
|
|
.title("Launch method")
|
|
.subtitle(&format!(
|
|
"This app will launch using: {}",
|
|
app_fuse_status.label()
|
|
))
|
|
.tooltip_text(
|
|
"AppImages can launch two ways: 'FUSE mount' mounts the image as a \
|
|
virtual drive (fast, instant startup), or 'extract' unpacks to a temp \
|
|
folder first (slower, but works everywhere). The method is chosen \
|
|
automatically based on your system's FUSE support."
|
|
)
|
|
.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("Sandboxing")
|
|
.description(
|
|
"Isolate this app for extra security. Sandboxing limits what \
|
|
the app can access on your system."
|
|
)
|
|
.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!(
|
|
"Isolate this app using Firejail. Current mode: {}",
|
|
current_mode.display_label()
|
|
)
|
|
} else {
|
|
"Firejail is not installed. Use the row below to copy the install command.".to_string()
|
|
};
|
|
|
|
let firejail_row = adw::SwitchRow::builder()
|
|
.title("Firejail sandbox")
|
|
.subtitle(&sandbox_subtitle)
|
|
.active(current_mode == SandboxMode::Firejail)
|
|
.sensitive(firejail_available)
|
|
.tooltip_text(
|
|
"Sandboxing restricts what an app can access on your system - files, \
|
|
network, devices, etc. This adds a security layer so that even if an \
|
|
app is compromised, it cannot freely access your personal data. Firejail \
|
|
is a lightweight Linux sandboxing tool."
|
|
)
|
|
.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 Firejail")
|
|
.subtitle(firejail_cmd)
|
|
.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(&format!("Copy '{}' to clipboard", firejail_cmd)));
|
|
info_row.add_suffix(©_btn);
|
|
sandbox_group.add(&info_row);
|
|
}
|
|
inner.append(&sandbox_group);
|
|
|
|
clamp.set_child(Some(&inner));
|
|
tab.append(&clamp);
|
|
tab
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tab 3: Security - vulnerability scanning and integrity
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn build_security_tab(record: &AppImageRecord, db: &Rc<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();
|
|
|
|
let group = adw::PreferencesGroup::builder()
|
|
.title("Vulnerability Scanning")
|
|
.description(
|
|
"Scan the libraries bundled inside this AppImage for known \
|
|
security vulnerabilities (CVEs)."
|
|
)
|
|
.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("Bundled libraries")
|
|
.subtitle(&format!(
|
|
"{} libraries detected inside this AppImage",
|
|
libs.len()
|
|
))
|
|
.build();
|
|
group.add(&lib_row);
|
|
|
|
if summary.total() == 0 {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Vulnerabilities")
|
|
.subtitle("No known security issues found in the bundled libraries.")
|
|
.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 scan")
|
|
.subtitle("Check bundled libraries against known CVE databases")
|
|
.activatable(true)
|
|
.tooltip_text(
|
|
"CVE stands for Common Vulnerabilities and Exposures - a public list \
|
|
of known security bugs in software. AppImages bundle their own copies \
|
|
of system libraries, which may contain outdated versions with known \
|
|
vulnerabilities. This scan checks those bundled libraries against the \
|
|
OSV.dev database to find any known issues."
|
|
)
|
|
.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("Scan failed - the AppImage may not be mountable");
|
|
}
|
|
}
|
|
});
|
|
});
|
|
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("SHA256 checksum")
|
|
.subtitle(hash)
|
|
.subtitle_selectable(true)
|
|
.tooltip_text(
|
|
"A SHA256 checksum is a unique fingerprint of the file. If even one \
|
|
byte changes, the checksum changes completely. You can compare this \
|
|
against the developer's published checksum to verify the file hasn't \
|
|
been tampered with or corrupted during download."
|
|
)
|
|
.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 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("Config, cache, and data directories this app may have created.")
|
|
.build();
|
|
|
|
// Discover button
|
|
let discover_row = adw::ActionRow::builder()
|
|
.title("Find app data")
|
|
.subtitle("Search for config, cache, and data directories")
|
|
.activatable(true)
|
|
.build();
|
|
let discover_icon = gtk::Image::from_icon_name("folder-saved-search-symbolic");
|
|
discover_icon.set_valign(gtk::Align::Center);
|
|
discover_row.add_suffix(&discover_icon);
|
|
|
|
let record_clone = record.clone();
|
|
let record_id = record.id;
|
|
discover_row.connect_activated(move |row| {
|
|
row.set_sensitive(false);
|
|
row.set_subtitle("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 associated data directories 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);
|
|
|
|
// File location group
|
|
let location_group = adw::PreferencesGroup::builder()
|
|
.title("File Location")
|
|
.build();
|
|
|
|
let path_row = adw::ActionRow::builder()
|
|
.title("Path")
|
|
.subtitle(&record.path)
|
|
.subtitle_selectable(true)
|
|
.build();
|
|
path_row.add_css_class("property");
|
|
let copy_path_btn = widgets::copy_button(&record.path, Some(toast_overlay));
|
|
copy_path_btn.set_valign(gtk::Align::Center);
|
|
path_row.add_suffix(©_path_btn);
|
|
|
|
// Open folder button
|
|
let folder_path = std::path::Path::new(&record.path)
|
|
.parent()
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
.unwrap_or_default();
|
|
if !folder_path.is_empty() {
|
|
let open_folder_btn = gtk::Button::builder()
|
|
.icon_name("folder-open-symbolic")
|
|
.tooltip_text("Open containing folder")
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
open_folder_btn.add_css_class("flat");
|
|
open_folder_btn.update_property(&[
|
|
gtk::accessible::Property::Label("Open containing folder"),
|
|
]);
|
|
let folder = folder_path.clone();
|
|
open_folder_btn.connect_clicked(move |_| {
|
|
let file = gio::File::for_path(&folder);
|
|
let launcher = gtk::FileLauncher::new(Some(&file));
|
|
launcher.open_containing_folder(gtk::Window::NONE, None::<&gio::Cancellable>, |_| {});
|
|
});
|
|
path_row.add_suffix(&open_folder_btn);
|
|
}
|
|
location_group.add(&path_row);
|
|
inner.append(&location_group);
|
|
|
|
clamp.set_child(Some(&inner));
|
|
tab.append(&clamp);
|
|
tab
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// User-friendly explanations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn wayland_user_explanation(status: &WaylandStatus) -> &'static str {
|
|
match status {
|
|
WaylandStatus::Native =>
|
|
"Runs natively on Wayland - the best experience on modern Linux desktops.",
|
|
WaylandStatus::XWayland =>
|
|
"Uses XWayland for display. Works fine, but may appear slightly \
|
|
blurry on high-resolution screens.",
|
|
WaylandStatus::Possible =>
|
|
"Might work on Wayland with the right settings. Try launching it to find out.",
|
|
WaylandStatus::X11Only =>
|
|
"Designed for X11 only. It will run through XWayland automatically, \
|
|
but you may notice minor display quirks.",
|
|
WaylandStatus::Unknown =>
|
|
"Not yet determined. Launch the app or use 'Analyze toolkit' below to check.",
|
|
}
|
|
}
|
|
|
|
fn fuse_user_explanation(status: &FuseStatus) -> &'static str {
|
|
match status {
|
|
FuseStatus::FullyFunctional =>
|
|
"FUSE is working - AppImages mount directly for fast startup.",
|
|
FuseStatus::Fuse3Only =>
|
|
"Only FUSE 3 found. Some AppImages need FUSE 2. \
|
|
Click the copy button to get the install command.",
|
|
FuseStatus::NoFusermount =>
|
|
"FUSE tools not found. The app will still work by extracting to a \
|
|
temporary folder, but startup will be slower.",
|
|
FuseStatus::NoDevFuse =>
|
|
"/dev/fuse not available. FUSE may not be configured on your system. \
|
|
Apps will extract to a temp folder instead.",
|
|
FuseStatus::MissingLibfuse2 =>
|
|
"libfuse2 is missing. Click the copy button to get the install command.",
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
}
|