Rewrite detail view copy for beginners, add tab transitions and lightbox fixes

This commit is contained in:
2026-02-27 19:48:59 +02:00
parent fb632c16c6
commit e20759d1cb
4 changed files with 316 additions and 137 deletions

View File

@@ -6,7 +6,7 @@ license = "GPL-3.0-or-later"
[dependencies] [dependencies]
gtk = { version = "0.11", package = "gtk4", features = ["v4_16"] } gtk = { version = "0.11", package = "gtk4", features = ["v4_16"] }
adw = { version = "0.9", package = "libadwaita", features = ["v1_6"] } adw = { version = "0.9", package = "libadwaita", features = ["v1_7"] }
glib = "0.22" glib = "0.22"
gio = "0.22" gio = "0.22"

View File

@@ -221,3 +221,22 @@ button.flat:not(.pill):not(.suggested-action):not(.destructive-action) {
min-width: 24px; min-width: 24px;
min-height: 24px; min-height: 24px;
} }
/* ===== Screenshot Lightbox ===== */
window.lightbox {
background-color: rgba(0, 0, 0, 0.92);
border-radius: 12px;
}
window.lightbox .lightbox-counter {
background: rgba(0, 0, 0, 0.6);
color: white;
border-radius: 12px;
padding: 4px 12px;
font-size: 0.85em;
}
window.lightbox .lightbox-nav {
min-width: 48px;
min-height: 48px;
}

View File

@@ -23,8 +23,10 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
// Toast overlay for copy actions // Toast overlay for copy actions
let toast_overlay = adw::ToastOverlay::new(); let toast_overlay = adw::ToastOverlay::new();
// ViewStack for tabbed content // ViewStack for tabbed content with crossfade transitions
let view_stack = adw::ViewStack::new(); let view_stack = adw::ViewStack::new();
view_stack.set_enable_transitions(true);
view_stack.set_transition_duration(200);
// Build tab pages // Build tab pages
let overview_page = build_overview_tab(record, db); let overview_page = build_overview_tab(record, db);
@@ -324,14 +326,14 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title("License") .title("License")
.subtitle(lic) .subtitle(lic)
.tooltip_text("SPDX license identifier for this application") .tooltip_text("The license that governs how this app can be used and shared")
.build(); .build();
about_group.add(&row); about_group.add(&row);
} }
if let Some(ref pg) = record.project_group { if let Some(ref pg) = record.project_group {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title("Project group") .title("Project")
.subtitle(pg) .subtitle(pg)
.build(); .build();
about_group.add(&row); about_group.add(&row);
@@ -411,16 +413,24 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.build(); .build();
overlay.add_overlay(&spinner); 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 // Click handler for lightbox
let textures_click = textures.clone(); let textures_click = textures.clone();
let click = gtk::GestureClick::new(); let click = gtk::GestureClick::new();
click.connect_released(move |gesture, _, _, _| { click.connect_released(move |gesture, _, _, _| {
let textures_ref = textures_click.borrow(); let textures_ref = textures_click.borrow();
if let Some(Some(ref texture)) = textures_ref.get(idx) { if textures_ref.iter().any(|t| t.is_some()) {
if let Some(widget) = gesture.widget() { if let Some(widget) = gesture.widget() {
if let Some(root) = gtk::prelude::WidgetExt::root(&widget) { if let Some(root) = gtk::prelude::WidgetExt::root(&widget) {
if let Ok(window) = root.downcast::<gtk::Window>() { if let Ok(window) = root.downcast::<gtk::Window>() {
show_screenshot_lightbox(&window, texture); show_screenshot_lightbox(
&window,
&textures_click,
idx,
);
} }
} }
} }
@@ -497,7 +507,7 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let link_entries: &[(&str, &str, &Option<String>)] = &[ let link_entries: &[(&str, &str, &Option<String>)] = &[
("Homepage", "web-browser-symbolic", &record.homepage_url), ("Homepage", "web-browser-symbolic", &record.homepage_url),
("Bug tracker", "bug-symbolic", &record.bugtracker_url), ("Report a problem", "bug-symbolic", &record.bugtracker_url),
("Source code", "code-symbolic", &record.vcs_url), ("Source code", "code-symbolic", &record.vcs_url),
("Documentation", "help-browser-symbolic", &record.help_url), ("Documentation", "help-browser-symbolic", &record.help_url),
("Donate", "emblem-favorite-symbolic", &record.donation_url), ("Donate", "emblem-favorite-symbolic", &record.donation_url),
@@ -560,9 +570,8 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
display_label display_label
)) ))
.tooltip_text( .tooltip_text(
"AppImages can include built-in update information that tells Driftwood \ "Driftwood can check for newer versions of this app automatically. \
where to check for newer versions. Common methods include GitHub releases, \ The developer has included information about where updates are published."
zsync (efficient delta updates), and direct download URLs."
) )
.build(); .build();
updates_group.add(&row); updates_group.add(&row);
@@ -723,7 +732,7 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
if has_capabilities { if has_capabilities {
let cap_group = adw::PreferencesGroup::builder() let cap_group = adw::PreferencesGroup::builder()
.title("Capabilities") .title("Features")
.build(); .build();
if let Some(ref kw) = record.keywords { if let Some(ref kw) = record.keywords {
@@ -751,7 +760,7 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.title("Content rating") .title("Content rating")
.subtitle(cr) .subtitle(cr)
.tooltip_text( .tooltip_text(
"Content rating based on the OARS (Open Age Ratings Service) system", "An age rating for the app's content, similar to game ratings.",
) )
.build(); .build();
cap_group.add(&row); cap_group.add(&row);
@@ -761,11 +770,11 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
if let Ok(actions) = serde_json::from_str::<Vec<String>>(actions_json) { if let Ok(actions) = serde_json::from_str::<Vec<String>>(actions_json) {
if !actions.is_empty() { if !actions.is_empty() {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title("Desktop actions") .title("Quick actions")
.subtitle(&actions.join(", ")) .subtitle(&actions.join(", "))
.tooltip_text( .tooltip_text(
"Additional actions available from the right-click menu \ "Additional actions available from the right-click menu \
when this app is integrated into the desktop", when this app is added to your app menu.",
) )
.build(); .build();
cap_group.add(&row); cap_group.add(&row);
@@ -784,29 +793,32 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.build(); .build();
let type_str = match record.appimage_type { let type_str = match record.appimage_type {
Some(1) => "Type 1 (ISO 9660) - older format, still widely supported", Some(1) => "Type 1 - older format, still widely supported",
Some(2) => "Type 2 (SquashFS) - modern format, most common today", Some(2) => "Type 2 - modern, compressed format",
_ => "Unknown type", _ => "Unknown type",
}; };
let type_row = adw::ActionRow::builder() let type_row = adw::ActionRow::builder()
.title("AppImage format") .title("Package format")
.subtitle(type_str) .subtitle(type_str)
.tooltip_text( .tooltip_text(
"AppImages come in two formats. Type 1 uses an ISO 9660 filesystem \ "AppImages come in two formats. Type 1 is the older format. \
(older, simpler). Type 2 uses SquashFS (modern, compressed, smaller \ Type 2 is the current standard - it uses compression for smaller \
files). Type 2 is the standard today and is what most AppImage tools \ files and faster loading."
produce."
) )
.build(); .build();
info_group.add(&type_row); info_group.add(&type_row);
let exec_row = adw::ActionRow::builder() let exec_row = adw::ActionRow::builder()
.title("Executable") .title("Ready to run")
.subtitle(if record.is_executable { .subtitle(if record.is_executable {
"Yes - this file has execute permission" "Yes - this file is ready to launch"
} else { } else {
"No - execute permission is missing. It will be set automatically when launched." "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(); .build();
info_group.add(&exec_row); info_group.add(&exec_row);
@@ -814,13 +826,13 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let sig_row = adw::ActionRow::builder() let sig_row = adw::ActionRow::builder()
.title("Digital signature") .title("Digital signature")
.subtitle(if record.has_signature { .subtitle(if record.has_signature {
"This AppImage contains a GPG signature" "Signed by the developer"
} else { } else {
"Not signed" "Not signed"
}) })
.tooltip_text( .tooltip_text(
"AppImages can be digitally signed by their author using GPG. \ "This app was signed by its developer, which helps verify \
A signature helps verify that the file hasn't been tampered with." it hasn't been tampered with since it was published."
) )
.build(); .build();
let sig_badge = if record.has_signature { let sig_badge = if record.has_signature {
@@ -839,7 +851,7 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
info_group.add(&seen_row); info_group.add(&seen_row);
let scanned_row = adw::ActionRow::builder() let scanned_row = adw::ActionRow::builder()
.title("Last scanned") .title("Last checked")
.subtitle(&record.last_scanned) .subtitle(&record.last_scanned)
.build(); .build();
info_group.add(&scanned_row); info_group.add(&scanned_row);
@@ -886,21 +898,21 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
// Desktop Integration group // Desktop Integration group
let integration_group = adw::PreferencesGroup::builder() let integration_group = adw::PreferencesGroup::builder()
.title("Desktop Integration") .title("App Menu")
.description( .description(
"Show this app in your Activities menu and app launcher, \ "Add this app to your launcher so you can find it \
just like a regular installed application." like any other installed app."
) )
.build(); .build();
let switch_row = adw::SwitchRow::builder() let switch_row = adw::SwitchRow::builder()
.title("Add to application menu") .title("Add to application menu")
.subtitle("Creates a .desktop entry and installs the app icon") .subtitle("Creates a shortcut and installs the app icon")
.active(record.integrated) .active(record.integrated)
.tooltip_text( .tooltip_text(
"Desktop integration makes this AppImage appear in your Activities menu \ "This makes the app appear in your Activities menu and app launcher, \
and app launcher, just like a regular installed app. It creates a .desktop \ just like a regular installed app. It creates a shortcut file and \
file (a shortcut) and copies the app's icon to your system icon folder." copies the app's icon to your system."
) )
.build(); .build();
@@ -940,7 +952,7 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
if record.integrated { if record.integrated {
if let Some(ref desktop_file) = record.desktop_file { if let Some(ref desktop_file) = record.desktop_file {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title("Desktop file") .title("Shortcut file")
.subtitle(desktop_file) .subtitle(desktop_file)
.subtitle_selectable(true) .subtitle_selectable(true)
.build(); .build();
@@ -954,8 +966,8 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
let compat_group = adw::PreferencesGroup::builder() let compat_group = adw::PreferencesGroup::builder()
.title("Compatibility") .title("Compatibility")
.description( .description(
"How well this app works with your display server and filesystem. \ "How well this app works with your system. \
Most issues here can be resolved with a small package install." Most issues can be fixed with a quick install."
) )
.build(); .build();
@@ -966,13 +978,12 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
.unwrap_or(WaylandStatus::Unknown); .unwrap_or(WaylandStatus::Unknown);
let wayland_row = adw::ActionRow::builder() let wayland_row = adw::ActionRow::builder()
.title("Wayland display") .title("Display compatibility")
.subtitle(wayland_user_explanation(&wayland_status)) .subtitle(wayland_user_explanation(&wayland_status))
.tooltip_text( .tooltip_text(
"Wayland is the modern display system used by GNOME and most Linux desktops. \ "Wayland is the modern display system on Linux. Apps built for the \
It replaced the older X11 system. Apps built for X11 still work through \ older system (X11) still work, but native Wayland apps look sharper, \
a compatibility layer called XWayland, but native Wayland apps look \ especially on high-resolution screens."
sharper and perform better, especially on high-resolution screens."
) )
.build(); .build();
let wayland_badge = widgets::status_badge(wayland_status.label(), wayland_status.badge_class()); let wayland_badge = widgets::status_badge(wayland_status.label(), wayland_status.badge_class());
@@ -982,14 +993,13 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
// Analyze toolkit button // Analyze toolkit button
let analyze_row = adw::ActionRow::builder() let analyze_row = adw::ActionRow::builder()
.title("Analyze toolkit") .title("Detect app framework")
.subtitle("Inspect bundled libraries to detect which UI toolkit this app uses") .subtitle("Check which technology this app is built with")
.activatable(true) .activatable(true)
.tooltip_text( .tooltip_text(
"UI toolkits (like GTK, Qt, Electron) are the frameworks apps use to \ "Apps are built with different frameworks (like GTK, Qt, or Electron). \
draw their windows and buttons. Knowing the toolkit helps predict Wayland \ Knowing the framework helps predict how well the app works with \
compatibility - GTK4 apps are natively Wayland, while older Qt or Electron \ your display system."
apps may need XWayland."
) )
.build(); .build();
let analyze_icon = gtk::Image::from_icon_name("system-search-symbolic"); let analyze_icon = gtk::Image::from_icon_name("system-search-symbolic");
@@ -1017,12 +1027,12 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
let toolkit_label = analysis.toolkit.label(); let toolkit_label = analysis.toolkit.label();
let lib_count = analysis.libraries_found.len(); let lib_count = analysis.libraries_found.len();
row_clone.set_subtitle(&format!( row_clone.set_subtitle(&format!(
"Detected: {} ({} libraries scanned)", "Built with: {}",
toolkit_label, lib_count, toolkit_label,
)); ));
} }
Err(_) => { Err(_) => {
row_clone.set_subtitle("Analysis failed - the AppImage may not be mountable"); row_clone.set_subtitle("Analysis failed - could not read the app's contents");
} }
} }
}); });
@@ -1032,11 +1042,14 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
// Runtime Wayland status (from post-launch analysis) // Runtime Wayland status (from post-launch analysis)
if let Some(ref runtime_status) = record.runtime_wayland_status { if let Some(ref runtime_status) = record.runtime_wayland_status {
let runtime_row = adw::ActionRow::builder() let runtime_row = adw::ActionRow::builder()
.title("Last observed protocol") .title("Last display mode")
.subtitle(&format!( .subtitle(&format!(
"When this app was last launched, it used: {}", "When this app was last launched, it used: {}",
runtime_status runtime_status
)) ))
.tooltip_text(
"How the app connected to your display the last time it was launched."
)
.build(); .build();
if let Some(ref checked) = record.runtime_wayland_checked { if let Some(ref checked) = record.runtime_wayland_checked {
let info = gtk::Label::builder() let info = gtk::Label::builder()
@@ -1058,13 +1071,11 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
.unwrap_or(fuse_system.status.clone()); .unwrap_or(fuse_system.status.clone());
let fuse_row = adw::ActionRow::builder() let fuse_row = adw::ActionRow::builder()
.title("FUSE (filesystem)") .title("App mounting")
.subtitle(fuse_user_explanation(&fuse_status)) .subtitle(fuse_user_explanation(&fuse_status))
.tooltip_text( .tooltip_text(
"FUSE (Filesystem in Userspace) lets AppImages mount themselves as \ "FUSE lets apps like AppImages run directly without unpacking first. \
virtual drives so they can run directly without extracting. Without it, \ Without it, apps still work but take a little longer to start."
AppImages still work but need to extract to a temp folder first, which \
is slower. Most systems have FUSE already, but some need libfuse2 installed."
) )
.build(); .build();
let fuse_badge = widgets::status_badge_with_icon( let fuse_badge = widgets::status_badge_with_icon(
@@ -1077,7 +1088,7 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
if let Some(cmd) = fuse_install_command(&fuse_status) { if let Some(cmd) = fuse_install_command(&fuse_status) {
let copy_btn = widgets::copy_button(cmd, Some(toast_overlay)); let copy_btn = widgets::copy_button(cmd, Some(toast_overlay));
copy_btn.set_valign(gtk::Align::Center); copy_btn.set_valign(gtk::Align::Center);
copy_btn.set_tooltip_text(Some(&format!("Copy '{}' to clipboard", cmd))); copy_btn.set_tooltip_text(Some("Copy install command to clipboard"));
fuse_row.add_suffix(&copy_btn); fuse_row.add_suffix(&copy_btn);
} }
compat_group.add(&fuse_row); compat_group.add(&fuse_row);
@@ -1086,16 +1097,15 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
let appimage_path = std::path::Path::new(&record.path); let appimage_path = std::path::Path::new(&record.path);
let app_fuse_status = fuse::determine_app_fuse_status(&fuse_system, appimage_path); let app_fuse_status = fuse::determine_app_fuse_status(&fuse_system, appimage_path);
let launch_method_row = adw::ActionRow::builder() let launch_method_row = adw::ActionRow::builder()
.title("Launch method") .title("Startup method")
.subtitle(&format!( .subtitle(&format!(
"This app will launch using: {}", "This app will launch using: {}",
app_fuse_status.label() app_fuse_status.label()
)) ))
.tooltip_text( .tooltip_text(
"AppImages can launch two ways: 'FUSE mount' mounts the image as a \ "AppImages can start two ways: mounting (fast, instant startup) or \
virtual drive (fast, instant startup), or 'extract' unpacks to a temp \ unpacking to a temporary folder first (slower, but works everywhere). \
folder first (slower, but works everywhere). The method is chosen \ The method is chosen automatically based on your system."
automatically based on your system's FUSE support."
) )
.build(); .build();
let launch_badge = widgets::status_badge( let launch_badge = widgets::status_badge(
@@ -1109,10 +1119,10 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
// Sandboxing group // Sandboxing group
let sandbox_group = adw::PreferencesGroup::builder() let sandbox_group = adw::PreferencesGroup::builder()
.title("Sandboxing") .title("App Isolation")
.description( .description(
"Isolate this app for extra security. Sandboxing limits what \ "Restrict what this app can access on your system \
the app can access on your system." for extra security."
) )
.build(); .build();
@@ -1125,24 +1135,20 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
let firejail_available = launcher::has_firejail(); let firejail_available = launcher::has_firejail();
let sandbox_subtitle = if firejail_available { let sandbox_subtitle = if firejail_available {
format!( format!("Currently: {}", current_mode.display_label())
"Isolate this app using Firejail. Current mode: {}",
current_mode.display_label()
)
} else { } else {
"Firejail is not installed. Use the row below to copy the install command.".to_string() "Not available yet. Install with one command using the button below.".to_string()
}; };
let firejail_row = adw::SwitchRow::builder() let firejail_row = adw::SwitchRow::builder()
.title("Firejail sandbox") .title("Isolate this app")
.subtitle(&sandbox_subtitle) .subtitle(&sandbox_subtitle)
.active(current_mode == SandboxMode::Firejail) .active(current_mode == SandboxMode::Firejail)
.sensitive(firejail_available) .sensitive(firejail_available)
.tooltip_text( .tooltip_text(
"Sandboxing restricts what an app can access on your system - files, \ "Sandboxing restricts what an app can access - files, network, \
network, devices, etc. This adds a security layer so that even if an \ devices, etc. Even if an app has a security issue, it can't \
app is compromised, it cannot freely access your personal data. Firejail \ freely access your personal data."
is a lightweight Linux sandboxing tool."
) )
.build(); .build();
@@ -1163,15 +1169,15 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
if !firejail_available { if !firejail_available {
let firejail_cmd = "sudo apt install firejail"; let firejail_cmd = "sudo apt install firejail";
let info_row = adw::ActionRow::builder() let info_row = adw::ActionRow::builder()
.title("Install Firejail") .title("Install app isolation")
.subtitle(firejail_cmd) .subtitle("Install with one command")
.build(); .build();
let badge = widgets::status_badge("Missing", "warning"); let badge = widgets::status_badge("Missing", "warning");
badge.set_valign(gtk::Align::Center); badge.set_valign(gtk::Align::Center);
info_row.add_suffix(&badge); info_row.add_suffix(&badge);
let copy_btn = widgets::copy_button(firejail_cmd, Some(toast_overlay)); let copy_btn = widgets::copy_button(firejail_cmd, Some(toast_overlay));
copy_btn.set_valign(gtk::Align::Center); copy_btn.set_valign(gtk::Align::Center);
copy_btn.set_tooltip_text(Some(&format!("Copy '{}' to clipboard", firejail_cmd))); copy_btn.set_tooltip_text(Some("Copy install command to clipboard"));
info_row.add_suffix(&copy_btn); info_row.add_suffix(&copy_btn);
sandbox_group.add(&info_row); sandbox_group.add(&info_row);
} }
@@ -1207,10 +1213,9 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.build(); .build();
let group = adw::PreferencesGroup::builder() let group = adw::PreferencesGroup::builder()
.title("Vulnerability Scanning") .title("Security Check")
.description( .description(
"Scan the libraries bundled inside this AppImage for known \ "Check this app for known security issues."
security vulnerabilities (CVEs)."
) )
.build(); .build();
@@ -1231,18 +1236,22 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
group.add(&row); group.add(&row);
} else { } else {
let lib_row = adw::ActionRow::builder() let lib_row = adw::ActionRow::builder()
.title("Bundled libraries") .title("Included components")
.subtitle(&format!( .subtitle(&format!(
"{} libraries detected inside this AppImage", "{} components found inside this app",
libs.len() 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(); .build();
group.add(&lib_row); group.add(&lib_row);
if summary.total() == 0 { if summary.total() == 0 {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title("Vulnerabilities") .title("Vulnerabilities")
.subtitle("No known security issues found in the bundled libraries.") .subtitle("No known security issues found.")
.build(); .build();
let badge = widgets::status_badge("Clean", "success"); let badge = widgets::status_badge("Clean", "success");
badge.set_valign(gtk::Align::Center); badge.set_valign(gtk::Align::Center);
@@ -1266,15 +1275,12 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
// Scan button // Scan button
let scan_row = adw::ActionRow::builder() let scan_row = adw::ActionRow::builder()
.title("Run security scan") .title("Run security check")
.subtitle("Check bundled libraries against known CVE databases") .subtitle("Check for known security issues in this app")
.activatable(true) .activatable(true)
.tooltip_text( .tooltip_text(
"CVE stands for Common Vulnerabilities and Exposures - a public list \ "This checks the components inside this app against a public database \
of known security bugs in software. AppImages bundle their own copies \ of known security issues to see if any are outdated or vulnerable."
of system libraries, which may contain outdated versions with known \
vulnerabilities. This scan checks those bundled libraries against the \
OSV.dev database to find any known issues."
) )
.build(); .build();
let scan_icon = gtk::Image::from_icon_name("security-medium-symbolic"); let scan_icon = gtk::Image::from_icon_name("security-medium-symbolic");
@@ -1313,7 +1319,7 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
} }
} }
Err(_) => { Err(_) => {
row_clone.set_subtitle("Scan failed - the AppImage may not be mountable"); row_clone.set_subtitle("Check failed - could not read the app's contents");
} }
} }
}); });
@@ -1330,14 +1336,14 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
if let Some(ref hash) = record.sha256 { if let Some(ref hash) = record.sha256 {
let hash_row = adw::ActionRow::builder() let hash_row = adw::ActionRow::builder()
.title("SHA256 checksum") .title("File fingerprint")
.subtitle(hash) .subtitle(hash)
.subtitle_selectable(true) .subtitle_selectable(true)
.tooltip_text( .tooltip_text(
"A SHA256 checksum is a unique fingerprint of the file. If even one \ "A unique code (SHA256 checksum) generated from the file's contents. \
byte changes, the checksum changes completely. You can compare this \ If the file changes in any way, this code changes too. You can compare \
against the developer's published checksum to verify the file hasn't \ it against the developer's published fingerprint to verify nothing \
been tampered with or corrupted during download." was altered."
) )
.build(); .build();
hash_row.add_css_class("property"); hash_row.add_css_class("property");
@@ -1416,13 +1422,13 @@ fn build_storage_tab(
// Data paths group // Data paths group
let paths_group = adw::PreferencesGroup::builder() let paths_group = adw::PreferencesGroup::builder()
.title("App Data") .title("App Data")
.description("Config, cache, and data directories this app may have created.") .description("Settings, cache, and data this app may have saved to your system.")
.build(); .build();
// Discover button // Discover button
let discover_row = adw::ActionRow::builder() let discover_row = adw::ActionRow::builder()
.title("Find app data") .title("Find app data")
.subtitle("Search for config, cache, and data directories") .subtitle("Search for files this app has saved")
.activatable(true) .activatable(true)
.build(); .build();
let discover_icon = gtk::Image::from_icon_name("folder-saved-search-symbolic"); let discover_icon = gtk::Image::from_icon_name("folder-saved-search-symbolic");
@@ -1449,7 +1455,7 @@ fn build_storage_tab(
Ok(fp) => { Ok(fp) => {
let count = fp.paths.len(); let count = fp.paths.len();
if count == 0 { if count == 0 {
row_clone.set_subtitle("No associated data directories found"); row_clone.set_subtitle("No saved data found");
} else { } else {
row_clone.set_subtitle(&format!( row_clone.set_subtitle(&format!(
"Found {} path{} using {}", "Found {} path{} using {}",
@@ -1501,7 +1507,7 @@ fn build_storage_tab(
.build(); .build();
let path_row = adw::ActionRow::builder() let path_row = adw::ActionRow::builder()
.title("Path") .title("File location")
.subtitle(&record.path) .subtitle(&record.path)
.subtitle_selectable(true) .subtitle_selectable(true)
.build(); .build();
@@ -1548,35 +1554,36 @@ fn build_storage_tab(
fn wayland_user_explanation(status: &WaylandStatus) -> &'static str { fn wayland_user_explanation(status: &WaylandStatus) -> &'static str {
match status { match status {
WaylandStatus::Native => WaylandStatus::Native =>
"Runs natively on Wayland - the best experience on modern Linux desktops.", "Fully compatible - the best experience on your system.",
WaylandStatus::XWayland => WaylandStatus::XWayland =>
"Uses XWayland for display. Works fine, but may appear slightly \ "Works through a compatibility layer. May appear slightly \
blurry on high-resolution screens.", blurry on high-resolution screens.",
WaylandStatus::Possible => WaylandStatus::Possible =>
"Might work on Wayland with the right settings. Try launching it to find out.", "Might work well. Try launching it to find out.",
WaylandStatus::X11Only => WaylandStatus::X11Only =>
"Designed for X11 only. It will run through XWayland automatically, \ "Built for an older display system. It will run automatically, \
but you may notice minor display quirks.", but you may notice minor visual quirks.",
WaylandStatus::Unknown => WaylandStatus::Unknown =>
"Not yet determined. Launch the app or use 'Analyze toolkit' below to check.", "Not yet determined. Launch the app or use 'Detect app framework' to check.",
} }
} }
fn fuse_user_explanation(status: &FuseStatus) -> &'static str { fn fuse_user_explanation(status: &FuseStatus) -> &'static str {
match status { match status {
FuseStatus::FullyFunctional => FuseStatus::FullyFunctional =>
"FUSE is working - AppImages mount directly for fast startup.", "Everything is set up - apps start instantly.",
FuseStatus::Fuse3Only => FuseStatus::Fuse3Only =>
"Only FUSE 3 found. Some AppImages need FUSE 2. \ "A small system component is missing. Most apps will still work, \
Click the copy button to get the install command.", but some may need it. Copy the install command to fix this.",
FuseStatus::NoFusermount => FuseStatus::NoFusermount =>
"FUSE tools not found. The app will still work by extracting to a \ "A system component is missing, so apps will take a little longer \
temporary folder, but startup will be slower.", to start. They'll still work fine.",
FuseStatus::NoDevFuse => FuseStatus::NoDevFuse =>
"/dev/fuse not available. FUSE may not be configured on your system. \ "Your system doesn't support instant app mounting. Apps will unpack \
Apps will extract to a temp folder instead.", before starting, which takes a bit longer.",
FuseStatus::MissingLibfuse2 => FuseStatus::MissingLibfuse2 =>
"libfuse2 is missing. Click the copy button to get the install command.", "A small system component is needed for fast startup. \
Copy the install command to fix this.",
} }
} }
@@ -1590,29 +1597,182 @@ fn fuse_install_command(status: &FuseStatus) -> Option<&'static str> {
} }
} }
/// Show a screenshot in a fullscreen-ish lightbox dialog. /// Show a screenshot in a fullscreen lightbox window with prev/next navigation.
fn show_screenshot_lightbox(parent: &gtk::Window, texture: &gtk::gdk::Texture) { /// Uses a separate gtk::Window to avoid parent scroll position interference.
fn show_screenshot_lightbox(
parent: &gtk::Window,
textures: &Rc<std::cell::RefCell<Vec<Option<gtk::gdk::Texture>>>>,
initial_index: usize,
) {
let current = Rc::new(std::cell::Cell::new(initial_index));
let textures = textures.clone();
let count = textures.borrow().len();
let has_multiple = count > 1;
// Fullscreen modal window with dark background
let win = gtk::Window::builder()
.transient_for(parent)
.modal(true)
.decorated(false)
.default_width(parent.width())
.default_height(parent.height())
.build();
win.add_css_class("lightbox");
// Image with generous margins so there's a dark border to click on
let picture = gtk::Picture::builder() let picture = gtk::Picture::builder()
.paintable(texture)
.content_fit(gtk::ContentFit::Contain) .content_fit(gtk::ContentFit::Contain)
.hexpand(true) .halign(gtk::Align::Center)
.vexpand(true) .valign(gtk::Align::Center)
.margin_start(72)
.margin_end(72)
.margin_top(56)
.margin_bottom(56)
.build(); .build();
picture.set_can_shrink(true); picture.set_can_shrink(true);
let dialog = adw::Dialog::builder() // Set initial texture
.title("Screenshot") {
.content_width(900) let t = textures.borrow();
.content_height(600) 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(); .build();
let toolbar = adw::ToolbarView::new(); let next_btn = gtk::Button::builder()
let header = adw::HeaderBar::new(); .icon_name("go-next-symbolic")
toolbar.add_top_bar(&header); .css_classes(["circular", "osd", "lightbox-nav"])
toolbar.set_content(Some(&picture)); .valign(gtk::Align::Center)
dialog.set_child(Some(&toolbar)); .halign(gtk::Align::End)
.margin_end(16)
.build();
dialog.present(Some(parent)); // 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(|_, _, _, _| {});
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. /// Fetch a favicon for a URL and set it on an image widget.

View File

@@ -103,14 +103,14 @@ fn build_report_content(db: &Rc<Database>) -> gtk::ScrolledWindow {
let empty = adw::StatusPage::builder() let empty = adw::StatusPage::builder()
.icon_name("security-medium-symbolic") .icon_name("security-medium-symbolic")
.title("No Security Scans") .title("No Security Scans")
.description("Run a security scan to check bundled libraries for known vulnerabilities.") .description("Run a security check to look for known issues in your apps.")
.build(); .build();
content.append(&empty); content.append(&empty);
} else { } else {
let clean = adw::StatusPage::builder() let clean = adw::StatusPage::builder()
.icon_name("security-high-symbolic") .icon_name("security-high-symbolic")
.title("All Clear") .title("All Clear")
.description("No known vulnerabilities found in any bundled libraries.") .description("No known security issues found in any of your apps.")
.build(); .build();
content.append(&clean); content.append(&clean);
} }
@@ -144,13 +144,13 @@ fn build_report_content(db: &Rc<Database>) -> gtk::ScrolledWindow {
fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::PreferencesGroup { fn build_summary_group(summary: &crate::core::database::CveSummary) -> adw::PreferencesGroup {
let group = adw::PreferencesGroup::builder() let group = adw::PreferencesGroup::builder()
.title("Vulnerability Summary") .title("Vulnerability Summary")
.description("Overall security status across all AppImages") .description("Overall security status across all your apps")
.build(); .build();
let total_row = adw::ActionRow::builder() let total_row = adw::ActionRow::builder()
.title("Total vulnerabilities") .title("Total vulnerabilities")
.subtitle(&summary.total().to_string()) .subtitle(&summary.total().to_string())
.tooltip_text("Common Vulnerabilities and Exposures found in bundled libraries") .tooltip_text("Known security issues found in the components bundled inside your apps")
.build(); .build();
let total_badge = widgets::status_badge(summary.max_severity(), summary.badge_class()); let total_badge = widgets::status_badge(summary.max_severity(), summary.badge_class());
total_badge.set_valign(gtk::Align::Center); total_badge.set_valign(gtk::Align::Center);
@@ -207,7 +207,7 @@ fn build_app_findings_group(
summary: &crate::core::database::CveSummary, summary: &crate::core::database::CveSummary,
cve_matches: &[crate::core::database::CveMatchRecord], cve_matches: &[crate::core::database::CveMatchRecord],
) -> adw::PreferencesGroup { ) -> adw::PreferencesGroup {
let description = format!("{} CVE (vulnerability) records found", summary.total()); let description = format!("{} known security issues found", summary.total());
let group = adw::PreferencesGroup::builder() let group = adw::PreferencesGroup::builder()
.title(app_name) .title(app_name)
.description(&description) .description(&description)