Add comprehensive AppImage metadata extraction, display, and bug fixes

- Add AppStream XML parser (quick-xml) to extract rich metadata from bundled
  metainfo/appdata files: description, developer, license, URLs, keywords,
  categories, content rating, release history, and MIME types
- Database migration v9: 16 new columns for extended metadata storage
- Extended inspector to parse AppStream XML, desktop entry extended fields,
  and detect binary signatures without executing AppImages
- Redesigned detail view overview tab with 8 conditional groups: About,
  Description, Links, Release History, Usage, Capabilities, File Info
- Fix crash on exit caused by stale GLib SourceId removal in debounce timers
- Fix wayland.rs executing AppImages directly to detect squashfs offset,
  replaced with safe binary scan via find_squashfs_offset_for()
- Fix scan skipping re-analysis of apps missing new metadata fields
This commit is contained in:
lashman
2026-02-27 18:31:07 +02:00
parent 39b773fed5
commit 1bb7a3bdc0
13 changed files with 1239 additions and 24 deletions

View File

@@ -257,7 +257,8 @@ fn build_banner(record: &AppImageRecord) -> gtk::Box {
}
// ---------------------------------------------------------------------------
// Tab 1: Overview - updates, usage, basic file info
// Tab 1: Overview - about, description, links, updates, releases, usage,
// capabilities, file info
// ---------------------------------------------------------------------------
fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
@@ -280,7 +281,151 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.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::<&gtk::gio::Cancellable>,
|_| {},
);
});
links_group.add(&row);
}
}
inner.append(&links_group);
}
// -----------------------------------------------------------------------
// Updates section
// -----------------------------------------------------------------------
let updates_group = adw::PreferencesGroup::builder()
.title("Updates")
.description("Keep this app up to date by checking for new versions.")
@@ -364,7 +509,71 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
}
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();
@@ -386,7 +595,72 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
}
inner.append(&usage_group);
// File info section
// -----------------------------------------------------------------------
// 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();
@@ -418,6 +692,28 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.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)