Enrich catalog apps with GitHub API data (stars, version, downloads, release date) via two strategies: background drip for repo-level info and on-demand fetch when opening a detail page. - Add github_enrichment module with API calls, asset filtering, and architecture auto-detection for AppImage downloads - DB migrations v14/v15 for GitHub metadata and release asset columns - Extract github_owner/repo from feed links during catalog sync - Display colored stat cards (stars, version, downloads, released) on detail pages with on-demand enrichment - Show stars and version on browse tiles and featured carousel cards - Replace install button with SplitButton dropdown when multiple arch assets available, preferring detected architecture - Disable install button until enrichment completes to prevent stale AppImageHub URL downloads - Keep enrichment banner visible on catalog page until truly complete, showing paused state when rate-limited - Add GitHub token and auto-enrich toggle to preferences
2809 lines
102 KiB
Rust
2809 lines
102 KiB
Rust
use adw::prelude::*;
|
|
use std::cell::Cell;
|
|
use std::io::Read as _;
|
|
use std::rc::Rc;
|
|
|
|
use gtk::gio;
|
|
|
|
use crate::core::backup;
|
|
use crate::core::database::{AppImageRecord, Database};
|
|
use crate::core::footprint;
|
|
use crate::core::fuse::{self, FuseStatus};
|
|
use crate::core::integrator;
|
|
use crate::core::launcher::{self, SandboxMode};
|
|
use crate::core::notification;
|
|
use crate::core::security;
|
|
use crate::core::updater;
|
|
use crate::core::wayland::{self, WaylandStatus};
|
|
use super::integration_dialog;
|
|
use super::update_dialog;
|
|
use super::widgets;
|
|
|
|
pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::NavigationPage {
|
|
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
|
|
|
// Toast overlay for copy actions
|
|
let toast_overlay = adw::ToastOverlay::new();
|
|
|
|
// ViewStack for tabbed content (transitions disabled for instant switching).
|
|
// vhomogeneous=false so the stack sizes to the visible child only,
|
|
// preventing shorter tabs from having excess scrollable empty space.
|
|
let view_stack = adw::ViewStack::new();
|
|
view_stack.set_vhomogeneous(false);
|
|
view_stack.set_enable_transitions(false);
|
|
|
|
// Build tab pages (2-tab layout: About + Details)
|
|
let about_page = build_overview_tab(record, db);
|
|
view_stack.add_titled(&about_page, Some("about"), "About");
|
|
view_stack.page(&about_page).set_icon_name(Some("info-symbolic"));
|
|
|
|
// Details tab combines System + Security + Storage
|
|
let system_content = build_system_tab(record, db, &toast_overlay);
|
|
let security_content = build_security_tab(record, db);
|
|
let storage_content = build_storage_tab(record, db, &toast_overlay);
|
|
|
|
let details_page = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.build();
|
|
details_page.append(&system_content);
|
|
details_page.append(&security_content);
|
|
details_page.append(&storage_content);
|
|
|
|
view_stack.add_titled(&details_page, Some("details"), "Details");
|
|
view_stack.page(&details_page).set_icon_name(Some("applications-system-symbolic"));
|
|
|
|
// Restore last-used tab from GSettings (map old tab names to new ones)
|
|
let settings = gio::Settings::new(crate::config::APP_ID);
|
|
let saved_tab = settings.string("detail-tab");
|
|
let mapped_tab = match saved_tab.as_str() {
|
|
"overview" | "about" => "about",
|
|
"system" | "security" | "storage" | "details" => "details",
|
|
_ => "about",
|
|
};
|
|
view_stack.set_visible_child_name(mapped_tab);
|
|
|
|
// Persist tab choice on switch
|
|
view_stack.connect_visible_child_name_notify(move |stack| {
|
|
if let Some(name) = stack.visible_child_name() {
|
|
settings.set_string("detail-tab", &name).ok();
|
|
}
|
|
});
|
|
|
|
// Banner scrolls with content (not sticky) so tall banners don't eat space
|
|
let scroll_content = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.build();
|
|
scroll_content.append(&build_banner(record));
|
|
scroll_content.append(&view_stack);
|
|
|
|
let scrolled = gtk::ScrolledWindow::builder()
|
|
.child(&scroll_content)
|
|
.vexpand(true)
|
|
.build();
|
|
|
|
toast_overlay.set_child(Some(&scrolled));
|
|
|
|
// Header bar with ViewSwitcher as title widget (standard GNOME pattern)
|
|
let header = adw::HeaderBar::new();
|
|
|
|
let switcher = adw::ViewSwitcher::builder()
|
|
.stack(&view_stack)
|
|
.policy(adw::ViewSwitcherPolicy::Wide)
|
|
.build();
|
|
header.set_title_widget(Some(&switcher));
|
|
|
|
// Launch button
|
|
let launch_button = gtk::Button::builder()
|
|
.label("Launch")
|
|
.tooltip_text("Launch this AppImage")
|
|
.build();
|
|
launch_button.add_css_class("suggested-action");
|
|
launch_button.update_property(&[
|
|
gtk::accessible::Property::Label("Launch application"),
|
|
]);
|
|
let record_id = record.id;
|
|
let path = record.path.clone();
|
|
let app_name_launch = record.app_name.clone().unwrap_or_else(|| record.filename.clone());
|
|
let launch_args_raw = record.launch_args.clone();
|
|
let db_launch = db.clone();
|
|
let toast_launch = toast_overlay.clone();
|
|
let first_run_prompted = record.first_run_prompted;
|
|
let db_perm = db.clone();
|
|
launch_button.connect_clicked(move |btn| {
|
|
// Check if first-run permission dialog should be shown
|
|
if !first_run_prompted {
|
|
let btn_ref = btn.clone();
|
|
let path = path.clone();
|
|
let app_name = app_name_launch.clone();
|
|
let app_name_dialog = app_name.clone();
|
|
let db_launch = db_launch.clone();
|
|
let toast_ref = toast_launch.clone();
|
|
let launch_args_raw = launch_args_raw.clone();
|
|
let db_perm = db_perm.clone();
|
|
crate::ui::permission_dialog::show_permission_dialog(
|
|
btn,
|
|
&app_name_dialog,
|
|
record_id,
|
|
&db_perm,
|
|
move || {
|
|
do_launch(
|
|
&btn_ref, record_id, &path, &app_name,
|
|
launcher::parse_launch_args(launch_args_raw.as_deref()),
|
|
&db_launch, &toast_ref,
|
|
);
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
let launch_args = launcher::parse_launch_args(launch_args_raw.as_deref());
|
|
do_launch(
|
|
btn, record_id, &path, &app_name_launch,
|
|
launch_args, &db_launch, &toast_launch,
|
|
);
|
|
});
|
|
|
|
header.pack_start(&launch_button);
|
|
|
|
// Uninstall button
|
|
let uninstall_button = gtk::Button::builder()
|
|
.icon_name("user-trash-symbolic")
|
|
.tooltip_text("Uninstall this AppImage")
|
|
.build();
|
|
uninstall_button.add_css_class("flat");
|
|
uninstall_button.update_property(&[
|
|
gtk::accessible::Property::Label("Uninstall application"),
|
|
]);
|
|
let record_for_uninstall = record.clone();
|
|
let db_uninstall = db.clone();
|
|
let toast_uninstall = toast_overlay.clone();
|
|
let is_integrated_header = record.integrated;
|
|
uninstall_button.connect_clicked(move |_btn| {
|
|
let fp = footprint::get_footprint(&db_uninstall, record_for_uninstall.id, record_for_uninstall.size_bytes as u64);
|
|
let fp_paths: Vec<(String, String, u64)> = fp.paths.iter()
|
|
.filter(|p| p.exists)
|
|
.map(|p| (
|
|
p.path.to_string_lossy().to_string(),
|
|
p.path_type.label().to_string(),
|
|
p.size_bytes,
|
|
))
|
|
.collect();
|
|
show_uninstall_dialog(
|
|
&toast_uninstall,
|
|
&record_for_uninstall,
|
|
&db_uninstall,
|
|
is_integrated_header,
|
|
&fp_paths,
|
|
);
|
|
});
|
|
header.pack_end(&uninstall_button);
|
|
|
|
// Check for Update button
|
|
let update_button = gtk::Button::builder()
|
|
.icon_name("software-update-available-symbolic")
|
|
.tooltip_text("Check for updates")
|
|
.build();
|
|
update_button.add_css_class("flat");
|
|
update_button.update_property(&[
|
|
gtk::accessible::Property::Label("Check for updates"),
|
|
]);
|
|
let record_for_update = record.clone();
|
|
let db_update = db.clone();
|
|
update_button.connect_clicked(move |btn| {
|
|
update_dialog::show_update_dialog(btn, &record_for_update, &db_update);
|
|
});
|
|
header.pack_end(&update_button);
|
|
|
|
let toolbar = adw::ToolbarView::new();
|
|
toolbar.add_top_bar(&header);
|
|
toolbar.set_content(Some(&toast_overlay));
|
|
|
|
adw::NavigationPage::builder()
|
|
.title(name)
|
|
.tag("detail")
|
|
.child(&toolbar)
|
|
.build()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Banner
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn build_banner(record: &AppImageRecord) -> gtk::Box {
|
|
let banner = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Horizontal)
|
|
.spacing(16)
|
|
.margin_start(18)
|
|
.margin_end(18)
|
|
.build();
|
|
banner.add_css_class("detail-banner");
|
|
banner.set_accessible_role(gtk::AccessibleRole::Banner);
|
|
|
|
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
|
|
|
// Large icon with drop shadow
|
|
let icon = widgets::app_icon(record.icon_path.as_deref(), name, 96);
|
|
icon.set_valign(gtk::Align::Start);
|
|
icon.add_css_class("icon-dropshadow");
|
|
banner.append(&icon);
|
|
|
|
// Text column
|
|
let text_col = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(4)
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
|
|
let name_label = gtk::Label::builder()
|
|
.label(name)
|
|
.css_classes(["title-1"])
|
|
.halign(gtk::Align::Start)
|
|
.build();
|
|
text_col.append(&name_label);
|
|
|
|
// Version + architecture inline
|
|
let meta_parts: Vec<String> = [
|
|
record.app_version.as_deref().map(|v| v.to_string()),
|
|
record.architecture.as_deref().map(|a| a.to_string()),
|
|
]
|
|
.iter()
|
|
.filter_map(|p| p.clone())
|
|
.collect();
|
|
|
|
if !meta_parts.is_empty() {
|
|
let meta_label = gtk::Label::builder()
|
|
.label(&meta_parts.join(" - "))
|
|
.css_classes(["dimmed"])
|
|
.halign(gtk::Align::Start)
|
|
.build();
|
|
text_col.append(&meta_label);
|
|
}
|
|
|
|
// Description
|
|
if let Some(ref desc) = record.description {
|
|
if !desc.is_empty() {
|
|
let desc_label = gtk::Label::builder()
|
|
.label(desc)
|
|
.css_classes(["body"])
|
|
.halign(gtk::Align::Start)
|
|
.wrap(true)
|
|
.xalign(0.0)
|
|
.build();
|
|
text_col.append(&desc_label);
|
|
}
|
|
}
|
|
|
|
// Key status badges inline
|
|
let badge_box = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Horizontal)
|
|
.spacing(6)
|
|
.margin_top(4)
|
|
.build();
|
|
|
|
badge_box.append(&widgets::integration_badge(record.integrated));
|
|
|
|
if let Some(ref ws) = record.wayland_status {
|
|
let status = WaylandStatus::from_str(ws);
|
|
if status != WaylandStatus::Unknown {
|
|
badge_box.append(&widgets::status_badge(status.label(), status.badge_class()));
|
|
}
|
|
}
|
|
|
|
if let (Some(ref latest), Some(ref current)) = (&record.latest_version, &record.app_version) {
|
|
if crate::core::updater::version_is_newer(latest, current) {
|
|
badge_box.append(&widgets::status_badge("Update available", "info"));
|
|
}
|
|
}
|
|
|
|
text_col.append(&badge_box);
|
|
banner.append(&text_col);
|
|
banner
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tab 1: Overview - about, description, links, updates, releases, usage,
|
|
// capabilities, file info
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|
let tab = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(24)
|
|
.margin_top(18)
|
|
.margin_bottom(24)
|
|
.margin_start(18)
|
|
.margin_end(18)
|
|
.build();
|
|
|
|
let clamp = adw::Clamp::builder()
|
|
.maximum_size(800)
|
|
.tightening_threshold(600)
|
|
.build();
|
|
|
|
let inner = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(24)
|
|
.build();
|
|
|
|
// -----------------------------------------------------------------------
|
|
// About section
|
|
// -----------------------------------------------------------------------
|
|
let has_about_data = record.appstream_id.is_some()
|
|
|| record.generic_name.is_some()
|
|
|| record.developer.is_some()
|
|
|| record.license.is_some()
|
|
|| record.project_group.is_some();
|
|
|
|
if has_about_data {
|
|
let about_group = adw::PreferencesGroup::builder()
|
|
.title("About")
|
|
.build();
|
|
|
|
if let Some(ref id) = record.appstream_id {
|
|
let row = adw::ActionRow::builder()
|
|
.title("App ID")
|
|
.subtitle(id)
|
|
.subtitle_selectable(true)
|
|
.build();
|
|
about_group.add(&row);
|
|
}
|
|
|
|
if let Some(ref gn) = record.generic_name {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Type")
|
|
.subtitle(gn)
|
|
.build();
|
|
about_group.add(&row);
|
|
}
|
|
|
|
if let Some(ref dev) = record.developer {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Developer")
|
|
.subtitle(dev)
|
|
.build();
|
|
about_group.add(&row);
|
|
}
|
|
|
|
if let Some(ref lic) = record.license {
|
|
let row = adw::ActionRow::builder()
|
|
.title("License")
|
|
.subtitle(lic)
|
|
.tooltip_text("The license that governs how this app can be used and shared")
|
|
.build();
|
|
about_group.add(&row);
|
|
}
|
|
|
|
if let Some(ref pg) = record.project_group {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Project")
|
|
.subtitle(pg)
|
|
.build();
|
|
about_group.add(&row);
|
|
}
|
|
|
|
inner.append(&about_group);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Description section
|
|
// -----------------------------------------------------------------------
|
|
if let Some(ref desc) = record.appstream_description {
|
|
if !desc.is_empty() {
|
|
let desc_group = adw::PreferencesGroup::builder()
|
|
.title("Description")
|
|
.build();
|
|
|
|
let label = gtk::Label::builder()
|
|
.label(desc)
|
|
.wrap(true)
|
|
.xalign(0.0)
|
|
.css_classes(["body"])
|
|
.selectable(true)
|
|
.margin_top(8)
|
|
.margin_bottom(8)
|
|
.margin_start(12)
|
|
.margin_end(12)
|
|
.build();
|
|
desc_group.add(&label);
|
|
|
|
inner.append(&desc_group);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Screenshots section - async image loading from URLs, click to lightbox
|
|
// -----------------------------------------------------------------------
|
|
if let Some(ref urls_str) = record.screenshot_urls {
|
|
let urls: Vec<&str> = urls_str.lines().filter(|u| !u.is_empty()).collect();
|
|
if !urls.is_empty() {
|
|
let screenshots_group = adw::PreferencesGroup::builder()
|
|
.title("Screenshots")
|
|
.build();
|
|
|
|
let carousel = adw::Carousel::builder()
|
|
.hexpand(true)
|
|
.allow_scroll_wheel(true)
|
|
.allow_mouse_drag(true)
|
|
.build();
|
|
carousel.set_height_request(300);
|
|
|
|
let dots = adw::CarouselIndicatorDots::builder()
|
|
.carousel(&carousel)
|
|
.build();
|
|
|
|
// Store textures for lightbox access
|
|
let textures: Rc<std::cell::RefCell<Vec<Option<gtk::gdk::Texture>>>> =
|
|
Rc::new(std::cell::RefCell::new(vec![None; urls.len()]));
|
|
|
|
for (idx, url) in urls.iter().enumerate() {
|
|
let picture = gtk::Picture::builder()
|
|
.content_fit(gtk::ContentFit::Contain)
|
|
.height_request(300)
|
|
.build();
|
|
if let Some(cursor) = gtk::gdk::Cursor::from_name("pointer", None) {
|
|
picture.set_cursor(Some(&cursor));
|
|
}
|
|
picture.set_can_shrink(true);
|
|
|
|
// Placeholder spinner while loading
|
|
let overlay = gtk::Overlay::builder().child(&picture).build();
|
|
let spinner = adw::Spinner::builder()
|
|
.width_request(32)
|
|
.height_request(32)
|
|
.halign(gtk::Align::Center)
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
overlay.add_overlay(&spinner);
|
|
|
|
// Don't let overlays steal focus (prevents scroll jump on dialog close)
|
|
overlay.set_focusable(false);
|
|
overlay.set_focus_on_click(false);
|
|
|
|
// Click handler for lightbox
|
|
let textures_click = textures.clone();
|
|
let click = gtk::GestureClick::new();
|
|
click.connect_released(move |gesture, _, _, _| {
|
|
let textures_ref = textures_click.borrow();
|
|
if textures_ref.iter().any(|t| t.is_some()) {
|
|
if let Some(widget) = gesture.widget() {
|
|
if let Some(root) = gtk::prelude::WidgetExt::root(&widget) {
|
|
if let Ok(window) = root.downcast::<gtk::Window>() {
|
|
show_screenshot_lightbox(
|
|
&window,
|
|
&textures_click,
|
|
idx,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
overlay.add_controller(click);
|
|
|
|
carousel.append(&overlay);
|
|
|
|
// Load image asynchronously
|
|
let url_owned = url.to_string();
|
|
let picture_ref = picture.clone();
|
|
let spinner_ref = spinner.clone();
|
|
let textures_load = textures.clone();
|
|
glib::spawn_future_local(async move {
|
|
let result = gio::spawn_blocking(move || {
|
|
let mut response = ureq::get(&url_owned)
|
|
.header("User-Agent", "Driftwood-AppImage-Manager/0.1")
|
|
.call()
|
|
.ok()?;
|
|
let mut buf = Vec::new();
|
|
response.body_mut().as_reader().read_to_end(&mut buf).ok()?;
|
|
Some(buf)
|
|
})
|
|
.await;
|
|
|
|
spinner_ref.set_visible(false);
|
|
if let Ok(Some(data)) = result {
|
|
let gbytes = glib::Bytes::from(&data);
|
|
if let Ok(texture) = gtk::gdk::Texture::from_bytes(&gbytes) {
|
|
picture_ref.set_paintable(Some(&texture));
|
|
if let Some(slot) = textures_load.borrow_mut().get_mut(idx) {
|
|
*slot = Some(texture);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
let carousel_box = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(8)
|
|
.margin_top(8)
|
|
.margin_bottom(8)
|
|
.build();
|
|
carousel_box.append(&carousel);
|
|
if urls.len() > 1 {
|
|
carousel_box.append(&dots);
|
|
}
|
|
screenshots_group.add(&carousel_box);
|
|
|
|
inner.append(&screenshots_group);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Links section
|
|
// -----------------------------------------------------------------------
|
|
// Use source_url as fallback for vcs_url (auto-detected from update_info)
|
|
let source_code_url = record.vcs_url.clone().or_else(|| record.source_url.clone());
|
|
|
|
let has_links = record.homepage_url.is_some()
|
|
|| record.bugtracker_url.is_some()
|
|
|| record.donation_url.is_some()
|
|
|| record.help_url.is_some()
|
|
|| source_code_url.is_some();
|
|
|
|
if has_links {
|
|
let links_group = adw::PreferencesGroup::builder()
|
|
.title("Links")
|
|
.build();
|
|
|
|
let link_entries: &[(&str, &str, &Option<String>)] = &[
|
|
("Homepage", "web-browser-symbolic", &record.homepage_url),
|
|
("Report a problem", "bug-symbolic", &record.bugtracker_url),
|
|
("Source code", "code-symbolic", &source_code_url),
|
|
("Documentation", "help-browser-symbolic", &record.help_url),
|
|
("Donate", "emblem-favorite-symbolic", &record.donation_url),
|
|
];
|
|
|
|
for (title, icon_name, url_opt) in link_entries {
|
|
if let Some(ref url) = url_opt {
|
|
let row = adw::ActionRow::builder()
|
|
.title(*title)
|
|
.subtitle(url)
|
|
.activatable(true)
|
|
.build();
|
|
|
|
let icon = gtk::Image::from_icon_name("external-link-symbolic");
|
|
icon.set_valign(gtk::Align::Center);
|
|
row.add_suffix(&icon);
|
|
|
|
// Start with the fallback icon, then try to load favicon
|
|
let prefix_icon = gtk::Image::from_icon_name(*icon_name);
|
|
prefix_icon.set_valign(gtk::Align::Center);
|
|
prefix_icon.set_pixel_size(16);
|
|
row.add_prefix(&prefix_icon);
|
|
fetch_favicon_async(url, &prefix_icon);
|
|
|
|
let url_clone = url.clone();
|
|
row.connect_activated(move |row| {
|
|
let launcher = gtk::UriLauncher::new(&url_clone);
|
|
let window = row
|
|
.root()
|
|
.and_then(|r| r.downcast::<gtk::Window>().ok());
|
|
launcher.launch(
|
|
window.as_ref(),
|
|
None::<>k::gio::Cancellable>,
|
|
|_| {},
|
|
);
|
|
});
|
|
links_group.add(&row);
|
|
}
|
|
}
|
|
|
|
inner.append(&links_group);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Updates section
|
|
// -----------------------------------------------------------------------
|
|
let updates_group = adw::PreferencesGroup::builder()
|
|
.title("Updates")
|
|
.description("Keep this app up to date by checking for new versions.")
|
|
.build();
|
|
|
|
if let Some(ref update_type) = record.update_type {
|
|
let display_label = updater::parse_update_info(update_type)
|
|
.map(|ut| ut.type_label_display())
|
|
.unwrap_or("Unknown format");
|
|
let row = adw::ActionRow::builder()
|
|
.title("Update method")
|
|
.subtitle(&format!(
|
|
"This app checks for updates using: {}",
|
|
display_label
|
|
))
|
|
.tooltip_text(
|
|
"Driftwood can check for newer versions of this app automatically. \
|
|
The developer has included information about where updates are published."
|
|
)
|
|
.build();
|
|
updates_group.add(&row);
|
|
} else {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Update method")
|
|
.subtitle(
|
|
"This app does not include update information. \
|
|
You will need to check for new versions manually."
|
|
)
|
|
.tooltip_text(
|
|
"AppImages can include built-in update information that tells Driftwood \
|
|
where to check for newer versions. This one doesn't have any, so you'll \
|
|
need to download updates yourself from wherever you got the app."
|
|
)
|
|
.build();
|
|
let badge = widgets::status_badge("Manual only", "neutral");
|
|
badge.set_valign(gtk::Align::Center);
|
|
row.add_suffix(&badge);
|
|
updates_group.add(&row);
|
|
}
|
|
|
|
if let Some(ref latest) = record.latest_version {
|
|
let is_newer = record
|
|
.app_version
|
|
.as_deref()
|
|
.map(|current| crate::core::updater::version_is_newer(latest, current))
|
|
.unwrap_or(true);
|
|
|
|
if is_newer {
|
|
let subtitle = format!(
|
|
"A newer version is available: {} (you have {})",
|
|
latest,
|
|
record.app_version.as_deref().unwrap_or("unknown"),
|
|
);
|
|
let row = adw::ActionRow::builder()
|
|
.title("Update available")
|
|
.subtitle(&subtitle)
|
|
.build();
|
|
let badge = widgets::status_badge("Update", "info");
|
|
badge.set_valign(gtk::Align::Center);
|
|
row.add_suffix(&badge);
|
|
updates_group.add(&row);
|
|
} else {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Version status")
|
|
.subtitle("You are running the latest version.")
|
|
.build();
|
|
let badge = widgets::status_badge("Latest", "success");
|
|
badge.set_valign(gtk::Align::Center);
|
|
row.add_suffix(&badge);
|
|
updates_group.add(&row);
|
|
}
|
|
}
|
|
|
|
if let Some(ref checked) = record.update_checked {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Last checked")
|
|
.subtitle(&widgets::relative_time(checked))
|
|
.build();
|
|
updates_group.add(&row);
|
|
}
|
|
inner.append(&updates_group);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Release History section
|
|
// -----------------------------------------------------------------------
|
|
if let Some(ref release_json) = record.release_history {
|
|
if let Ok(releases) = serde_json::from_str::<Vec<serde_json::Value>>(release_json) {
|
|
if !releases.is_empty() {
|
|
let release_group = adw::PreferencesGroup::builder()
|
|
.title("Release History")
|
|
.description("Recent versions of this application.")
|
|
.build();
|
|
|
|
for release in releases.iter().take(10) {
|
|
let version = release
|
|
.get("version")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("?");
|
|
let date = release
|
|
.get("date")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("");
|
|
let desc = release.get("description").and_then(|v| v.as_str());
|
|
|
|
let title = if date.is_empty() {
|
|
format!("v{}", version)
|
|
} else {
|
|
format!("v{} - {}", version, date)
|
|
};
|
|
|
|
if let Some(desc_text) = desc {
|
|
let row = adw::ExpanderRow::builder()
|
|
.title(&title)
|
|
.subtitle("Click to see changes")
|
|
.build();
|
|
|
|
let label = gtk::Label::builder()
|
|
.label(desc_text)
|
|
.wrap(true)
|
|
.xalign(0.0)
|
|
.css_classes(["body"])
|
|
.margin_top(8)
|
|
.margin_bottom(8)
|
|
.margin_start(12)
|
|
.margin_end(12)
|
|
.build();
|
|
let label_row = gtk::ListBoxRow::builder()
|
|
.activatable(false)
|
|
.selectable(false)
|
|
.child(&label)
|
|
.build();
|
|
row.add_row(&label_row);
|
|
|
|
release_group.add(&row);
|
|
} else {
|
|
let row = adw::ActionRow::builder()
|
|
.title(&title)
|
|
.build();
|
|
release_group.add(&row);
|
|
}
|
|
}
|
|
|
|
inner.append(&release_group);
|
|
}
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Usage section
|
|
// -----------------------------------------------------------------------
|
|
let usage_group = adw::PreferencesGroup::builder()
|
|
.title("Usage")
|
|
.build();
|
|
|
|
let stats = launcher::get_launch_stats(db, record.id);
|
|
|
|
let launches_row = adw::ActionRow::builder()
|
|
.title("Total launches")
|
|
.subtitle(&stats.total_launches.to_string())
|
|
.build();
|
|
usage_group.add(&launches_row);
|
|
|
|
if let Some(ref last) = stats.last_launched {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Last launched")
|
|
.subtitle(&widgets::relative_time(last))
|
|
.build();
|
|
usage_group.add(&row);
|
|
}
|
|
inner.append(&usage_group);
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Capabilities section
|
|
// -----------------------------------------------------------------------
|
|
let has_capabilities = record.keywords.is_some()
|
|
|| record.mime_types.is_some()
|
|
|| record.content_rating.is_some()
|
|
|| record.desktop_actions.is_some();
|
|
|
|
if has_capabilities {
|
|
let cap_group = adw::PreferencesGroup::builder()
|
|
.title("Features")
|
|
.build();
|
|
|
|
if let Some(ref kw) = record.keywords {
|
|
if !kw.is_empty() {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Keywords")
|
|
.subtitle(kw)
|
|
.build();
|
|
cap_group.add(&row);
|
|
}
|
|
}
|
|
|
|
if let Some(ref mt) = record.mime_types {
|
|
if !mt.is_empty() {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Supported file types")
|
|
.subtitle(mt)
|
|
.build();
|
|
cap_group.add(&row);
|
|
}
|
|
}
|
|
|
|
if let Some(ref cr) = record.content_rating {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Content rating")
|
|
.subtitle(cr)
|
|
.tooltip_text(
|
|
"An age rating for the app's content, similar to game ratings.",
|
|
)
|
|
.build();
|
|
cap_group.add(&row);
|
|
}
|
|
|
|
if let Some(ref actions_json) = record.desktop_actions {
|
|
if let Ok(actions) = serde_json::from_str::<Vec<String>>(actions_json) {
|
|
if !actions.is_empty() {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Quick actions")
|
|
.subtitle(&actions.join(", "))
|
|
.tooltip_text(
|
|
"Additional actions available from the right-click menu \
|
|
when this app is added to your app menu.",
|
|
)
|
|
.build();
|
|
cap_group.add(&row);
|
|
}
|
|
}
|
|
}
|
|
|
|
inner.append(&cap_group);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// File Information section
|
|
// -----------------------------------------------------------------------
|
|
let info_group = adw::PreferencesGroup::builder()
|
|
.title("File Information")
|
|
.build();
|
|
|
|
let type_str = match record.appimage_type {
|
|
Some(1) => "Type 1 - older format, still widely supported",
|
|
Some(2) => "Type 2 - modern, compressed format",
|
|
_ => "Unknown type",
|
|
};
|
|
let type_row = adw::ActionRow::builder()
|
|
.title("Package format")
|
|
.subtitle(type_str)
|
|
.tooltip_text(
|
|
"AppImages come in two formats. Type 1 is the older format. \
|
|
Type 2 is the current standard - it uses compression for smaller \
|
|
files and faster loading."
|
|
)
|
|
.build();
|
|
info_group.add(&type_row);
|
|
|
|
let exec_row = adw::ActionRow::builder()
|
|
.title("Ready to run")
|
|
.subtitle(if record.is_executable {
|
|
"Yes - this file is ready to launch"
|
|
} else {
|
|
"No - will be fixed automatically when launched"
|
|
})
|
|
.tooltip_text(
|
|
"Whether the file has the permissions needed to run. \
|
|
If not, Driftwood sets this up automatically the first time you launch it."
|
|
)
|
|
.build();
|
|
info_group.add(&exec_row);
|
|
|
|
// Digital signature status
|
|
let sig_row = adw::ActionRow::builder()
|
|
.title("Verified by developer")
|
|
.subtitle(if record.has_signature {
|
|
"Signed by the developer"
|
|
} else {
|
|
"Not signed"
|
|
})
|
|
.tooltip_text(
|
|
"This app was signed by its developer, which helps verify \
|
|
it hasn't been tampered with since it was published."
|
|
)
|
|
.build();
|
|
let sig_badge = if record.has_signature {
|
|
widgets::status_badge("Signed", "success")
|
|
} else {
|
|
widgets::status_badge("Unsigned", "neutral")
|
|
};
|
|
sig_badge.set_valign(gtk::Align::Center);
|
|
sig_row.add_suffix(&sig_badge);
|
|
info_group.add(&sig_row);
|
|
|
|
let seen_row = adw::ActionRow::builder()
|
|
.title("First seen")
|
|
.subtitle(&widgets::relative_time(&record.first_seen))
|
|
.build();
|
|
info_group.add(&seen_row);
|
|
|
|
let scanned_row = adw::ActionRow::builder()
|
|
.title("Last checked")
|
|
.subtitle(&widgets::relative_time(&record.last_scanned))
|
|
.build();
|
|
info_group.add(&scanned_row);
|
|
|
|
if let Some(ref notes) = record.notes {
|
|
if !notes.is_empty() {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Notes")
|
|
.subtitle(notes)
|
|
.build();
|
|
info_group.add(&row);
|
|
}
|
|
}
|
|
inner.append(&info_group);
|
|
|
|
// "You might also like" - similar apps from the user's library
|
|
if let Some(ref cats) = record.categories {
|
|
if let Ok(similar) = db.find_similar_apps(cats, record.id, 4) {
|
|
if !similar.is_empty() {
|
|
let similar_group = adw::PreferencesGroup::builder()
|
|
.title("You might also like")
|
|
.build();
|
|
|
|
for (id, name, icon_path) in &similar {
|
|
let row = adw::ActionRow::builder()
|
|
.title(name.as_str())
|
|
.activatable(true)
|
|
.build();
|
|
|
|
let icon = widgets::app_icon(
|
|
icon_path.as_deref(),
|
|
name,
|
|
32,
|
|
);
|
|
row.add_prefix(&icon);
|
|
|
|
// Store the record ID in the widget name for navigation
|
|
row.set_widget_name(&format!("similar-{}", id));
|
|
|
|
similar_group.add(&row);
|
|
}
|
|
|
|
inner.append(&similar_group);
|
|
}
|
|
}
|
|
}
|
|
|
|
clamp.set_child(Some(&inner));
|
|
tab.append(&clamp);
|
|
tab
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tab 2: System - integration, compatibility, sandboxing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &adw::ToastOverlay) -> gtk::Box {
|
|
let tab = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(24)
|
|
.margin_top(18)
|
|
.margin_bottom(24)
|
|
.margin_start(18)
|
|
.margin_end(18)
|
|
.build();
|
|
|
|
let clamp = adw::Clamp::builder()
|
|
.maximum_size(800)
|
|
.tightening_threshold(600)
|
|
.build();
|
|
|
|
let inner = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(24)
|
|
.build();
|
|
|
|
// Desktop Integration group
|
|
let integration_group = adw::PreferencesGroup::builder()
|
|
.title("App Menu")
|
|
.description(
|
|
"Add this app to your launcher so you can find it \
|
|
like any other installed app."
|
|
)
|
|
.build();
|
|
|
|
let switch_row = adw::SwitchRow::builder()
|
|
.title("Add to application menu")
|
|
.subtitle("Creates a shortcut and installs the app icon")
|
|
.active(record.integrated)
|
|
.tooltip_text(
|
|
"This makes the app appear in your Activities menu and app launcher, \
|
|
just like a regular installed app. It creates a shortcut file and \
|
|
copies the app's icon to your system."
|
|
)
|
|
.build();
|
|
|
|
let record_id = record.id;
|
|
let record_clone = record.clone();
|
|
let db_ref = db.clone();
|
|
let db_dialog = db.clone();
|
|
let record_dialog = record.clone();
|
|
let suppress = Rc::new(Cell::new(false));
|
|
let suppress_ref = suppress.clone();
|
|
switch_row.connect_active_notify(move |row| {
|
|
if suppress_ref.get() {
|
|
return;
|
|
}
|
|
if row.is_active() {
|
|
let row_clone = row.clone();
|
|
let suppress_inner = suppress_ref.clone();
|
|
integration_dialog::show_integration_dialog(
|
|
row,
|
|
&record_dialog,
|
|
&db_dialog,
|
|
move |success| {
|
|
if !success {
|
|
suppress_inner.set(true);
|
|
row_clone.set_active(false);
|
|
suppress_inner.set(false);
|
|
}
|
|
},
|
|
);
|
|
} else {
|
|
integrator::undo_all_modifications(&db_ref, record_id).ok();
|
|
integrator::remove_integration(&record_clone).ok();
|
|
db_ref.set_integrated(record_id, false, None).ok();
|
|
}
|
|
});
|
|
integration_group.add(&switch_row);
|
|
|
|
if record.integrated {
|
|
if let Some(ref desktop_file) = record.desktop_file {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Shortcut file")
|
|
.subtitle(desktop_file)
|
|
.subtitle_selectable(true)
|
|
.build();
|
|
row.add_css_class("property");
|
|
integration_group.add(&row);
|
|
}
|
|
}
|
|
// Autostart toggle
|
|
let autostart_row = adw::SwitchRow::builder()
|
|
.title("Start at login")
|
|
.subtitle("Launch this app automatically when you log in")
|
|
.active(record.autostart)
|
|
.tooltip_text(
|
|
"Creates an autostart entry so this app launches \
|
|
when you log in to your desktop."
|
|
)
|
|
.build();
|
|
let record_autostart = record.clone();
|
|
let db_autostart = db.clone();
|
|
let toast_autostart = toast_overlay.clone();
|
|
let record_id_as = record.id;
|
|
autostart_row.connect_active_notify(move |row| {
|
|
if row.is_active() {
|
|
match integrator::enable_autostart(&db_autostart, &record_autostart) {
|
|
Ok(path) => {
|
|
log::info!("Autostart enabled: {}", path.display());
|
|
toast_autostart.add_toast(adw::Toast::new("Will start at login"));
|
|
}
|
|
Err(e) => {
|
|
log::error!("Failed to enable autostart: {}", e);
|
|
toast_autostart.add_toast(adw::Toast::new("Failed to enable autostart"));
|
|
}
|
|
}
|
|
} else {
|
|
integrator::disable_autostart(&db_autostart, record_id_as).ok();
|
|
toast_autostart.add_toast(adw::Toast::new("Autostart disabled"));
|
|
}
|
|
});
|
|
integration_group.add(&autostart_row);
|
|
|
|
// StartupWMClass row with editable override
|
|
let wm_class_row = adw::EntryRow::builder()
|
|
.title("Window class (advanced)")
|
|
.text(record.startup_wm_class.as_deref().unwrap_or(""))
|
|
.show_apply_button(true)
|
|
.build();
|
|
let db_wm = db.clone();
|
|
let record_id_wm = record.id;
|
|
let toast_wm = toast_overlay.clone();
|
|
wm_class_row.connect_apply(move |row| {
|
|
let text = row.text().to_string();
|
|
let value = if text.is_empty() { None } else { Some(text.as_str()) };
|
|
match db_wm.set_startup_wm_class(record_id_wm, value) {
|
|
Ok(()) => toast_wm.add_toast(adw::Toast::new("WM class updated")),
|
|
Err(e) => {
|
|
log::error!("Failed to set WM class: {}", e);
|
|
toast_wm.add_toast(adw::Toast::new("Failed to update WM class"));
|
|
}
|
|
}
|
|
});
|
|
integration_group.add(&wm_class_row);
|
|
|
|
// System-wide install toggle
|
|
if record.integrated {
|
|
let syswide_row = adw::SwitchRow::builder()
|
|
.title("Install system-wide")
|
|
.subtitle(if record.system_wide {
|
|
"Installed for all users in /opt/driftwood-apps/"
|
|
} else {
|
|
"Make available to all users on this computer"
|
|
})
|
|
.active(record.system_wide)
|
|
.tooltip_text(
|
|
"Copies the AppImage and its shortcut to system directories \
|
|
so all users on this computer can access it. Requires \
|
|
administrator privileges."
|
|
)
|
|
.build();
|
|
|
|
let record_sw = record.clone();
|
|
let db_sw = db.clone();
|
|
let toast_sw = toast_overlay.clone();
|
|
let record_id_sw = record.id;
|
|
syswide_row.connect_active_notify(move |row| {
|
|
if row.is_active() {
|
|
match integrator::install_system_wide(&record_sw, &db_sw) {
|
|
Ok(()) => {
|
|
toast_sw.add_toast(adw::Toast::new("Installed system-wide"));
|
|
}
|
|
Err(e) => {
|
|
log::error!("System-wide install failed: {}", e);
|
|
toast_sw.add_toast(adw::Toast::new("System-wide install failed"));
|
|
row.set_active(false);
|
|
}
|
|
}
|
|
} else {
|
|
match integrator::remove_system_wide(&db_sw, record_id_sw) {
|
|
Ok(()) => {
|
|
toast_sw.add_toast(adw::Toast::new("System-wide install removed"));
|
|
}
|
|
Err(e) => {
|
|
log::error!("Failed to remove system-wide install: {}", e);
|
|
toast_sw.add_toast(adw::Toast::new("Failed to remove system-wide install"));
|
|
row.set_active(true);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
integration_group.add(&syswide_row);
|
|
}
|
|
|
|
inner.append(&integration_group);
|
|
|
|
// Version Rollback group
|
|
if let Some(ref prev_path) = record.previous_version_path {
|
|
let prev = std::path::Path::new(prev_path);
|
|
if prev.exists() {
|
|
let rollback_group = adw::PreferencesGroup::builder()
|
|
.title("Version Rollback")
|
|
.description("A previous version is available from the last update.")
|
|
.build();
|
|
let rollback_row = adw::ActionRow::builder()
|
|
.title("Previous version available")
|
|
.subtitle(prev_path.as_str())
|
|
.build();
|
|
let rollback_btn = gtk::Button::builder()
|
|
.label("Rollback")
|
|
.valign(gtk::Align::Center)
|
|
.css_classes(["destructive-action"])
|
|
.build();
|
|
rollback_row.add_suffix(&rollback_btn);
|
|
|
|
let current_path = record.path.clone();
|
|
let prev_path_owned = prev_path.clone();
|
|
let record_id_rb = record.id;
|
|
let db_rb = db.clone();
|
|
let toast_rb = toast_overlay.clone();
|
|
rollback_btn.connect_clicked(move |btn| {
|
|
let current = std::path::Path::new(¤t_path);
|
|
let prev = std::path::Path::new(&prev_path_owned);
|
|
let temp_path = current.with_extension("AppImage.rollback-tmp");
|
|
// Swap: current -> tmp, prev -> current, tmp -> prev
|
|
let result = std::fs::rename(current, &temp_path)
|
|
.and_then(|_| std::fs::rename(prev, current))
|
|
.and_then(|_| std::fs::rename(&temp_path, prev));
|
|
match result {
|
|
Ok(()) => {
|
|
db_rb.set_previous_version(record_id_rb, Some(&prev_path_owned)).ok();
|
|
toast_rb.add_toast(adw::Toast::new("Rolled back to previous version"));
|
|
btn.set_sensitive(false);
|
|
}
|
|
Err(e) => {
|
|
log::error!("Rollback failed: {}", e);
|
|
toast_rb.add_toast(adw::Toast::new("Rollback failed"));
|
|
}
|
|
}
|
|
});
|
|
|
|
rollback_group.add(&rollback_row);
|
|
inner.append(&rollback_group);
|
|
}
|
|
}
|
|
|
|
// File type associations group
|
|
if let Some(ref mime_str) = record.mime_types {
|
|
let types: Vec<&str> = mime_str.split(';').filter(|s| !s.is_empty()).collect();
|
|
if !types.is_empty() {
|
|
let mime_group = adw::PreferencesGroup::builder()
|
|
.title("Opens these file types")
|
|
.description("File types this app can handle. Set as default to always open them with this app.")
|
|
.build();
|
|
|
|
let app_id = integrator::make_app_id(
|
|
record.app_name.as_deref().unwrap_or(&record.filename),
|
|
);
|
|
|
|
for mime_type in &types {
|
|
let row = adw::ActionRow::builder()
|
|
.title(*mime_type)
|
|
.build();
|
|
|
|
let set_btn = gtk::Button::builder()
|
|
.label("Set Default")
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
set_btn.add_css_class("flat");
|
|
|
|
let db_mime = db.clone();
|
|
let record_id = record.id;
|
|
let app_id_clone = app_id.clone();
|
|
let mime = mime_type.to_string();
|
|
let toast_mime = toast_overlay.clone();
|
|
set_btn.connect_clicked(move |btn| {
|
|
match integrator::set_mime_default(
|
|
&db_mime, record_id, &app_id_clone, &mime,
|
|
) {
|
|
Ok(()) => {
|
|
toast_mime.add_toast(adw::Toast::new(
|
|
&format!("Set as default for {}", mime),
|
|
));
|
|
btn.set_sensitive(false);
|
|
btn.set_label("Default");
|
|
}
|
|
Err(e) => {
|
|
log::error!("Failed to set MIME default: {}", e);
|
|
toast_mime.add_toast(adw::Toast::new("Failed to set default"));
|
|
}
|
|
}
|
|
});
|
|
|
|
row.add_suffix(&set_btn);
|
|
mime_group.add(&row);
|
|
}
|
|
inner.append(&mime_group);
|
|
}
|
|
}
|
|
|
|
// Default Application group
|
|
if let Some(ref cats) = record.categories {
|
|
let caps = integrator::detect_default_capabilities(cats);
|
|
if !caps.is_empty() && record.integrated {
|
|
let default_group = adw::PreferencesGroup::builder()
|
|
.title("Default Application")
|
|
.description(
|
|
"Set this app as your system default for tasks it can handle."
|
|
)
|
|
.build();
|
|
|
|
let app_id = integrator::make_app_id(
|
|
record.app_name.as_deref().unwrap_or(&record.filename),
|
|
);
|
|
|
|
for cap in &caps {
|
|
let row = adw::ActionRow::builder()
|
|
.title(cap.label())
|
|
.build();
|
|
|
|
let set_btn = gtk::Button::builder()
|
|
.label("Set Default")
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
set_btn.add_css_class("flat");
|
|
|
|
let db_def = db.clone();
|
|
let record_id = record.id;
|
|
let app_id_clone = app_id.clone();
|
|
let cap_clone = cap.clone();
|
|
let toast_def = toast_overlay.clone();
|
|
set_btn.connect_clicked(move |btn| {
|
|
match integrator::set_default_app(
|
|
&db_def, record_id, &app_id_clone, &cap_clone,
|
|
) {
|
|
Ok(()) => {
|
|
toast_def.add_toast(adw::Toast::new(
|
|
&format!("Set as default {}", cap_clone.label().to_lowercase()),
|
|
));
|
|
btn.set_sensitive(false);
|
|
btn.set_label("Default");
|
|
}
|
|
Err(e) => {
|
|
log::error!("Failed to set default app: {}", e);
|
|
toast_def.add_toast(adw::Toast::new("Failed to set default"));
|
|
}
|
|
}
|
|
});
|
|
|
|
row.add_suffix(&set_btn);
|
|
default_group.add(&row);
|
|
}
|
|
inner.append(&default_group);
|
|
}
|
|
}
|
|
|
|
// Runtime Compatibility group
|
|
let compat_group = adw::PreferencesGroup::builder()
|
|
.title("Compatibility")
|
|
.description(
|
|
"How well this app works with your system. \
|
|
Most issues can be fixed with a quick install."
|
|
)
|
|
.build();
|
|
|
|
let wayland_status = record
|
|
.wayland_status
|
|
.as_deref()
|
|
.map(WaylandStatus::from_str)
|
|
.unwrap_or(WaylandStatus::Unknown);
|
|
|
|
let wayland_row = adw::ActionRow::builder()
|
|
.title("Display compatibility")
|
|
.subtitle(wayland_user_explanation(&wayland_status))
|
|
.tooltip_text(
|
|
"Wayland is the modern display system on Linux. Apps built for the \
|
|
older system (X11) still work, but native Wayland apps look sharper, \
|
|
especially on high-resolution screens."
|
|
)
|
|
.build();
|
|
let wayland_badge = widgets::status_badge(wayland_status.label(), wayland_status.badge_class());
|
|
wayland_badge.set_valign(gtk::Align::Center);
|
|
wayland_row.add_suffix(&wayland_badge);
|
|
compat_group.add(&wayland_row);
|
|
|
|
// Analyze toolkit button
|
|
let analyze_row = adw::ActionRow::builder()
|
|
.title("Detect app framework")
|
|
.subtitle("Check which technology this app is built with")
|
|
.activatable(true)
|
|
.tooltip_text(
|
|
"Apps are built with different frameworks (like GTK, Qt, or Electron). \
|
|
Knowing the framework helps predict how well the app works with \
|
|
your display system."
|
|
)
|
|
.build();
|
|
let analyze_icon = gtk::Image::from_icon_name("system-search-symbolic");
|
|
analyze_icon.set_valign(gtk::Align::Center);
|
|
analyze_row.add_suffix(&analyze_icon);
|
|
|
|
let record_path_wayland = record.path.clone();
|
|
analyze_row.connect_activated(move |row| {
|
|
row.set_sensitive(false);
|
|
row.update_state(&[gtk::accessible::State::Busy(true)]);
|
|
row.set_subtitle("Analyzing...");
|
|
let row_clone = row.clone();
|
|
let path = record_path_wayland.clone();
|
|
glib::spawn_future_local(async move {
|
|
let result = gio::spawn_blocking(move || {
|
|
let appimage_path = std::path::Path::new(&path);
|
|
wayland::analyze_appimage(appimage_path)
|
|
})
|
|
.await;
|
|
|
|
row_clone.set_sensitive(true);
|
|
row_clone.update_state(&[gtk::accessible::State::Busy(false)]);
|
|
match result {
|
|
Ok(analysis) => {
|
|
let toolkit_label = analysis.toolkit.label();
|
|
let _lib_count = analysis.libraries_found.len();
|
|
row_clone.set_subtitle(&format!(
|
|
"Built with: {}",
|
|
toolkit_label,
|
|
));
|
|
}
|
|
Err(_) => {
|
|
row_clone.set_subtitle("Analysis failed - could not read the app's contents");
|
|
}
|
|
}
|
|
});
|
|
});
|
|
compat_group.add(&analyze_row);
|
|
|
|
// Runtime Wayland status (from post-launch analysis)
|
|
if let Some(ref runtime_status) = record.runtime_wayland_status {
|
|
let runtime_row = adw::ActionRow::builder()
|
|
.title("Last display mode")
|
|
.subtitle(&format!(
|
|
"When this app was last launched, it used: {}",
|
|
runtime_status
|
|
))
|
|
.tooltip_text(
|
|
"How the app connected to your display the last time it was launched."
|
|
)
|
|
.build();
|
|
if let Some(ref checked) = record.runtime_wayland_checked {
|
|
let info = gtk::Label::builder()
|
|
.label(checked)
|
|
.css_classes(["dimmed", "caption"])
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
runtime_row.add_suffix(&info);
|
|
}
|
|
compat_group.add(&runtime_row);
|
|
}
|
|
|
|
// FUSE status - always use live system detection (the stored fuse_status
|
|
// is per-app AppImageFuseStatus, not the system-level FuseStatus)
|
|
let fuse_system = fuse::detect_system_fuse();
|
|
let fuse_status = fuse_system.status.clone();
|
|
|
|
let fuse_row = adw::ActionRow::builder()
|
|
.title("App mounting")
|
|
.subtitle(fuse_user_explanation(&fuse_status))
|
|
.tooltip_text(
|
|
"FUSE lets apps like AppImages run directly without unpacking first. \
|
|
Without it, apps still work but take a little longer to start."
|
|
)
|
|
.build();
|
|
let fuse_badge = widgets::status_badge_with_icon(
|
|
if fuse_status.is_functional() { "emblem-ok-symbolic" } else { "dialog-warning-symbolic" },
|
|
fuse_status.label(),
|
|
fuse_status.badge_class(),
|
|
);
|
|
fuse_badge.set_valign(gtk::Align::Center);
|
|
fuse_row.add_suffix(&fuse_badge);
|
|
if let Some(cmd) = fuse_install_command(&fuse_status) {
|
|
let copy_btn = widgets::copy_button(cmd, Some(toast_overlay));
|
|
copy_btn.set_valign(gtk::Align::Center);
|
|
copy_btn.set_tooltip_text(Some("Copy install command to clipboard"));
|
|
fuse_row.add_suffix(©_btn);
|
|
}
|
|
compat_group.add(&fuse_row);
|
|
|
|
// Per-app launch method
|
|
let appimage_path = std::path::Path::new(&record.path);
|
|
let app_fuse_status = fuse::determine_app_fuse_status(&fuse_system, appimage_path);
|
|
let launch_method_row = adw::ActionRow::builder()
|
|
.title("Startup method")
|
|
.subtitle(&format!(
|
|
"This app will launch using: {}",
|
|
app_fuse_status.label()
|
|
))
|
|
.tooltip_text(
|
|
"AppImages can start two ways: mounting (fast, instant startup) or \
|
|
unpacking to a temporary folder first (slower, but works everywhere). \
|
|
The method is chosen automatically based on your system."
|
|
)
|
|
.build();
|
|
let launch_badge = widgets::status_badge(
|
|
fuse_system.status.as_str(),
|
|
app_fuse_status.badge_class(),
|
|
);
|
|
launch_badge.set_valign(gtk::Align::Center);
|
|
launch_method_row.add_suffix(&launch_badge);
|
|
compat_group.add(&launch_method_row);
|
|
inner.append(&compat_group);
|
|
|
|
// Sandboxing group
|
|
let sandbox_group = adw::PreferencesGroup::builder()
|
|
.title("App Isolation")
|
|
.description(
|
|
"Restrict what this app can access on your system \
|
|
for extra security."
|
|
)
|
|
.build();
|
|
|
|
let current_mode = record
|
|
.sandbox_mode
|
|
.as_deref()
|
|
.map(SandboxMode::from_str)
|
|
.unwrap_or(SandboxMode::None);
|
|
|
|
let firejail_available = launcher::has_firejail();
|
|
|
|
let sandbox_subtitle = if firejail_available {
|
|
format!("Currently: {}", current_mode.display_label())
|
|
} else {
|
|
"Not available yet. Install with one command using the button below.".to_string()
|
|
};
|
|
|
|
let firejail_row = adw::SwitchRow::builder()
|
|
.title("Isolate this app")
|
|
.subtitle(&sandbox_subtitle)
|
|
.active(current_mode == SandboxMode::Firejail)
|
|
.sensitive(firejail_available)
|
|
.tooltip_text(
|
|
"Sandboxing restricts what an app can access - files, network, \
|
|
devices, etc. Even if an app has a security issue, it can't \
|
|
freely access your personal data."
|
|
)
|
|
.build();
|
|
|
|
let record_id = record.id;
|
|
let db_ref = db.clone();
|
|
firejail_row.connect_active_notify(move |row| {
|
|
let mode = if row.is_active() {
|
|
SandboxMode::Firejail
|
|
} else {
|
|
SandboxMode::None
|
|
};
|
|
if let Err(e) = db_ref.update_sandbox_mode(record_id, Some(mode.as_str())) {
|
|
log::warn!("Failed to update sandbox mode: {}", e);
|
|
}
|
|
});
|
|
sandbox_group.add(&firejail_row);
|
|
|
|
if !firejail_available {
|
|
let firejail_cmd = "sudo apt install firejail";
|
|
let info_row = adw::ActionRow::builder()
|
|
.title("Install app isolation")
|
|
.subtitle("Install with one command")
|
|
.build();
|
|
let badge = widgets::status_badge("Missing", "warning");
|
|
badge.set_valign(gtk::Align::Center);
|
|
info_row.add_suffix(&badge);
|
|
let copy_btn = widgets::copy_button(firejail_cmd, Some(toast_overlay));
|
|
copy_btn.set_valign(gtk::Align::Center);
|
|
copy_btn.set_tooltip_text(Some("Copy install command to clipboard"));
|
|
info_row.add_suffix(©_btn);
|
|
sandbox_group.add(&info_row);
|
|
}
|
|
inner.append(&sandbox_group);
|
|
|
|
clamp.set_child(Some(&inner));
|
|
tab.append(&clamp);
|
|
tab
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tab 3: Security - vulnerability scanning and integrity
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|
let tab = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(24)
|
|
.margin_top(18)
|
|
.margin_bottom(24)
|
|
.margin_start(18)
|
|
.margin_end(18)
|
|
.build();
|
|
|
|
let clamp = adw::Clamp::builder()
|
|
.maximum_size(800)
|
|
.tightening_threshold(600)
|
|
.build();
|
|
|
|
let inner = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(24)
|
|
.build();
|
|
|
|
// Verification group
|
|
let verify_group = adw::PreferencesGroup::builder()
|
|
.title("Verification")
|
|
.description("Check the integrity and authenticity of this AppImage")
|
|
.build();
|
|
|
|
// Signature status
|
|
let sig_label = if record.has_signature { "Signature present" } else { "No signature" };
|
|
let sig_badge_class = if record.has_signature { "success" } else { "neutral" };
|
|
let sig_row = adw::ActionRow::builder()
|
|
.title("Verified by developer")
|
|
.subtitle(sig_label)
|
|
.build();
|
|
let sig_badge = widgets::status_badge(sig_label, sig_badge_class);
|
|
sig_badge.set_valign(gtk::Align::Center);
|
|
sig_row.add_suffix(&sig_badge);
|
|
verify_group.add(&sig_row);
|
|
|
|
// SHA256 hash
|
|
if let Some(ref hash) = record.sha256 {
|
|
let sha_row = adw::ActionRow::builder()
|
|
.title("File checksum")
|
|
.subtitle(hash)
|
|
.subtitle_selectable(true)
|
|
.build();
|
|
verify_group.add(&sha_row);
|
|
}
|
|
|
|
// Verification status
|
|
let verify_status = record.verification_status.as_deref().unwrap_or("not_checked");
|
|
let verify_row = adw::ActionRow::builder()
|
|
.title("Verification")
|
|
.subtitle(match verify_status {
|
|
"signed_valid" => "Signed and verified",
|
|
"signed_invalid" => "Signature present but invalid",
|
|
"checksum_match" => "Checksum verified",
|
|
"checksum_mismatch" => "Checksum mismatch - file may be corrupted",
|
|
_ => "Not verified",
|
|
})
|
|
.build();
|
|
let verify_badge = widgets::status_badge(
|
|
match verify_status {
|
|
"signed_valid" | "checksum_match" => "Verified",
|
|
"signed_invalid" | "checksum_mismatch" => "Failed",
|
|
_ => "Unchecked",
|
|
},
|
|
match verify_status {
|
|
"signed_valid" | "checksum_match" => "success",
|
|
"signed_invalid" | "checksum_mismatch" => "error",
|
|
_ => "neutral",
|
|
},
|
|
);
|
|
verify_badge.set_valign(gtk::Align::Center);
|
|
verify_row.add_suffix(&verify_badge);
|
|
verify_group.add(&verify_row);
|
|
|
|
// Manual SHA256 verification input
|
|
let sha_input = adw::EntryRow::builder()
|
|
.title("Verify SHA256")
|
|
.show_apply_button(true)
|
|
.build();
|
|
sha_input.set_tooltip_text(Some("Paste an expected SHA256 hash to verify this file"));
|
|
|
|
let record_path = record.path.clone();
|
|
let record_id_v = record.id;
|
|
let db_verify = db.clone();
|
|
sha_input.connect_apply(move |row| {
|
|
let expected = row.text().to_string();
|
|
if expected.is_empty() {
|
|
return;
|
|
}
|
|
let path = record_path.clone();
|
|
let db_ref = db_verify.clone();
|
|
let row_ref = row.clone();
|
|
glib::spawn_future_local(async move {
|
|
let result = gio::spawn_blocking(move || {
|
|
let p = std::path::Path::new(&path);
|
|
crate::core::verification::verify_sha256(p, &expected)
|
|
})
|
|
.await;
|
|
|
|
if let Ok(status) = result {
|
|
let label = status.label();
|
|
row_ref.set_title(&format!("Verify SHA256 - {}", label));
|
|
db_ref.set_verification_status(record_id_v, status.as_str()).ok();
|
|
}
|
|
});
|
|
});
|
|
verify_group.add(&sha_input);
|
|
|
|
// Check signature button
|
|
let check_sig_row = adw::ActionRow::builder()
|
|
.title("Check embedded signature")
|
|
.subtitle("Verify GPG signature if present")
|
|
.activatable(true)
|
|
.build();
|
|
let sig_arrow = gtk::Image::from_icon_name("go-next-symbolic");
|
|
sig_arrow.set_valign(gtk::Align::Center);
|
|
check_sig_row.add_suffix(&sig_arrow);
|
|
let record_path_sig = record.path.clone();
|
|
let record_id_sig = record.id;
|
|
let db_sig = db.clone();
|
|
check_sig_row.connect_activated(move |row| {
|
|
row.set_sensitive(false);
|
|
row.set_subtitle("Checking...");
|
|
let path = record_path_sig.clone();
|
|
let db_ref = db_sig.clone();
|
|
let row_ref = row.clone();
|
|
glib::spawn_future_local(async move {
|
|
let result = gio::spawn_blocking(move || {
|
|
let p = std::path::Path::new(&path);
|
|
crate::core::verification::check_embedded_signature(p)
|
|
})
|
|
.await;
|
|
|
|
if let Ok(status) = result {
|
|
row_ref.set_subtitle(&status.label());
|
|
db_ref.set_verification_status(record_id_sig, status.as_str()).ok();
|
|
let result_badge = widgets::status_badge(
|
|
match status.badge_class() {
|
|
"success" => "Verified",
|
|
"error" => "Failed",
|
|
_ => "Unknown",
|
|
},
|
|
status.badge_class(),
|
|
);
|
|
result_badge.set_valign(gtk::Align::Center);
|
|
row_ref.add_suffix(&result_badge);
|
|
}
|
|
row_ref.set_sensitive(true);
|
|
});
|
|
});
|
|
verify_group.add(&check_sig_row);
|
|
inner.append(&verify_group);
|
|
|
|
let group = adw::PreferencesGroup::builder()
|
|
.title("Security Check")
|
|
.description(
|
|
"Check this app for known security issues."
|
|
)
|
|
.build();
|
|
|
|
let libs = db.get_bundled_libraries(record.id).unwrap_or_default();
|
|
let summary = db.get_cve_summary(record.id).unwrap_or_default();
|
|
|
|
if libs.is_empty() {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Security scan")
|
|
.subtitle(
|
|
"This app has not been scanned yet. Use the button below \
|
|
to check for known vulnerabilities."
|
|
)
|
|
.build();
|
|
let badge = widgets::status_badge("Not scanned", "neutral");
|
|
badge.set_valign(gtk::Align::Center);
|
|
row.add_suffix(&badge);
|
|
group.add(&row);
|
|
} else {
|
|
let lib_row = adw::ActionRow::builder()
|
|
.title("Included components")
|
|
.subtitle(&format!(
|
|
"{} components found inside this app",
|
|
libs.len()
|
|
))
|
|
.tooltip_text(
|
|
"Apps bundle their own copies of system components (libraries). \
|
|
These can sometimes contain known security issues if they're outdated."
|
|
)
|
|
.build();
|
|
group.add(&lib_row);
|
|
|
|
if summary.total() == 0 {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Vulnerabilities")
|
|
.subtitle("No known security issues found.")
|
|
.build();
|
|
let badge = widgets::status_badge("Clean", "success");
|
|
badge.set_valign(gtk::Align::Center);
|
|
row.add_suffix(&badge);
|
|
group.add(&row);
|
|
} else {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Vulnerabilities")
|
|
.subtitle(&format!(
|
|
"{} known issue{} found. Consider updating this app if a newer version is available.",
|
|
summary.total(),
|
|
if summary.total() == 1 { "" } else { "s" },
|
|
))
|
|
.build();
|
|
let badge = widgets::status_badge(summary.max_severity(), summary.badge_class());
|
|
badge.set_valign(gtk::Align::Center);
|
|
row.add_suffix(&badge);
|
|
group.add(&row);
|
|
}
|
|
}
|
|
|
|
// Scan button
|
|
let scan_row = adw::ActionRow::builder()
|
|
.title("Run security check")
|
|
.subtitle("Check for known security issues in this app")
|
|
.activatable(true)
|
|
.tooltip_text(
|
|
"This checks the components inside this app against a public database \
|
|
of known security issues to see if any are outdated or vulnerable."
|
|
)
|
|
.build();
|
|
let scan_icon = gtk::Image::from_icon_name("security-medium-symbolic");
|
|
scan_icon.set_valign(gtk::Align::Center);
|
|
scan_row.add_suffix(&scan_icon);
|
|
|
|
let record_id = record.id;
|
|
let record_path = record.path.clone();
|
|
scan_row.connect_activated(move |row| {
|
|
row.set_sensitive(false);
|
|
row.update_state(&[gtk::accessible::State::Busy(true)]);
|
|
row.set_subtitle("Scanning - this may take a moment...");
|
|
let row_clone = row.clone();
|
|
let path = record_path.clone();
|
|
glib::spawn_future_local(async move {
|
|
let result = gio::spawn_blocking(move || {
|
|
let bg_db = Database::open().expect("Failed to open database");
|
|
let appimage_path = std::path::Path::new(&path);
|
|
security::scan_and_store(&bg_db, record_id, appimage_path)
|
|
})
|
|
.await;
|
|
|
|
row_clone.set_sensitive(true);
|
|
row_clone.update_state(&[gtk::accessible::State::Busy(false)]);
|
|
match result {
|
|
Ok(scan_result) => {
|
|
let total = scan_result.total_cves();
|
|
if total == 0 {
|
|
row_clone.set_subtitle("No vulnerabilities found - looking good!");
|
|
} else {
|
|
row_clone.set_subtitle(&format!(
|
|
"Found {} known issue{}. Check for app updates.",
|
|
total,
|
|
if total == 1 { "" } else { "s" },
|
|
));
|
|
}
|
|
}
|
|
Err(_) => {
|
|
row_clone.set_subtitle("Check failed - could not read the app's contents");
|
|
}
|
|
}
|
|
});
|
|
});
|
|
group.add(&scan_row);
|
|
inner.append(&group);
|
|
|
|
// Integrity group
|
|
if record.sha256.is_some() {
|
|
let integrity_group = adw::PreferencesGroup::builder()
|
|
.title("Integrity")
|
|
.description("Verify that the file has not been modified or corrupted.")
|
|
.build();
|
|
|
|
if let Some(ref hash) = record.sha256 {
|
|
let hash_row = adw::ActionRow::builder()
|
|
.title("File fingerprint")
|
|
.subtitle(hash)
|
|
.subtitle_selectable(true)
|
|
.tooltip_text(
|
|
"A unique code (SHA256 checksum) generated from the file's contents. \
|
|
If the file changes in any way, this code changes too. You can compare \
|
|
it against the developer's published fingerprint to verify nothing \
|
|
was altered."
|
|
)
|
|
.build();
|
|
hash_row.add_css_class("property");
|
|
integrity_group.add(&hash_row);
|
|
}
|
|
inner.append(&integrity_group);
|
|
}
|
|
|
|
clamp.set_child(Some(&inner));
|
|
tab.append(&clamp);
|
|
tab
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tab 4: Storage - disk usage, data paths, file location
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn build_storage_tab(
|
|
record: &AppImageRecord,
|
|
db: &Rc<Database>,
|
|
toast_overlay: &adw::ToastOverlay,
|
|
) -> gtk::Box {
|
|
let tab = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(24)
|
|
.margin_top(18)
|
|
.margin_bottom(24)
|
|
.margin_start(18)
|
|
.margin_end(18)
|
|
.build();
|
|
|
|
let clamp = adw::Clamp::builder()
|
|
.maximum_size(800)
|
|
.tightening_threshold(600)
|
|
.build();
|
|
|
|
let inner = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(24)
|
|
.build();
|
|
|
|
// Disk usage group
|
|
let size_group = adw::PreferencesGroup::builder()
|
|
.title("Disk Usage")
|
|
.description(
|
|
"Disk space used by this app, including any configuration, \
|
|
cache, or data files it may have created."
|
|
)
|
|
.build();
|
|
|
|
let fp = footprint::get_footprint(db, record.id, record.size_bytes as u64);
|
|
|
|
let appimage_row = adw::ActionRow::builder()
|
|
.title("AppImage file")
|
|
.subtitle(&widgets::format_size(record.size_bytes))
|
|
.build();
|
|
size_group.add(&appimage_row);
|
|
|
|
if record.is_portable {
|
|
let location_label = if let Some(ref mp) = record.mount_point {
|
|
format!("Portable ({})", mp)
|
|
} else {
|
|
"Portable (removable media)".to_string()
|
|
};
|
|
let location_row = adw::ActionRow::builder()
|
|
.title("Location")
|
|
.subtitle(&location_label)
|
|
.build();
|
|
let badge = widgets::status_badge("Portable", "info");
|
|
badge.set_valign(gtk::Align::Center);
|
|
location_row.add_suffix(&badge);
|
|
size_group.add(&location_row);
|
|
}
|
|
|
|
if !fp.paths.is_empty() {
|
|
let categories = [
|
|
("Configuration", fp.config_size),
|
|
("Application data", fp.data_size),
|
|
("Cache", fp.cache_size),
|
|
("State", fp.state_size),
|
|
("Other", fp.other_size),
|
|
];
|
|
for (label, size) in &categories {
|
|
if *size > 0 {
|
|
let row = adw::ActionRow::builder()
|
|
.title(*label)
|
|
.subtitle(&widgets::format_size(*size as i64))
|
|
.build();
|
|
size_group.add(&row);
|
|
}
|
|
}
|
|
let data_total = fp.data_total();
|
|
if data_total > 0 {
|
|
let total_row = adw::ActionRow::builder()
|
|
.title("Total disk usage")
|
|
.subtitle(&format!(
|
|
"{} (AppImage) + {} (app data) = {}",
|
|
widgets::format_size(record.size_bytes),
|
|
widgets::format_size(data_total as i64),
|
|
widgets::format_size(fp.total_size() as i64),
|
|
))
|
|
.build();
|
|
size_group.add(&total_row);
|
|
}
|
|
}
|
|
inner.append(&size_group);
|
|
|
|
// Data paths group
|
|
let paths_group = adw::PreferencesGroup::builder()
|
|
.title("App Data")
|
|
.description("Settings, cache, and data this app may have saved to your system.")
|
|
.build();
|
|
|
|
// Discover button
|
|
let discover_row = adw::ActionRow::builder()
|
|
.title("Find app data")
|
|
.subtitle("Search for files this app has saved")
|
|
.activatable(true)
|
|
.build();
|
|
let discover_icon = gtk::Image::from_icon_name("folder-saved-search-symbolic");
|
|
discover_icon.set_valign(gtk::Align::Center);
|
|
discover_row.add_suffix(&discover_icon);
|
|
|
|
let record_clone = record.clone();
|
|
let record_id = record.id;
|
|
discover_row.connect_activated(move |row| {
|
|
row.set_sensitive(false);
|
|
row.set_subtitle("Searching...");
|
|
let row_clone = row.clone();
|
|
let rec = record_clone.clone();
|
|
glib::spawn_future_local(async move {
|
|
let result = gio::spawn_blocking(move || {
|
|
let bg_db = Database::open().expect("Failed to open database");
|
|
footprint::discover_and_store(&bg_db, record_id, &rec);
|
|
footprint::get_footprint(&bg_db, record_id, rec.size_bytes as u64)
|
|
})
|
|
.await;
|
|
|
|
row_clone.set_sensitive(true);
|
|
match result {
|
|
Ok(fp) => {
|
|
let count = fp.paths.len();
|
|
if count == 0 {
|
|
row_clone.set_subtitle("No saved data found");
|
|
} else {
|
|
row_clone.set_subtitle(&format!(
|
|
"Found {} path{} using {}",
|
|
count,
|
|
if count == 1 { "" } else { "s" },
|
|
widgets::format_size(fp.data_total() as i64),
|
|
));
|
|
}
|
|
}
|
|
Err(_) => {
|
|
row_clone.set_subtitle("Search failed");
|
|
}
|
|
}
|
|
});
|
|
});
|
|
paths_group.add(&discover_row);
|
|
|
|
// Individual discovered paths
|
|
for dp in &fp.paths {
|
|
if dp.exists {
|
|
let row = adw::ActionRow::builder()
|
|
.title(dp.path_type.label())
|
|
.subtitle(&*dp.path.to_string_lossy())
|
|
.subtitle_selectable(true)
|
|
.build();
|
|
let icon = gtk::Image::from_icon_name(dp.path_type.icon_name());
|
|
icon.set_pixel_size(16);
|
|
row.add_prefix(&icon);
|
|
let conf_badge = widgets::status_badge(
|
|
dp.confidence.as_str(),
|
|
dp.confidence.badge_class(),
|
|
);
|
|
conf_badge.set_valign(gtk::Align::Center);
|
|
row.add_suffix(&conf_badge);
|
|
let size_label = gtk::Label::builder()
|
|
.label(&widgets::format_size(dp.size_bytes as i64))
|
|
.css_classes(["dimmed", "caption"])
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
row.add_suffix(&size_label);
|
|
paths_group.add(&row);
|
|
}
|
|
}
|
|
inner.append(&paths_group);
|
|
|
|
// Backups group
|
|
inner.append(&build_backup_group(record.id, toast_overlay));
|
|
|
|
// Uninstall group
|
|
let uninstall_group = adw::PreferencesGroup::builder()
|
|
.title("Uninstall")
|
|
.description("Remove this AppImage and optionally clean up its data")
|
|
.build();
|
|
|
|
let uninstall_btn = gtk::Button::builder()
|
|
.label("Uninstall AppImage...")
|
|
.halign(gtk::Align::Start)
|
|
.margin_top(6)
|
|
.margin_bottom(6)
|
|
.build();
|
|
uninstall_btn.add_css_class("destructive-action");
|
|
uninstall_btn.add_css_class("pill");
|
|
|
|
let record_uninstall = record.clone();
|
|
let db_uninstall = db.clone();
|
|
let toast_uninstall = toast_overlay.clone();
|
|
let fp_paths: Vec<(String, String, u64)> = fp.paths.iter()
|
|
.filter(|p| p.exists)
|
|
.map(|p| (
|
|
p.path.to_string_lossy().to_string(),
|
|
p.path_type.label().to_string(),
|
|
p.size_bytes,
|
|
))
|
|
.collect();
|
|
let is_integrated = record.integrated;
|
|
uninstall_btn.connect_clicked(move |_btn| {
|
|
show_uninstall_dialog(
|
|
&toast_uninstall,
|
|
&record_uninstall,
|
|
&db_uninstall,
|
|
is_integrated,
|
|
&fp_paths,
|
|
);
|
|
});
|
|
uninstall_group.add(&adw::ActionRow::builder()
|
|
.child(&uninstall_btn)
|
|
.build());
|
|
inner.append(&uninstall_group);
|
|
|
|
// File location group
|
|
let location_group = adw::PreferencesGroup::builder()
|
|
.title("File Location")
|
|
.build();
|
|
|
|
let path_row = adw::ActionRow::builder()
|
|
.title("File location")
|
|
.subtitle(&record.path)
|
|
.subtitle_selectable(true)
|
|
.build();
|
|
path_row.add_css_class("property");
|
|
let copy_path_btn = widgets::copy_button(&record.path, Some(toast_overlay));
|
|
copy_path_btn.set_valign(gtk::Align::Center);
|
|
path_row.add_suffix(©_path_btn);
|
|
|
|
// Open folder button
|
|
let folder_path = std::path::Path::new(&record.path)
|
|
.parent()
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
.unwrap_or_default();
|
|
if !folder_path.is_empty() {
|
|
let open_folder_btn = gtk::Button::builder()
|
|
.icon_name("folder-open-symbolic")
|
|
.tooltip_text("Open containing folder")
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
open_folder_btn.add_css_class("flat");
|
|
open_folder_btn.update_property(&[
|
|
gtk::accessible::Property::Label("Open containing folder"),
|
|
]);
|
|
let folder = folder_path.clone();
|
|
open_folder_btn.connect_clicked(move |_| {
|
|
let file = gio::File::for_path(&folder);
|
|
let launcher = gtk::FileLauncher::new(Some(&file));
|
|
launcher.open_containing_folder(gtk::Window::NONE, None::<&gio::Cancellable>, |_| {});
|
|
});
|
|
path_row.add_suffix(&open_folder_btn);
|
|
}
|
|
location_group.add(&path_row);
|
|
inner.append(&location_group);
|
|
|
|
clamp.set_child(Some(&inner));
|
|
tab.append(&clamp);
|
|
tab
|
|
}
|
|
|
|
fn build_backup_group(record_id: i64, toast_overlay: &adw::ToastOverlay) -> adw::PreferencesGroup {
|
|
let group = adw::PreferencesGroup::builder()
|
|
.title("Backups")
|
|
.description("Save and restore this app's settings and data files")
|
|
.build();
|
|
|
|
// Fetch existing backups
|
|
let db = Database::open().ok();
|
|
let backups = db.as_ref()
|
|
.map(|d| backup::list_backups(d, Some(record_id)))
|
|
.unwrap_or_default();
|
|
|
|
if backups.is_empty() {
|
|
let empty_row = adw::ActionRow::builder()
|
|
.title("No backups yet")
|
|
.subtitle("Create a backup to save this app's settings and data")
|
|
.build();
|
|
let empty_icon = gtk::Image::from_icon_name("document-open-symbolic");
|
|
empty_icon.set_valign(gtk::Align::Center);
|
|
empty_icon.add_css_class("dim-label");
|
|
empty_row.add_prefix(&empty_icon);
|
|
group.add(&empty_row);
|
|
} else {
|
|
for b in &backups {
|
|
log::debug!(
|
|
"Listing backup id={} for appimage_id={} at {}",
|
|
b.id, b.appimage_id, b.archive_path,
|
|
);
|
|
let expander = adw::ExpanderRow::builder()
|
|
.title(&b.created_at)
|
|
.subtitle(&format!(
|
|
"v{} - {} - {} file{}",
|
|
b.app_version.as_deref().unwrap_or("unknown"),
|
|
widgets::format_size(b.archive_size),
|
|
b.path_count,
|
|
if b.path_count == 1 { "" } else { "s" },
|
|
))
|
|
.build();
|
|
|
|
// Exists/missing badge using icon + text (not color-only)
|
|
let badge = if b.exists {
|
|
let bx = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Horizontal)
|
|
.spacing(4)
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
let icon = gtk::Image::from_icon_name("emblem-ok-symbolic");
|
|
icon.add_css_class("success");
|
|
let label = gtk::Label::new(Some("Exists"));
|
|
label.add_css_class("caption");
|
|
label.add_css_class("success");
|
|
bx.append(&icon);
|
|
bx.append(&label);
|
|
bx
|
|
} else {
|
|
let bx = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Horizontal)
|
|
.spacing(4)
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
let icon = gtk::Image::from_icon_name("dialog-warning-symbolic");
|
|
icon.add_css_class("warning");
|
|
let label = gtk::Label::new(Some("Missing"));
|
|
label.add_css_class("caption");
|
|
label.add_css_class("warning");
|
|
bx.append(&icon);
|
|
bx.append(&label);
|
|
bx
|
|
};
|
|
expander.add_suffix(&badge);
|
|
|
|
// Restore row
|
|
let restore_row = adw::ActionRow::builder()
|
|
.title("Restore")
|
|
.subtitle("Restore settings and data from this backup")
|
|
.activatable(true)
|
|
.tooltip_text("Overwrite current settings with this backup")
|
|
.build();
|
|
let restore_icon = gtk::Image::from_icon_name("edit-undo-symbolic");
|
|
restore_icon.set_valign(gtk::Align::Center);
|
|
restore_row.add_prefix(&restore_icon);
|
|
restore_row.update_property(&[
|
|
gtk::accessible::Property::Label("Restore this backup"),
|
|
]);
|
|
|
|
let archive_path = b.archive_path.clone();
|
|
let toast_restore = toast_overlay.clone();
|
|
restore_row.connect_activated(move |row| {
|
|
row.set_sensitive(false);
|
|
row.set_subtitle("Restoring...");
|
|
let row_clone = row.clone();
|
|
let path = archive_path.clone();
|
|
let toast = toast_restore.clone();
|
|
glib::spawn_future_local(async move {
|
|
let result = gio::spawn_blocking(move || {
|
|
backup::restore_backup(std::path::Path::new(&path))
|
|
})
|
|
.await;
|
|
|
|
row_clone.set_sensitive(true);
|
|
match result {
|
|
Ok(Ok(res)) => {
|
|
let skip_note = if res.paths_skipped > 0 {
|
|
format!(" ({} skipped)", res.paths_skipped)
|
|
} else {
|
|
String::new()
|
|
};
|
|
row_clone.set_subtitle(&format!(
|
|
"Restored {} path{}{}",
|
|
res.paths_restored,
|
|
if res.paths_restored == 1 { "" } else { "s" },
|
|
skip_note,
|
|
));
|
|
let toast_msg = format!(
|
|
"Restored {} path{}{}",
|
|
res.paths_restored,
|
|
if res.paths_restored == 1 { "" } else { "s" },
|
|
skip_note,
|
|
);
|
|
toast.add_toast(adw::Toast::new(&toast_msg));
|
|
log::info!(
|
|
"Backup restored: app={}, paths_restored={}, paths_skipped={}",
|
|
res.manifest.app_name, res.paths_restored, res.paths_skipped,
|
|
);
|
|
}
|
|
_ => {
|
|
row_clone.set_subtitle("Restore failed");
|
|
toast.add_toast(adw::Toast::new("Failed to restore backup"));
|
|
}
|
|
}
|
|
});
|
|
});
|
|
expander.add_row(&restore_row);
|
|
|
|
// Delete row
|
|
let delete_row = adw::ActionRow::builder()
|
|
.title("Delete")
|
|
.subtitle("Permanently remove this backup")
|
|
.activatable(true)
|
|
.tooltip_text("Delete this backup archive from disk")
|
|
.build();
|
|
let delete_icon = gtk::Image::from_icon_name("edit-delete-symbolic");
|
|
delete_icon.set_valign(gtk::Align::Center);
|
|
delete_row.add_prefix(&delete_icon);
|
|
delete_row.update_property(&[
|
|
gtk::accessible::Property::Label("Delete this backup"),
|
|
]);
|
|
|
|
let backup_id = b.id;
|
|
let toast_delete = toast_overlay.clone();
|
|
let group_ref = group.clone();
|
|
let expander_ref = expander.clone();
|
|
delete_row.connect_activated(move |row| {
|
|
row.set_sensitive(false);
|
|
row.set_subtitle("Deleting...");
|
|
let row_clone = row.clone();
|
|
let toast = toast_delete.clone();
|
|
let group_del = group_ref.clone();
|
|
let expander_del = expander_ref.clone();
|
|
glib::spawn_future_local(async move {
|
|
let result = gio::spawn_blocking(move || {
|
|
let bg_db = Database::open().expect("Failed to open database");
|
|
backup::delete_backup(&bg_db, backup_id)
|
|
})
|
|
.await;
|
|
|
|
match result {
|
|
Ok(Ok(())) => {
|
|
group_del.remove(&expander_del);
|
|
toast.add_toast(adw::Toast::new("Backup deleted"));
|
|
}
|
|
_ => {
|
|
row_clone.set_sensitive(true);
|
|
row_clone.set_subtitle("Delete failed");
|
|
toast.add_toast(adw::Toast::new("Failed to delete backup"));
|
|
}
|
|
}
|
|
});
|
|
});
|
|
expander.add_row(&delete_row);
|
|
|
|
group.add(&expander);
|
|
}
|
|
}
|
|
|
|
// Create backup row (always shown at bottom)
|
|
let create_row = adw::ActionRow::builder()
|
|
.title("Create backup")
|
|
.subtitle("Save a snapshot of this app's settings and data")
|
|
.activatable(true)
|
|
.tooltip_text("Create a new backup of this app's configuration files")
|
|
.build();
|
|
let create_icon = gtk::Image::from_icon_name("list-add-symbolic");
|
|
create_icon.set_valign(gtk::Align::Center);
|
|
create_row.add_prefix(&create_icon);
|
|
create_row.update_property(&[
|
|
gtk::accessible::Property::Label("Create a new backup"),
|
|
]);
|
|
|
|
let toast_create = toast_overlay.clone();
|
|
create_row.connect_activated(move |row| {
|
|
row.set_sensitive(false);
|
|
row.set_subtitle("Creating backup...");
|
|
let row_clone = row.clone();
|
|
let toast = toast_create.clone();
|
|
glib::spawn_future_local(async move {
|
|
let result = gio::spawn_blocking(move || {
|
|
let bg_db = Database::open().expect("Failed to open database");
|
|
backup::create_backup(&bg_db, record_id)
|
|
})
|
|
.await;
|
|
|
|
row_clone.set_sensitive(true);
|
|
match result {
|
|
Ok(Ok(path)) => {
|
|
let filename = path.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("backup");
|
|
row_clone.set_subtitle(&format!("Created {}", filename));
|
|
toast.add_toast(adw::Toast::new("Backup created"));
|
|
}
|
|
Ok(Err(backup::BackupError::NoPaths)) => {
|
|
row_clone.set_subtitle("Try discovering app data first");
|
|
toast.add_toast(adw::Toast::new("No data paths found to back up"));
|
|
}
|
|
_ => {
|
|
row_clone.set_subtitle("Backup failed");
|
|
toast.add_toast(adw::Toast::new("Failed to create backup"));
|
|
}
|
|
}
|
|
});
|
|
});
|
|
group.add(&create_row);
|
|
|
|
group
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// User-friendly explanations
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn wayland_user_explanation(status: &WaylandStatus) -> &'static str {
|
|
match status {
|
|
WaylandStatus::Native =>
|
|
"Fully compatible - the best experience on your system.",
|
|
WaylandStatus::XWayland =>
|
|
"Works through a compatibility layer. May appear slightly \
|
|
blurry on high-resolution screens.",
|
|
WaylandStatus::Possible =>
|
|
"Might work well. Try launching it to find out.",
|
|
WaylandStatus::X11Only =>
|
|
"Built for an older display system. It will run automatically, \
|
|
but you may notice minor visual quirks.",
|
|
WaylandStatus::Unknown =>
|
|
"Not yet determined. Launch the app or use 'Detect app framework' to check.",
|
|
}
|
|
}
|
|
|
|
fn fuse_user_explanation(status: &FuseStatus) -> &'static str {
|
|
match status {
|
|
FuseStatus::FullyFunctional =>
|
|
"Everything is set up - apps start instantly.",
|
|
FuseStatus::Fuse3Only =>
|
|
"A small system component is missing. Most apps will still work, \
|
|
but some may need it. Copy the install command to fix this.",
|
|
FuseStatus::NoFusermount =>
|
|
"A system component is missing, so apps will take a little longer \
|
|
to start. They'll still work fine.",
|
|
FuseStatus::NoDevFuse =>
|
|
"Your system doesn't support instant app mounting. Apps will unpack \
|
|
before starting, which takes a bit longer.",
|
|
FuseStatus::MissingLibfuse2 =>
|
|
"A small system component is needed for fast startup. \
|
|
Copy the install command to fix this.",
|
|
}
|
|
}
|
|
|
|
/// Return an install command for a FUSE status that needs one, or None.
|
|
fn fuse_install_command(status: &FuseStatus) -> Option<&'static str> {
|
|
match status {
|
|
FuseStatus::Fuse3Only | FuseStatus::MissingLibfuse2 => {
|
|
Some("sudo apt install libfuse2")
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Show a screenshot in a fullscreen lightbox window with prev/next navigation.
|
|
/// Uses a separate gtk::Window to avoid parent scroll position interference.
|
|
pub fn show_screenshot_lightbox(
|
|
parent: >k::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: >k::Image) {
|
|
// Extract domain from URL for favicon service
|
|
let domain = url
|
|
.trim_start_matches("https://")
|
|
.trim_start_matches("http://")
|
|
.split('/')
|
|
.next()
|
|
.unwrap_or("")
|
|
.to_string();
|
|
|
|
if domain.is_empty() {
|
|
return;
|
|
}
|
|
|
|
let image_ref = image.clone();
|
|
glib::spawn_future_local(async move {
|
|
let favicon_url = format!(
|
|
"https://www.google.com/s2/favicons?domain={}&sz=32",
|
|
domain
|
|
);
|
|
let result = gio::spawn_blocking(move || {
|
|
let mut response = ureq::get(&favicon_url)
|
|
.header("User-Agent", "Driftwood-AppImage-Manager/0.1")
|
|
.call()
|
|
.ok()?;
|
|
let mut buf = Vec::new();
|
|
response.body_mut().as_reader().read_to_end(&mut buf).ok()?;
|
|
if buf.len() > 100 {
|
|
Some(buf)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.await;
|
|
|
|
if let Ok(Some(data)) = result {
|
|
let gbytes = glib::Bytes::from(&data);
|
|
if let Ok(texture) = gtk::gdk::Texture::from_bytes(&gbytes) {
|
|
image_ref.set_paintable(Some(&texture));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn show_uninstall_dialog(
|
|
toast_overlay: &adw::ToastOverlay,
|
|
record: &AppImageRecord,
|
|
db: &Rc<Database>,
|
|
is_integrated: bool,
|
|
data_paths: &[(String, String, u64)],
|
|
) {
|
|
show_uninstall_dialog_with_callback(toast_overlay, record, db, is_integrated, data_paths, None);
|
|
}
|
|
|
|
pub fn show_uninstall_dialog_with_callback(
|
|
toast_overlay: &adw::ToastOverlay,
|
|
record: &AppImageRecord,
|
|
db: &Rc<Database>,
|
|
is_integrated: bool,
|
|
data_paths: &[(String, String, u64)],
|
|
on_complete: Option<Box<dyn FnOnce() + 'static>>,
|
|
) {
|
|
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
|
let dialog = adw::AlertDialog::builder()
|
|
.heading(&format!("Uninstall {}?", name))
|
|
.body("Select what to remove:")
|
|
.build();
|
|
dialog.add_response("cancel", "Cancel");
|
|
dialog.add_response("uninstall", "Uninstall");
|
|
dialog.set_response_appearance("uninstall", adw::ResponseAppearance::Destructive);
|
|
dialog.set_default_response(Some("cancel"));
|
|
dialog.set_close_response("cancel");
|
|
|
|
let extra = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(8)
|
|
.margin_top(12)
|
|
.build();
|
|
|
|
// Checkbox: AppImage file (always checked, not unchecked - it's the main thing)
|
|
let appimage_check = gtk::CheckButton::builder()
|
|
.label(&format!("AppImage file ({})", widgets::format_size(record.size_bytes)))
|
|
.active(true)
|
|
.build();
|
|
extra.append(&appimage_check);
|
|
|
|
// Checkbox: Desktop integration
|
|
let integration_check = if is_integrated {
|
|
let check = gtk::CheckButton::builder()
|
|
.label("Remove desktop integration")
|
|
.active(true)
|
|
.build();
|
|
extra.append(&check);
|
|
Some(check)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Checkboxes for each discovered data path
|
|
let mut path_checks: Vec<(gtk::CheckButton, String)> = Vec::new();
|
|
for (path, label, size) in data_paths {
|
|
let check = gtk::CheckButton::builder()
|
|
.label(&format!("{} - {} ({})", label, path, widgets::format_size(*size as i64)))
|
|
.active(true)
|
|
.build();
|
|
extra.append(&check);
|
|
path_checks.push((check, path.clone()));
|
|
}
|
|
|
|
dialog.set_extra_child(Some(&extra));
|
|
|
|
let record_id = record.id;
|
|
let record_path = record.path.clone();
|
|
let db_ref = db.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
let on_complete = std::cell::Cell::new(on_complete);
|
|
dialog.connect_response(Some("uninstall"), move |_dlg, _response| {
|
|
// Remove integration if checked
|
|
if let Some(ref check) = integration_check {
|
|
if check.is_active() {
|
|
integrator::undo_all_modifications(&db_ref, record_id).ok();
|
|
if let Ok(Some(rec)) = db_ref.get_appimage_by_id(record_id) {
|
|
integrator::remove_integration(&rec).ok();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove checked data paths
|
|
for (check, path) in &path_checks {
|
|
if check.is_active() {
|
|
let p = std::path::Path::new(path);
|
|
if p.is_dir() {
|
|
std::fs::remove_dir_all(p).ok();
|
|
} else if p.is_file() {
|
|
std::fs::remove_file(p).ok();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove AppImage file if checked
|
|
if appimage_check.is_active() {
|
|
std::fs::remove_file(&record_path).ok();
|
|
}
|
|
|
|
// Remove from database
|
|
db_ref.remove_appimage(record_id).ok();
|
|
|
|
toast_ref.add_toast(adw::Toast::new("AppImage uninstalled"));
|
|
|
|
// Run the completion callback if provided
|
|
if let Some(cb) = on_complete.take() {
|
|
cb();
|
|
}
|
|
|
|
// Navigate back (the detail view is now stale)
|
|
if let Some(nav) = toast_ref.ancestor(adw::NavigationView::static_type()) {
|
|
let nav: adw::NavigationView = nav.downcast().unwrap();
|
|
nav.pop();
|
|
}
|
|
});
|
|
|
|
dialog.present(Some(toast_overlay));
|
|
}
|
|
|
|
fn do_launch(
|
|
btn: >k::Button,
|
|
record_id: i64,
|
|
path: &str,
|
|
app_name: &str,
|
|
launch_args: Vec<String>,
|
|
db: &Rc<Database>,
|
|
toast_overlay: &adw::ToastOverlay,
|
|
) {
|
|
btn.set_sensitive(false);
|
|
let btn_ref = btn.clone();
|
|
let path = path.to_string();
|
|
let app_name = app_name.to_string();
|
|
let db_launch = db.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
glib::spawn_future_local(async move {
|
|
let path_bg = path.clone();
|
|
let result = gio::spawn_blocking(move || {
|
|
let appimage_path = std::path::Path::new(&path_bg);
|
|
launcher::launch_appimage(
|
|
&Database::open().expect("DB open"),
|
|
record_id,
|
|
appimage_path,
|
|
"gui_detail",
|
|
&launch_args,
|
|
&[],
|
|
)
|
|
}).await;
|
|
|
|
btn_ref.set_sensitive(true);
|
|
match result {
|
|
Ok(launcher::LaunchResult::Started { pid, method }) => {
|
|
log::info!("Launched: {} (PID: {}, method: {})", path, pid, method.as_str());
|
|
|
|
let db_wayland = db_launch.clone();
|
|
let path_clone = path.clone();
|
|
glib::spawn_future_local(async move {
|
|
glib::timeout_future(std::time::Duration::from_secs(3)).await;
|
|
let analysis_result = gio::spawn_blocking(move || {
|
|
wayland::analyze_running_process(pid)
|
|
}).await;
|
|
if let Ok(Ok(analysis)) = analysis_result {
|
|
let status_str = analysis.as_status_str();
|
|
log::info!(
|
|
"Runtime Wayland: {} -> {} (pid={}, env: {:?})",
|
|
path_clone, analysis.status_label(), analysis.pid, analysis.env_vars,
|
|
);
|
|
db_wayland.update_runtime_wayland_status(record_id, status_str).ok();
|
|
}
|
|
});
|
|
}
|
|
Ok(launcher::LaunchResult::Crashed { exit_code, stderr, method }) => {
|
|
log::error!("App crashed on launch (exit {}, method: {}): {}", exit_code.unwrap_or(-1), method.as_str(), stderr);
|
|
widgets::show_crash_dialog(&btn_ref, &app_name, exit_code, &stderr);
|
|
if let Some(app) = gtk::gio::Application::default() {
|
|
notification::send_system_notification(
|
|
&app,
|
|
&format!("crash-{}", record_id),
|
|
&format!("{} crashed", app_name),
|
|
&stderr.chars().take(200).collect::<String>(),
|
|
gtk::gio::NotificationPriority::Urgent,
|
|
);
|
|
}
|
|
}
|
|
Ok(launcher::LaunchResult::Failed(msg)) => {
|
|
log::error!("Failed to launch: {}", msg);
|
|
let toast = adw::Toast::builder()
|
|
.title(&format!("Could not launch: {}", msg))
|
|
.timeout(5)
|
|
.build();
|
|
toast_ref.add_toast(toast);
|
|
}
|
|
Err(_) => {
|
|
log::error!("Launch task panicked");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|