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:
@@ -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::<>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.")
|
||||
@@ -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)
|
||||
|
||||
@@ -342,9 +342,13 @@ impl LibraryView {
|
||||
let view_mode_d = view_mode_ref.clone();
|
||||
let search_empty_d = search_empty_ref.clone();
|
||||
|
||||
let debounce_clear = debounce_source.clone();
|
||||
let source_id = glib::timeout_add_local_once(
|
||||
std::time::Duration::from_millis(150),
|
||||
move || {
|
||||
// Clear the stored SourceId so nobody tries to remove a fired timer
|
||||
debounce_clear.set(None);
|
||||
|
||||
let recs = records_d.borrow();
|
||||
let match_flags: Vec<bool> = recs
|
||||
.iter()
|
||||
|
||||
Reference in New Issue
Block a user