Add Phase 5 enhancements: security, i18n, analysis, backup, notifications

- Database v8 migration: tags, pinned, avg_startup_ms columns
- Security scanning with CVE matching and batch scan
- Bundled library extraction and vulnerability reports
- Desktop notification system for security alerts
- Backup/restore system for AppImage configurations
- i18n framework with gettext support
- Runtime analysis and Wayland compatibility detection
- AppStream metadata and Flatpak-style build support
- File watcher module for live directory monitoring
- Preferences panel with GSettings integration
- CLI interface for headless operation
- Detail view: tabbed layout with ViewSwitcher in title bar,
  health score, sandbox controls, changelog links
- Library view: sort dropdown, context menu enhancements
- Dashboard: system status, disk usage, launch history
- Security report page with scan and export
- Packaging: meson build, PKGBUILD, metainfo
This commit is contained in:
lashman
2026-02-27 17:16:41 +02:00
parent a7ed3742fb
commit 423323d5a9
51 changed files with 10583 additions and 481 deletions

View File

@@ -2,7 +2,6 @@ use gtk::prelude::*;
use gtk::accessible::Property as AccessibleProperty;
use crate::core::database::AppImageRecord;
use crate::core::fuse::FuseStatus;
use crate::core::wayland::WaylandStatus;
use super::widgets;
@@ -11,25 +10,20 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(6)
.margin_top(14)
.margin_bottom(14)
.margin_start(14)
.margin_end(14)
.halign(gtk::Align::Center)
.halign(gtk::Align::Fill)
.build();
card.add_css_class("card");
card.set_size_request(200, -1);
// Icon (72x72) with integration emblem overlay
let name = record.app_name.as_deref().unwrap_or(&record.filename);
// Icon (64x64) with integration emblem overlay
let icon_widget = widgets::app_icon(
record.icon_path.as_deref(),
name,
72,
64,
);
icon_widget.add_css_class("icon-dropshadow");
// If integrated, overlay a small checkmark emblem
if record.integrated {
let overlay = gtk::Overlay::new();
overlay.set_child(Some(&icon_widget));
@@ -47,13 +41,14 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
card.append(&icon_widget);
}
// App name - .title-3 for more visual weight
// App name
let name_label = gtk::Label::builder()
.label(name)
.css_classes(["title-3"])
.css_classes(["title-4"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.max_width_chars(20)
.build();
card.append(&name_label);
// Version + size combined on one line
let version_text = record.app_version.as_deref().unwrap_or("");
@@ -61,28 +56,56 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
let meta_text = if version_text.is_empty() {
size_text
} else {
format!("{} - {}", version_text, size_text)
format!("{} - {}", version_text, size_text)
};
let meta_label = gtk::Label::builder()
.label(&meta_text)
.css_classes(["caption", "dimmed", "numeric"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.build();
card.append(&name_label);
card.append(&meta_label);
// Description snippet (if available)
if let Some(ref desc) = record.description {
if !desc.is_empty() {
let snippet = if desc.len() > 60 {
format!("{}...", &desc[..desc.char_indices().take_while(|&(i, _)| i < 57).last().map(|(i, c)| i + c.len_utf8()).unwrap_or(57)])
} else {
desc.clone()
};
let desc_label = gtk::Label::builder()
.label(&snippet)
.css_classes(["caption", "dimmed"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.lines(2)
.wrap(true)
.xalign(0.5)
.max_width_chars(25)
.build();
card.append(&desc_label);
}
}
// Single most important badge (priority: Update > FUSE issue > Wayland issue)
if let Some(badge) = build_priority_badge(record) {
let badge = build_priority_badge(record);
let has_badge = badge.is_some();
if let Some(badge) = badge {
let badge_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.halign(gtk::Align::Center)
.margin_top(4)
.margin_top(2)
.build();
badge_box.append(&badge);
card.append(&badge_box);
}
// Status border: green for healthy, amber for attention needed
if record.integrated && !has_badge {
card.add_css_class("status-ok");
} else if has_badge {
card.add_css_class("status-attention");
}
let child = gtk::FlowBoxChild::builder()
.child(&card)
.build();
@@ -95,9 +118,14 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
child
}
/// Return the single most important badge for a card.
/// Priority: Update available > FUSE issue > Wayland issue.
/// Return the single most important badge for a record.
/// Priority: Analyzing > Update available > FUSE issue > Wayland issue.
pub fn build_priority_badge(record: &AppImageRecord) -> Option<gtk::Label> {
// 0. Analysis in progress (highest priority)
if record.app_name.is_none() && record.analysis_status.as_deref() != Some("complete") {
return Some(widgets::status_badge("Analyzing...", "info"));
}
// 1. Update available (highest priority)
if let (Some(ref latest), Some(ref current)) = (&record.latest_version, &record.app_version) {
if crate::core::updater::version_is_newer(latest, current) {
@@ -106,10 +134,18 @@ pub fn build_priority_badge(record: &AppImageRecord) -> Option<gtk::Label> {
}
// 2. FUSE issue
// The database stores AppImageFuseStatus values (per-app), not FuseStatus (system).
// Check both: per-app statuses like "native_fuse"/"static_runtime" are fine,
// "extract_and_run" is slow but works, "cannot_launch" is a real problem.
// System statuses like "fully_functional" are also fine.
if let Some(ref fs) = record.fuse_status {
let status = FuseStatus::from_str(fs);
if !status.is_functional() {
return Some(widgets::status_badge(status.label(), status.badge_class()));
let is_ok = matches!(
fs.as_str(),
"native_fuse" | "static_runtime" | "fully_functional"
);
let is_slow = fs.as_str() == "extract_and_run";
if !is_ok && !is_slow {
return Some(widgets::status_badge("Needs setup", "warning"));
}
}
@@ -117,7 +153,7 @@ pub fn build_priority_badge(record: &AppImageRecord) -> Option<gtk::Label> {
if let Some(ref ws) = record.wayland_status {
let status = WaylandStatus::from_str(ws);
if status != WaylandStatus::Unknown && status != WaylandStatus::Native {
return Some(widgets::status_badge(status.label(), status.badge_class()));
return Some(widgets::status_badge("May look blurry", "neutral"));
}
}

View File

@@ -7,6 +7,7 @@ use crate::core::database::Database;
use crate::core::duplicates;
use crate::core::footprint;
use crate::core::orphan;
use crate::i18n::{i18n, i18n_f};
use super::widgets;
/// A reclaimable item discovered during analysis.
@@ -28,11 +29,11 @@ enum ReclaimCategory {
}
impl ReclaimCategory {
fn label(&self) -> &'static str {
fn label(&self) -> String {
match self {
ReclaimCategory::OrphanedDesktopEntry => "Orphaned desktop entries",
ReclaimCategory::CacheData => "Cache data",
ReclaimCategory::DuplicateAppImage => "Duplicate AppImages",
ReclaimCategory::OrphanedDesktopEntry => i18n("Orphaned desktop entries"),
ReclaimCategory::CacheData => i18n("Cache data"),
ReclaimCategory::DuplicateAppImage => i18n("Duplicate AppImages"),
}
}
@@ -48,7 +49,7 @@ impl ReclaimCategory {
/// Show the disk space reclamation wizard as an AdwDialog.
pub fn show_cleanup_wizard(parent: &impl IsA<gtk::Widget>, _db: &Rc<Database>) {
let dialog = adw::Dialog::builder()
.title("Disk Space Cleanup")
.title(&i18n("Disk Space Cleanup"))
.content_width(500)
.content_height(550)
.build();
@@ -118,8 +119,8 @@ pub fn show_cleanup_wizard(parent: &impl IsA<gtk::Widget>, _db: &Rc<Database>) {
Err(_) => {
let error_page = adw::StatusPage::builder()
.icon_name("dialog-error-symbolic")
.title("Analysis Failed")
.description("Could not analyze disk usage.")
.title(&i18n("Analysis Failed"))
.description(&i18n("Could not analyze disk usage."))
.build();
if let Some(child) = stack_ref.child_by_name("review") {
stack_ref.remove(&child);
@@ -148,13 +149,13 @@ fn build_analysis_step() -> gtk::Box {
page.append(&spinner);
let label = gtk::Label::builder()
.label("Analyzing disk usage...")
.label(&i18n("Analyzing disk usage..."))
.css_classes(["title-3"])
.build();
page.append(&label);
let subtitle = gtk::Label::builder()
.label("Checking for orphaned files, cache data, and duplicates")
.label(&i18n("Checking for orphaned files, cache data, and duplicates"))
.css_classes(["dimmed"])
.build();
page.append(&subtitle);
@@ -178,8 +179,8 @@ fn build_review_step(
if items_ref.is_empty() {
let empty = adw::StatusPage::builder()
.icon_name("emblem-ok-symbolic")
.title("All Clean")
.description("No reclaimable disk space found.")
.title(&i18n("All Clean"))
.description(&i18n("No reclaimable disk space found."))
.vexpand(true)
.build();
page.append(&empty);
@@ -189,7 +190,7 @@ fn build_review_step(
// Summary header
let total_size: u64 = items_ref.iter().map(|i| i.size_bytes).sum();
let summary_label = gtk::Label::builder()
.label(&format!("Found {} reclaimable", widgets::format_size(total_size as i64)))
.label(&i18n_f("Found {} reclaimable", &[("{}", &widgets::format_size(total_size as i64))]))
.css_classes(["title-3"])
.margin_top(12)
.margin_start(18)
@@ -199,7 +200,7 @@ fn build_review_step(
page.append(&summary_label);
let desc_label = gtk::Label::builder()
.label("Select items to remove")
.label(&i18n("Select items to remove"))
.css_classes(["dimmed"])
.margin_start(18)
.margin_end(18)
@@ -251,8 +252,9 @@ fn build_review_step(
let cat_icon = gtk::Image::from_icon_name(cat.icon_name());
cat_icon.set_pixel_size(16);
cat_header.append(&cat_icon);
let cat_label_text = cat.label();
let cat_label = gtk::Label::builder()
.label(&format!("{} ({})", cat.label(), widgets::format_size(cat_size as i64)))
.label(&format!("{} ({})", cat_label_text, widgets::format_size(cat_size as i64)))
.css_classes(["title-4"])
.halign(gtk::Align::Start)
.build();
@@ -263,7 +265,7 @@ fn build_review_step(
list_box.add_css_class("boxed-list");
list_box.set_selection_mode(gtk::SelectionMode::None);
list_box.update_property(&[
gtk::accessible::Property::Label(cat.label()),
gtk::accessible::Property::Label(&cat_label_text),
]);
for (idx, item) in &cat_items {
@@ -305,11 +307,11 @@ fn build_review_step(
.build();
let clean_button = gtk::Button::builder()
.label("Clean Selected")
.label(&i18n("Clean Selected"))
.build();
clean_button.add_css_class("destructive-action");
clean_button.update_property(&[
gtk::accessible::Property::Label("Clean selected items"),
gtk::accessible::Property::Label(&i18n("Clean selected items")),
]);
let items_clone = items.clone();
@@ -337,17 +339,16 @@ fn build_review_step(
}
let confirm = adw::AlertDialog::builder()
.heading("Confirm Cleanup")
.body(&format!(
"Remove {} item{}?",
count,
if count == 1 { "" } else { "s" }
.heading(&i18n("Confirm Cleanup"))
.body(&i18n_f(
"Remove {} items?",
&[("{}", &count.to_string())],
))
.close_response("cancel")
.default_response("clean")
.build();
confirm.add_response("cancel", "Cancel");
confirm.add_response("clean", "Clean");
confirm.add_response("cancel", &i18n("Cancel"));
confirm.add_response("clean", &i18n("Clean"));
confirm.set_response_appearance("clean", adw::ResponseAppearance::Destructive);
let on_confirm_inner = on_confirm_ref.clone();
@@ -416,31 +417,32 @@ fn build_complete_step(count: usize, size: u64, dialog: &adw::Dialog) -> gtk::Bo
if count == 0 {
let status = adw::StatusPage::builder()
.icon_name("emblem-ok-symbolic")
.title("Nothing Selected")
.description("No items were selected for cleanup.")
.title(&i18n("Nothing Selected"))
.description(&i18n("No items were selected for cleanup."))
.build();
page.append(&status);
} else {
let status = adw::StatusPage::builder()
.icon_name("user-trash-symbolic")
.title("Cleanup Complete")
.description(&format!(
"Removed {} item{}, freeing {}",
count,
if count == 1 { "" } else { "s" },
widgets::format_size(size as i64),
.title(&i18n("Cleanup Complete"))
.description(&i18n_f(
"Removed {count} items, freeing {size}",
&[
("{count}", &count.to_string()),
("{size}", &widgets::format_size(size as i64)),
],
))
.build();
page.append(&status);
}
let close_button = gtk::Button::builder()
.label("Close")
.label(&i18n("Close"))
.halign(gtk::Align::Center)
.build();
close_button.add_css_class("pill");
close_button.update_property(&[
gtk::accessible::Property::Label("Close cleanup dialog"),
gtk::accessible::Property::Label(&i18n("Close cleanup dialog")),
]);
let dialog_ref = dialog.clone();

View File

@@ -22,32 +22,15 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
// Toast overlay for copy actions
let toast_overlay = adw::ToastOverlay::new();
// Main content container
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.build();
// Hero banner (always visible at top)
let banner = build_banner(record);
content.append(&banner);
// ViewSwitcher (tab bar) - inline style, between banner and tab content
// ViewStack for tabbed content
let view_stack = adw::ViewStack::new();
let switcher = adw::ViewSwitcher::builder()
.stack(&view_stack)
.policy(adw::ViewSwitcherPolicy::Wide)
.build();
switcher.add_css_class("inline");
switcher.add_css_class("detail-view-switcher");
content.append(&switcher);
// Build tab pages
let overview_page = build_overview_tab(record, db);
view_stack.add_titled(&overview_page, Some("overview"), "Overview");
view_stack.page(&overview_page).set_icon_name(Some("info-symbolic"));
let system_page = build_system_tab(record, db);
let system_page = build_system_tab(record, db, &toast_overlay);
view_stack.add_titled(&system_page, Some("system"), "System");
view_stack.page(&system_page).set_icon_name(Some("system-run-symbolic"));
@@ -59,18 +42,31 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
view_stack.add_titled(&storage_page, Some("storage"), "Storage");
view_stack.page(&storage_page).set_icon_name(Some("drive-harddisk-symbolic"));
// Scrollable area for tab content
// Scrollable view stack
let scrolled = gtk::ScrolledWindow::builder()
.child(&view_stack)
.vexpand(true)
.build();
// Main vertical layout: banner + scrolled tabs
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.build();
content.append(&build_banner(record));
content.append(&scrolled);
toast_overlay.set_child(Some(&content));
// Header bar with per-app actions
// 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")
@@ -97,11 +93,9 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
let pid = child.id();
log::info!("Launched AppImage: {} (PID: {}, method: {})", path, pid, method.as_str());
// Run post-launch Wayland runtime analysis after a short delay
let db_wayland = db_launch.clone();
let path_clone = path.clone();
glib::spawn_future_local(async move {
// Wait 3 seconds for the process to initialize
glib::timeout_future(std::time::Duration::from_secs(3)).await;
let analysis_result = gio::spawn_blocking(move || {
@@ -165,7 +159,10 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
.build()
}
/// Rich banner at top: large icon + app name + version + badges
// ---------------------------------------------------------------------------
// Banner
// ---------------------------------------------------------------------------
fn build_banner(record: &AppImageRecord) -> gtk::Box {
let banner = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
@@ -178,7 +175,7 @@ fn build_banner(record: &AppImageRecord) -> gtk::Box {
let name = record.app_name.as_deref().unwrap_or(&record.filename);
// Large icon (96x96) with drop shadow
// 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");
@@ -259,7 +256,10 @@ fn build_banner(record: &AppImageRecord) -> gtk::Box {
banner
}
/// Tab 1: Overview - most commonly needed info at a glance
// ---------------------------------------------------------------------------
// Tab 1: Overview - updates, usage, basic file info
// ---------------------------------------------------------------------------
fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let tab = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
@@ -283,6 +283,7 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
// 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 {
@@ -291,15 +292,31 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.unwrap_or("Unknown format");
let row = adw::ActionRow::builder()
.title("Update method")
.subtitle(display_label)
.subtitle(&format!(
"This app checks for updates using: {}",
display_label
))
.tooltip_text(
"AppImages can include built-in update information that tells Driftwood \
where to check for newer versions. Common methods include GitHub releases, \
zsync (efficient delta updates), and direct download URLs."
)
.build();
updates_group.add(&row);
} else {
let row = adw::ActionRow::builder()
.title("Update method")
.subtitle("This app cannot check for updates automatically")
.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("None", "neutral");
let badge = widgets::status_badge("Manual only", "neutral");
badge.set_valign(gtk::Align::Center);
row.add_suffix(&badge);
updates_group.add(&row);
@@ -314,9 +331,9 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
if is_newer {
let subtitle = format!(
"{} -> {}",
"A newer version is available: {} (you have {})",
latest,
record.app_version.as_deref().unwrap_or("unknown"),
latest
);
let row = adw::ActionRow::builder()
.title("Update available")
@@ -328,8 +345,8 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
updates_group.add(&row);
} else {
let row = adw::ActionRow::builder()
.title("Status")
.subtitle("Up to date")
.title("Version status")
.subtitle("You are running the latest version.")
.build();
let badge = widgets::status_badge("Latest", "success");
badge.set_valign(gtk::Align::Center);
@@ -375,20 +392,29 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.build();
let type_str = match record.appimage_type {
Some(1) => "Type 1",
Some(2) => "Type 2",
_ => "Unknown",
Some(1) => "Type 1 (ISO 9660) - older format, still widely supported",
Some(2) => "Type 2 (SquashFS) - modern format, most common today",
_ => "Unknown type",
};
let type_row = adw::ActionRow::builder()
.title("AppImage type")
.title("AppImage format")
.subtitle(type_str)
.tooltip_text("Type 1 uses ISO9660, Type 2 uses SquashFS")
.tooltip_text(
"AppImages come in two formats. Type 1 uses an ISO 9660 filesystem \
(older, simpler). Type 2 uses SquashFS (modern, compressed, smaller \
files). Type 2 is the standard today and is what most AppImage tools \
produce."
)
.build();
info_group.add(&type_row);
let exec_row = adw::ActionRow::builder()
.title("Executable")
.subtitle(if record.is_executable { "Yes" } else { "No" })
.subtitle(if record.is_executable {
"Yes - this file has execute permission"
} else {
"No - execute permission is missing. It will be set automatically when launched."
})
.build();
info_group.add(&exec_row);
@@ -420,8 +446,11 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
tab
}
/// Tab 2: System - integration, compatibility, sandboxing
fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
// ---------------------------------------------------------------------------
// 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)
@@ -444,13 +473,21 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
// Desktop Integration group
let integration_group = adw::PreferencesGroup::builder()
.title("Desktop Integration")
.description("Add this app to your application menu")
.description(
"Show this app in your Activities menu and app launcher, \
just like a regular installed application."
)
.build();
let switch_row = adw::SwitchRow::builder()
.title("Add to application menu")
.subtitle("Creates a .desktop file and installs the icon")
.subtitle("Creates a .desktop entry and installs the app icon")
.active(record.integrated)
.tooltip_text(
"Desktop integration makes this AppImage appear in your Activities menu \
and app launcher, just like a regular installed app. It creates a .desktop \
file (a shortcut) and copies the app's icon to your system icon folder."
)
.build();
let record_id = record.id;
@@ -501,8 +538,11 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
// Runtime Compatibility group
let compat_group = adw::PreferencesGroup::builder()
.title("Runtime Compatibility")
.description("Wayland support and FUSE status")
.title("Compatibility")
.description(
"How well this app works with your display server and filesystem. \
Most issues here can be resolved with a small package install."
)
.build();
let wayland_status = record
@@ -512,20 +552,31 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.unwrap_or(WaylandStatus::Unknown);
let wayland_row = adw::ActionRow::builder()
.title("Wayland")
.subtitle(wayland_description(&wayland_status))
.tooltip_text("Display protocol for Linux desktops")
.title("Wayland display")
.subtitle(wayland_user_explanation(&wayland_status))
.tooltip_text(
"Wayland is the modern display system used by GNOME and most Linux desktops. \
It replaced the older X11 system. Apps built for X11 still work through \
a compatibility layer called XWayland, but native Wayland apps look \
sharper and perform better, especially on high-resolution screens."
)
.build();
let wayland_badge = widgets::status_badge(wayland_status.label(), wayland_status.badge_class());
wayland_badge.set_valign(gtk::Align::Center);
wayland_row.add_suffix(&wayland_badge);
compat_group.add(&wayland_row);
// Wayland analyze button
// Analyze toolkit button
let analyze_row = adw::ActionRow::builder()
.title("Analyze toolkit")
.subtitle("Inspect bundled libraries to detect UI toolkit")
.subtitle("Inspect bundled libraries to detect which UI toolkit this app uses")
.activatable(true)
.tooltip_text(
"UI toolkits (like GTK, Qt, Electron) are the frameworks apps use to \
draw their windows and buttons. Knowing the toolkit helps predict Wayland \
compatibility - GTK4 apps are natively Wayland, while older Qt or Electron \
apps may need XWayland."
)
.build();
let analyze_icon = gtk::Image::from_icon_name("system-search-symbolic");
analyze_icon.set_valign(gtk::Align::Center);
@@ -552,12 +603,12 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let toolkit_label = analysis.toolkit.label();
let lib_count = analysis.libraries_found.len();
row_clone.set_subtitle(&format!(
"Toolkit: {} ({} libraries scanned)",
"Detected: {} ({} libraries scanned)",
toolkit_label, lib_count,
));
}
Err(_) => {
row_clone.set_subtitle("Analysis failed");
row_clone.set_subtitle("Analysis failed - the AppImage may not be mountable");
}
}
});
@@ -567,8 +618,11 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
// Runtime Wayland status (from post-launch analysis)
if let Some(ref runtime_status) = record.runtime_wayland_status {
let runtime_row = adw::ActionRow::builder()
.title("Runtime display protocol")
.subtitle(runtime_status)
.title("Last observed protocol")
.subtitle(&format!(
"When this app was last launched, it used: {}",
runtime_status
))
.build();
if let Some(ref checked) = record.runtime_wayland_checked {
let info = gtk::Label::builder()
@@ -581,6 +635,7 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
compat_group.add(&runtime_row);
}
// FUSE status
let fuse_system = fuse::detect_system_fuse();
let fuse_status = record
.fuse_status
@@ -589,9 +644,14 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.unwrap_or(fuse_system.status.clone());
let fuse_row = adw::ActionRow::builder()
.title("FUSE")
.subtitle(fuse_description(&fuse_status))
.tooltip_text("Filesystem in Userspace - required for mounting AppImages")
.title("FUSE (filesystem)")
.subtitle(fuse_user_explanation(&fuse_status))
.tooltip_text(
"FUSE (Filesystem in Userspace) lets AppImages mount themselves as \
virtual drives so they can run directly without extracting. Without it, \
AppImages still work but need to extract to a temp folder first, which \
is slower. Most systems have FUSE already, but some need libfuse2 installed."
)
.build();
let fuse_badge = widgets::status_badge_with_icon(
if fuse_status.is_functional() { "emblem-ok-symbolic" } else { "dialog-warning-symbolic" },
@@ -600,14 +660,29 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
);
fuse_badge.set_valign(gtk::Align::Center);
fuse_row.add_suffix(&fuse_badge);
if let Some(cmd) = fuse_install_command(&fuse_status) {
let copy_btn = widgets::copy_button(cmd, Some(toast_overlay));
copy_btn.set_valign(gtk::Align::Center);
copy_btn.set_tooltip_text(Some(&format!("Copy '{}' to clipboard", cmd)));
fuse_row.add_suffix(&copy_btn);
}
compat_group.add(&fuse_row);
// Per-app FUSE launch method
// Per-app launch method
let appimage_path = std::path::Path::new(&record.path);
let app_fuse_status = fuse::determine_app_fuse_status(&fuse_system, appimage_path);
let launch_method_row = adw::ActionRow::builder()
.title("Launch method")
.subtitle(app_fuse_status.label())
.subtitle(&format!(
"This app will launch using: {}",
app_fuse_status.label()
))
.tooltip_text(
"AppImages can launch two ways: 'FUSE mount' mounts the image as a \
virtual drive (fast, instant startup), or 'extract' unpacks to a temp \
folder first (slower, but works everywhere). The method is chosen \
automatically based on your system's FUSE support."
)
.build();
let launch_badge = widgets::status_badge(
fuse_system.status.as_str(),
@@ -621,7 +696,10 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
// Sandboxing group
let sandbox_group = adw::PreferencesGroup::builder()
.title("Sandboxing")
.description("Isolate this app with Firejail")
.description(
"Isolate this app for extra security. Sandboxing limits what \
the app can access on your system."
)
.build();
let current_mode = record
@@ -633,17 +711,25 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let firejail_available = launcher::has_firejail();
let sandbox_subtitle = if firejail_available {
format!("Current mode: {}", current_mode.label())
format!(
"Isolate this app using Firejail. Current mode: {}",
current_mode.display_label()
)
} else {
"Firejail is not installed".to_string()
"Firejail is not installed. Use the row below to copy the install command.".to_string()
};
let firejail_row = adw::SwitchRow::builder()
.title("Firejail sandbox")
.subtitle(&sandbox_subtitle)
.tooltip_text("Linux application sandboxing tool")
.active(current_mode == SandboxMode::Firejail)
.sensitive(firejail_available)
.tooltip_text(
"Sandboxing restricts what an app can access on your system - files, \
network, devices, etc. This adds a security layer so that even if an \
app is compromised, it cannot freely access your personal data. Firejail \
is a lightweight Linux sandboxing tool."
)
.build();
let record_id = record.id;
@@ -661,13 +747,18 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
sandbox_group.add(&firejail_row);
if !firejail_available {
let firejail_cmd = "sudo apt install firejail";
let info_row = adw::ActionRow::builder()
.title("Install Firejail")
.subtitle("sudo apt install firejail")
.subtitle(firejail_cmd)
.build();
let badge = widgets::status_badge("Missing", "warning");
badge.set_valign(gtk::Align::Center);
info_row.add_suffix(&badge);
let copy_btn = widgets::copy_button(firejail_cmd, Some(toast_overlay));
copy_btn.set_valign(gtk::Align::Center);
copy_btn.set_tooltip_text(Some(&format!("Copy '{}' to clipboard", firejail_cmd)));
info_row.add_suffix(&copy_btn);
sandbox_group.add(&info_row);
}
inner.append(&sandbox_group);
@@ -677,7 +768,10 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
tab
}
/// Tab 3: Security - vulnerability scanning and integrity
// ---------------------------------------------------------------------------
// 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)
@@ -700,7 +794,10 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let group = adw::PreferencesGroup::builder()
.title("Vulnerability Scanning")
.description("Check bundled libraries for known CVEs")
.description(
"Scan the libraries bundled inside this AppImage for known \
security vulnerabilities (CVEs)."
)
.build();
let libs = db.get_bundled_libraries(record.id).unwrap_or_default();
@@ -709,7 +806,10 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
if libs.is_empty() {
let row = adw::ActionRow::builder()
.title("Security scan")
.subtitle("Not yet scanned for vulnerabilities")
.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);
@@ -718,14 +818,17 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
} else {
let lib_row = adw::ActionRow::builder()
.title("Bundled libraries")
.subtitle(&libs.len().to_string())
.subtitle(&format!(
"{} libraries detected inside this AppImage",
libs.len()
))
.build();
group.add(&lib_row);
if summary.total() == 0 {
let row = adw::ActionRow::builder()
.title("Vulnerabilities")
.subtitle("No known vulnerabilities")
.subtitle("No known security issues found in the bundled libraries.")
.build();
let badge = widgets::status_badge("Clean", "success");
badge.set_valign(gtk::Align::Center);
@@ -734,7 +837,11 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
} else {
let row = adw::ActionRow::builder()
.title("Vulnerabilities")
.subtitle(&format!("{} found", summary.total()))
.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);
@@ -745,9 +852,16 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
// Scan button
let scan_row = adw::ActionRow::builder()
.title("Scan this AppImage")
.subtitle("Check bundled libraries for known CVEs")
.title("Run security scan")
.subtitle("Check bundled libraries against known CVE databases")
.activatable(true)
.tooltip_text(
"CVE stands for Common Vulnerabilities and Exposures - a public list \
of known security bugs in software. AppImages bundle their own copies \
of system libraries, which may contain outdated versions with known \
vulnerabilities. This scan checks those bundled libraries against the \
OSV.dev database to find any known issues."
)
.build();
let scan_icon = gtk::Image::from_icon_name("security-medium-symbolic");
scan_icon.set_valign(gtk::Align::Center);
@@ -758,7 +872,7 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
scan_row.connect_activated(move |row| {
row.set_sensitive(false);
row.update_state(&[gtk::accessible::State::Busy(true)]);
row.set_subtitle("Scanning...");
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 {
@@ -775,15 +889,17 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
Ok(scan_result) => {
let total = scan_result.total_cves();
if total == 0 {
row_clone.set_subtitle("No vulnerabilities found");
row_clone.set_subtitle("No vulnerabilities found - looking good!");
} else {
row_clone.set_subtitle(&format!(
"Found {} CVE{}", total, if total == 1 { "" } else { "s" }
"Found {} known issue{}. Check for app updates.",
total,
if total == 1 { "" } else { "s" },
));
}
}
Err(_) => {
row_clone.set_subtitle("Scan failed");
row_clone.set_subtitle("Scan failed - the AppImage may not be mountable");
}
}
});
@@ -795,6 +911,7 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
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 {
@@ -802,7 +919,12 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.title("SHA256 checksum")
.subtitle(hash)
.subtitle_selectable(true)
.tooltip_text("Cryptographic hash for verifying file integrity")
.tooltip_text(
"A SHA256 checksum is a unique fingerprint of the file. If even one \
byte changes, the checksum changes completely. You can compare this \
against the developer's published checksum to verify the file hasn't \
been tampered with or corrupted during download."
)
.build();
hash_row.add_css_class("property");
integrity_group.add(&hash_row);
@@ -815,7 +937,10 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
tab
}
/// Tab 4: Storage - disk usage and data discovery
// ---------------------------------------------------------------------------
// Tab 4: Storage - disk usage, data paths, file location
// ---------------------------------------------------------------------------
fn build_storage_tab(
record: &AppImageRecord,
db: &Rc<Database>,
@@ -843,12 +968,16 @@ fn build_storage_tab(
// 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 size")
.title("AppImage file")
.subtitle(&widgets::format_size(record.size_bytes))
.build();
size_group.add(&appimage_row);
@@ -857,9 +986,9 @@ fn build_storage_tab(
let data_total = fp.data_total();
if data_total > 0 {
let total_row = adw::ActionRow::builder()
.title("Total disk footprint")
.title("Total disk usage")
.subtitle(&format!(
"{} (AppImage) + {} (data) = {}",
"{} (AppImage) + {} (app data) = {}",
widgets::format_size(record.size_bytes),
widgets::format_size(data_total as i64),
widgets::format_size(fp.total_size() as i64),
@@ -872,14 +1001,14 @@ fn build_storage_tab(
// Data paths group
let paths_group = adw::PreferencesGroup::builder()
.title("Data Paths")
.description("Config, data, and cache directories for this app")
.title("App Data")
.description("Config, cache, and data directories this app may have created.")
.build();
// Discover button
let discover_row = adw::ActionRow::builder()
.title("Discover data paths")
.subtitle("Search for config, data, and cache directories")
.title("Find app data")
.subtitle("Search for config, cache, and data directories")
.activatable(true)
.build();
let discover_icon = gtk::Image::from_icon_name("folder-saved-search-symbolic");
@@ -890,7 +1019,7 @@ fn build_storage_tab(
let record_id = record.id;
discover_row.connect_activated(move |row| {
row.set_sensitive(false);
row.set_subtitle("Discovering...");
row.set_subtitle("Searching...");
let row_clone = row.clone();
let rec = record_clone.clone();
glib::spawn_future_local(async move {
@@ -906,10 +1035,10 @@ fn build_storage_tab(
Ok(fp) => {
let count = fp.paths.len();
if count == 0 {
row_clone.set_subtitle("No associated paths found");
row_clone.set_subtitle("No associated data directories found");
} else {
row_clone.set_subtitle(&format!(
"Found {} path{} ({})",
"Found {} path{} using {}",
count,
if count == 1 { "" } else { "s" },
widgets::format_size(fp.data_total() as i64),
@@ -917,14 +1046,14 @@ fn build_storage_tab(
}
}
Err(_) => {
row_clone.set_subtitle("Discovery failed");
row_clone.set_subtitle("Search failed");
}
}
});
});
paths_group.add(&discover_row);
// Individual discovered paths with type icons and confidence badges
// Individual discovered paths
for dp in &fp.paths {
if dp.exists {
let row = adw::ActionRow::builder()
@@ -998,22 +1127,51 @@ fn build_storage_tab(
tab
}
fn wayland_description(status: &WaylandStatus) -> &'static str {
// ---------------------------------------------------------------------------
// User-friendly explanations
// ---------------------------------------------------------------------------
fn wayland_user_explanation(status: &WaylandStatus) -> &'static str {
match status {
WaylandStatus::Native => "Runs natively on Wayland",
WaylandStatus::XWayland => "Runs via XWayland compatibility layer",
WaylandStatus::Possible => "May run on Wayland with additional flags",
WaylandStatus::X11Only => "X11 only - no Wayland support",
WaylandStatus::Unknown => "Could not determine Wayland compatibility",
WaylandStatus::Native =>
"Runs natively on Wayland - the best experience on modern Linux desktops.",
WaylandStatus::XWayland =>
"Uses XWayland for display. Works fine, but may appear slightly \
blurry on high-resolution screens.",
WaylandStatus::Possible =>
"Might work on Wayland with the right settings. Try launching it to find out.",
WaylandStatus::X11Only =>
"Designed for X11 only. It will run through XWayland automatically, \
but you may notice minor display quirks.",
WaylandStatus::Unknown =>
"Not yet determined. Launch the app or use 'Analyze toolkit' below to check.",
}
}
fn fuse_description(status: &FuseStatus) -> &'static str {
fn fuse_user_explanation(status: &FuseStatus) -> &'static str {
match status {
FuseStatus::FullyFunctional => "FUSE mount available - native AppImage launch",
FuseStatus::Fuse3Only => "Only FUSE3 installed - may need libfuse2",
FuseStatus::NoFusermount => "fusermount binary not found",
FuseStatus::NoDevFuse => "/dev/fuse device not available",
FuseStatus::MissingLibfuse2 => "libfuse2 not installed",
FuseStatus::FullyFunctional =>
"FUSE is working - AppImages mount directly for fast startup.",
FuseStatus::Fuse3Only =>
"Only FUSE 3 found. Some AppImages need FUSE 2. \
Click the copy button to get the install command.",
FuseStatus::NoFusermount =>
"FUSE tools not found. The app will still work by extracting to a \
temporary folder, but startup will be slower.",
FuseStatus::NoDevFuse =>
"/dev/fuse not available. FUSE may not be configured on your system. \
Apps will extract to a temp folder instead.",
FuseStatus::MissingLibfuse2 =>
"libfuse2 is missing. Click the copy button to get the install command.",
}
}
/// Return an install command for a FUSE status that needs one, or None.
fn fuse_install_command(status: &FuseStatus) -> Option<&'static str> {
match status {
FuseStatus::Fuse3Only | FuseStatus::MissingLibfuse2 => {
Some("sudo apt install libfuse2")
}
_ => None,
}
}

305
src/ui/drop_dialog.rs Normal file
View File

@@ -0,0 +1,305 @@
use adw::prelude::*;
use gtk::gio;
use std::path::PathBuf;
use std::rc::Rc;
use crate::core::analysis;
use crate::core::database::Database;
use crate::core::discovery;
use crate::i18n::{i18n, ni18n_f};
/// Registered file info returned by the fast registration phase.
struct RegisteredFile {
id: i64,
path: PathBuf,
appimage_type: discovery::AppImageType,
}
/// Show a dialog offering to add dropped AppImage files to the library.
///
/// `files` should already be validated as AppImages (magic bytes checked).
/// `toast_overlay` is used to show result toasts.
/// `on_complete` is called after files are registered to refresh the UI.
pub fn show_drop_dialog(
parent: &impl IsA<gtk::Widget>,
files: Vec<PathBuf>,
toast_overlay: &adw::ToastOverlay,
on_complete: impl Fn() + 'static,
) {
if files.is_empty() {
return;
}
let count = files.len();
// Build heading and body
let heading = if count == 1 {
let name = files[0]
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "AppImage".to_string());
crate::i18n::i18n_f("Add {name}?", &[("{name}", &name)])
} else {
ni18n_f(
"Add {} app?",
"Add {} apps?",
count as u32,
&[("{}", &count.to_string())],
)
};
let body = if count == 1 {
files[0].to_string_lossy().to_string()
} else {
files
.iter()
.filter_map(|f| f.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect::<Vec<_>>()
.join("\n")
};
let dialog = adw::AlertDialog::builder()
.heading(&heading)
.body(&body)
.build();
dialog.add_response("cancel", &i18n("Cancel"));
dialog.add_response("add-only", &i18n("Just add"));
dialog.add_response("add-and-integrate", &i18n("Add to app menu"));
dialog.set_response_appearance("add-and-integrate", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("add-and-integrate"));
dialog.set_close_response("cancel");
let toast_ref = toast_overlay.clone();
let on_complete = Rc::new(on_complete);
dialog.connect_response(None, move |_dialog, response| {
if response == "cancel" {
return;
}
let integrate = response == "add-and-integrate";
let files = files.clone();
let toast_ref = toast_ref.clone();
let on_complete_ref = on_complete.clone();
glib::spawn_future_local(async move {
// Phase 1: Fast registration (copy + DB upsert only)
let result = gio::spawn_blocking(move || {
register_dropped_files(&files)
})
.await;
match result {
Ok(Ok(registered)) => {
let added = registered.len();
// Refresh UI immediately - apps appear with "Analyzing..." badge
on_complete_ref();
// Show toast
if added == 1 {
toast_ref.add_toast(adw::Toast::new(&i18n("Added to your apps")));
} else if added > 0 {
let msg = ni18n_f(
"Added {} app",
"Added {} apps",
added as u32,
&[("{}", &added.to_string())],
);
toast_ref.add_toast(adw::Toast::new(&msg));
}
// Phase 2: Background analysis for each file
let on_complete_bg = on_complete_ref.clone();
for reg in registered {
let on_complete_inner = on_complete_bg.clone();
glib::spawn_future_local(async move {
let _ = gio::spawn_blocking(move || {
analysis::run_background_analysis(
reg.id,
reg.path,
reg.appimage_type,
integrate,
);
})
.await;
// Refresh UI when each analysis completes
on_complete_inner();
});
}
}
Ok(Err(e)) => {
log::error!("Drop processing failed: {}", e);
toast_ref.add_toast(adw::Toast::new(&i18n("Failed to add app")));
}
Err(e) => {
log::error!("Drop task failed: {:?}", e);
toast_ref.add_toast(adw::Toast::new(&i18n("Failed to add app")));
}
}
});
});
dialog.present(Some(parent));
}
/// Fast registration of dropped files - copies to target dir and inserts into DB.
/// Returns a list of registered files for background analysis.
fn register_dropped_files(
files: &[PathBuf],
) -> Result<Vec<RegisteredFile>, String> {
let db = Database::open().map_err(|e| format!("Failed to open database: {}", e))?;
let settings = gio::Settings::new(crate::config::APP_ID);
let scan_dirs: Vec<String> = settings
.strv("scan-directories")
.iter()
.map(|s| s.to_string())
.collect();
// Target directory: first scan directory (default ~/Applications)
let target_dir = scan_dirs
.first()
.map(|d| discovery::expand_tilde(d))
.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("Applications")
});
// Ensure target directory exists
std::fs::create_dir_all(&target_dir)
.map_err(|e| format!("Failed to create {}: {}", target_dir.display(), e))?;
// Expand scan dirs for checking if file is already in a scan location
let expanded_scan_dirs: Vec<PathBuf> = scan_dirs
.iter()
.map(|d| discovery::expand_tilde(d))
.collect();
let mut registered = Vec::new();
for file in files {
// Determine if the file is already in a scan directory
let in_scan_dir = expanded_scan_dirs.iter().any(|scan_dir| {
file.parent()
.and_then(|p| p.canonicalize().ok())
.and_then(|parent| {
scan_dir.canonicalize().ok().map(|sd| parent == sd)
})
.unwrap_or(false)
});
let final_path = if in_scan_dir {
file.clone()
} else {
let filename = file
.file_name()
.ok_or_else(|| "No filename".to_string())?;
let dest = target_dir.join(filename);
// Don't overwrite existing files - generate a unique name
let dest = if dest.exists() && dest != *file {
let stem = dest
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("app");
let ext = dest
.extension()
.and_then(|e| e.to_str())
.unwrap_or("AppImage");
let mut counter = 1;
loop {
let candidate = target_dir.join(format!("{}-{}.{}", stem, counter, ext));
if !candidate.exists() {
break candidate;
}
counter += 1;
}
} else {
dest
};
std::fs::copy(file, &dest)
.map_err(|e| format!("Failed to copy {}: {}", file.display(), e))?;
// Make executable
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
if let Err(e) = std::fs::set_permissions(&dest, perms) {
log::warn!("Failed to set executable permissions on {}: {}", dest.display(), e);
}
}
dest
};
// Validate it's actually an AppImage
let appimage_type = match discovery::detect_appimage(&final_path) {
Some(t) => t,
None => {
log::warn!("Not a valid AppImage: {}", final_path.display());
continue;
}
};
let filename = final_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let metadata = std::fs::metadata(&final_path).ok();
let size_bytes = metadata.as_ref().map(|m| m.len() as i64).unwrap_or(0);
let modified = metadata
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|dur| {
chrono::DateTime::from_timestamp(dur.as_secs() as i64, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
});
let is_executable = {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata
.as_ref()
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
{
true
}
};
// Register in database with pending analysis status
let id = db
.upsert_appimage(
&final_path.to_string_lossy(),
&filename,
Some(appimage_type.as_i32()),
size_bytes,
is_executable,
modified.as_deref(),
)
.map_err(|e| format!("Database error: {}", e))?;
if let Err(e) = db.update_analysis_status(id, "pending") {
log::warn!("Failed to set analysis status to 'pending' for id {}: {}", id, e);
}
registered.push(RegisteredFile {
id,
path: final_path,
appimage_type,
});
}
Ok(registered)
}

View File

@@ -5,6 +5,7 @@ use std::rc::Rc;
use crate::core::database::Database;
use crate::core::duplicates::{self, DuplicateGroup, MatchReason, MemberRecommendation};
use crate::core::integrator;
use crate::i18n::{i18n, i18n_f, ni18n_f};
use super::widgets;
/// Show a dialog listing duplicate/multi-version AppImages with resolution options.
@@ -17,10 +18,10 @@ pub fn show_duplicate_dialog(
if groups.is_empty() {
let dialog = adw::AlertDialog::builder()
.heading("No Duplicates Found")
.body("No duplicate or multi-version AppImages were detected.")
.heading(&i18n("No Duplicates Found"))
.body(&i18n("No duplicate or multi-version AppImages were detected."))
.build();
dialog.add_response("ok", "OK");
dialog.add_response("ok", &i18n("OK"));
dialog.set_default_response(Some("ok"));
dialog.present(Some(parent));
return;
@@ -29,7 +30,7 @@ pub fn show_duplicate_dialog(
let summary = duplicates::summarize_duplicates(&groups);
let dialog = adw::Dialog::builder()
.title("Duplicates & Old Versions")
.title(&i18n("Duplicates & Old Versions"))
.content_width(600)
.content_height(500)
.build();
@@ -39,12 +40,12 @@ pub fn show_duplicate_dialog(
// "Remove All Suggested" bulk action button
let bulk_btn = gtk::Button::builder()
.label("Remove All Suggested")
.tooltip_text("Delete all items recommended for removal")
.label(&i18n("Remove All Suggested"))
.tooltip_text(&i18n("Delete all items recommended for removal"))
.build();
bulk_btn.add_css_class("destructive-action");
bulk_btn.update_property(&[
gtk::accessible::Property::Label("Remove all suggested duplicates"),
gtk::accessible::Property::Label(&i18n("Remove all suggested duplicates")),
]);
header.pack_end(&bulk_btn);
@@ -64,13 +65,14 @@ pub fn show_duplicate_dialog(
.build();
// Summary banner
let summary_text = format!(
"{} groups found ({} exact duplicates, {} with multiple versions). \
Potential savings: {}",
summary.total_groups,
summary.exact_duplicates,
summary.multi_version,
widgets::format_size(summary.total_potential_savings as i64),
let summary_text = i18n_f(
"{groups} groups found ({exact} exact duplicates, {multi} with multiple versions). Potential savings: {savings}",
&[
("{groups}", &summary.total_groups.to_string()),
("{exact}", &summary.exact_duplicates.to_string()),
("{multi}", &summary.multi_version.to_string()),
("{savings}", &widgets::format_size(summary.total_potential_savings as i64)),
],
);
let summary_label = gtk::Label::builder()
.label(&summary_text)
@@ -101,13 +103,17 @@ pub fn show_duplicate_dialog(
return;
}
let plural = if count == 1 { "" } else { "s" };
let confirm = adw::AlertDialog::builder()
.heading("Confirm Removal")
.body(&format!("Remove {} suggested duplicate{}?", count, plural))
.heading(&i18n("Confirm Removal"))
.body(&ni18n_f(
"Remove {count} suggested duplicate?",
"Remove {count} suggested duplicates?",
count as u32,
&[("{count}", &count.to_string())],
))
.build();
confirm.add_response("cancel", "Cancel");
confirm.add_response("remove", "Remove");
confirm.add_response("cancel", &i18n("Cancel"));
confirm.add_response("remove", &i18n("Remove"));
confirm.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
confirm.set_default_response(Some("cancel"));
confirm.set_close_response("cancel");
@@ -136,9 +142,14 @@ pub fn show_duplicate_dialog(
removed_count += 1;
}
if removed_count > 0 {
toast_confirm.add_toast(adw::Toast::new(&format!("Removed {} items", removed_count)));
toast_confirm.add_toast(adw::Toast::new(&ni18n_f(
"Removed {count} item",
"Removed {count} items",
removed_count as u32,
&[("{count}", &removed_count.to_string())],
)));
btn_ref.set_sensitive(false);
btn_ref.set_label("Done");
btn_ref.set_label(&i18n("Done"));
}
});
@@ -158,23 +169,27 @@ fn build_group_widget(
toast_overlay: &adw::ToastOverlay,
) -> (adw::PreferencesGroup, Vec<(i64, String, String, bool)>) {
let reason_text = match group.match_reason {
MatchReason::ExactDuplicate => "Exact duplicate",
MatchReason::MultiVersion => "Multiple versions",
MatchReason::SameVersionDifferentPath => "Same version, different path",
MatchReason::ExactDuplicate => i18n("Exact duplicate"),
MatchReason::MultiVersion => i18n("Multiple versions"),
MatchReason::SameVersionDifferentPath => i18n("Same version, different path"),
};
let description = if group.potential_savings > 0 {
format!(
"{} - Total: {} - Potential savings: {}",
reason_text,
widgets::format_size(group.total_size as i64),
widgets::format_size(group.potential_savings as i64),
i18n_f(
"{reason} - Total: {total} - Potential savings: {savings}",
&[
("{reason}", &reason_text),
("{total}", &widgets::format_size(group.total_size as i64)),
("{savings}", &widgets::format_size(group.potential_savings as i64)),
],
)
} else {
format!(
"{} - Total: {}",
reason_text,
widgets::format_size(group.total_size as i64),
i18n_f(
"{reason} - Total: {total}",
&[
("{reason}", &reason_text),
("{total}", &widgets::format_size(group.total_size as i64)),
],
)
};
@@ -187,11 +202,12 @@ fn build_group_widget(
for member in &group.members {
let record = &member.record;
let version = record.app_version.as_deref().unwrap_or("unknown");
let unknown = i18n("unknown");
let version = record.app_version.as_deref().unwrap_or(&unknown);
let size = widgets::format_size(record.size_bytes);
let title = if member.is_recommended {
format!("{} ({}) - Recommended", version, size)
i18n_f("{version} ({size}) - Recommended", &[("{version}", version), ("{size}", &size)])
} else {
format!("{} ({})", version, size)
};
@@ -225,7 +241,7 @@ fn build_group_widget(
let delete_btn = gtk::Button::builder()
.icon_name("user-trash-symbolic")
.tooltip_text("Delete this AppImage")
.tooltip_text(&i18n("Delete this AppImage"))
.css_classes(["flat", "circular"])
.valign(gtk::Align::Center)
.build();
@@ -235,7 +251,7 @@ fn build_group_widget(
let record_path = record.path.clone();
let record_name = record.app_name.clone().unwrap_or_else(|| record.filename.clone());
delete_btn.update_property(&[
gtk::accessible::Property::Label(&format!("Delete {}", record_name)),
gtk::accessible::Property::Label(&i18n_f("Delete {name}", &[("{name}", &record_name)])),
]);
let db_ref = db.clone();
let toast_ref = toast_overlay.clone();
@@ -253,7 +269,7 @@ fn build_group_widget(
db_ref.remove_appimage(record_id).ok();
// Update UI
btn.set_sensitive(false);
toast_ref.add_toast(adw::Toast::new(&format!("Removed {}", record_name)));
toast_ref.add_toast(adw::Toast::new(&i18n_f("Removed {name}", &[("{name}", &record_name)])));
});
row.add_suffix(&delete_btn);

View File

@@ -5,6 +5,7 @@ use crate::core::database::{AppImageRecord, Database};
use crate::core::fuse::FuseStatus;
use crate::core::integrator;
use crate::core::wayland::WaylandStatus;
use crate::i18n::{i18n, i18n_f};
use super::widgets;
/// Show a confirmation dialog before integrating an AppImage into the desktop.
@@ -18,14 +19,14 @@ pub fn show_integration_dialog(
let name = record.app_name.as_deref().unwrap_or(&record.filename);
let dialog = adw::AlertDialog::builder()
.heading(&format!("Integrate {}?", name))
.body("This will add the application to your desktop menu.")
.heading(&i18n_f("Integrate {name}?", &[("{name}", name)]))
.body(&i18n("This will add the application to your desktop menu."))
.close_response("cancel")
.default_response("integrate")
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("integrate", "Integrate");
dialog.add_response("cancel", &i18n("Cancel"));
dialog.add_response("integrate", &i18n("Integrate"));
dialog.set_response_appearance("integrate", adw::ResponseAppearance::Suggested);
// Build extra content with details
@@ -40,12 +41,12 @@ pub fn show_integration_dialog(
identity_box.add_css_class("boxed-list");
identity_box.set_selection_mode(gtk::SelectionMode::None);
identity_box.update_property(&[
gtk::accessible::Property::Label("Application details"),
gtk::accessible::Property::Label(&i18n("Application details")),
]);
// Name
let name_row = adw::ActionRow::builder()
.title("Application")
.title(&i18n("Application"))
.subtitle(name)
.build();
if let Some(ref icon_path) = record.icon_path {
@@ -65,7 +66,7 @@ pub fn show_integration_dialog(
// Version
if let Some(ref version) = record.app_version {
let row = adw::ActionRow::builder()
.title("Version")
.title(&i18n("Version"))
.subtitle(version)
.build();
identity_box.append(&row);
@@ -78,12 +79,12 @@ pub fn show_integration_dialog(
actions_box.add_css_class("boxed-list");
actions_box.set_selection_mode(gtk::SelectionMode::None);
actions_box.update_property(&[
gtk::accessible::Property::Label("Integration actions"),
gtk::accessible::Property::Label(&i18n("Integration actions")),
]);
let desktop_row = adw::ActionRow::builder()
.title("Desktop entry")
.subtitle("A .desktop file will be created in ~/.local/share/applications")
.title(&i18n("Desktop entry"))
.subtitle(&i18n("A .desktop file will be created in ~/.local/share/applications"))
.build();
let check1 = gtk::Image::from_icon_name("emblem-ok-symbolic");
check1.set_valign(gtk::Align::Center);
@@ -91,8 +92,8 @@ pub fn show_integration_dialog(
actions_box.append(&desktop_row);
let icon_row = adw::ActionRow::builder()
.title("Icon")
.subtitle("The app icon will be installed to ~/.local/share/icons")
.title(&i18n("Icon"))
.subtitle(&i18n("The app icon will be installed to ~/.local/share/icons"))
.build();
let check2 = gtk::Image::from_icon_name("emblem-ok-symbolic");
check2.set_valign(gtk::Align::Center);
@@ -114,23 +115,31 @@ pub fn show_integration_dialog(
.map(FuseStatus::from_str)
.unwrap_or(FuseStatus::MissingLibfuse2);
let mut warnings: Vec<(&str, &str, &str)> = Vec::new();
let mut warnings: Vec<(String, String, String)> = Vec::new();
if wayland_status == WaylandStatus::X11Only {
warnings.push(("X11 only", "This app does not support Wayland and will run through XWayland", "X11"));
warnings.push((
i18n("X11 only"),
i18n("This app does not support Wayland and will run through XWayland"),
"X11".to_string(),
));
} else if wayland_status == WaylandStatus::XWayland {
warnings.push(("XWayland", "This app runs through the XWayland compatibility layer", "XWayland"));
warnings.push((
i18n("XWayland"),
i18n("This app runs through the XWayland compatibility layer"),
"XWayland".to_string(),
));
}
if !fuse_status.is_functional() {
let fuse_msg = match fuse_status {
FuseStatus::Fuse3Only => "Only FUSE3 is installed - libfuse2 may be needed",
FuseStatus::NoFusermount => "fusermount not found - AppImage mount may fail",
FuseStatus::NoDevFuse => "/dev/fuse not available - AppImage mount will fail",
FuseStatus::MissingLibfuse2 => "libfuse2 not installed - fallback extraction will be used",
_ => "FUSE issue detected",
FuseStatus::Fuse3Only => i18n("Only FUSE3 is installed - libfuse2 may be needed"),
FuseStatus::NoFusermount => i18n("fusermount not found - AppImage mount may fail"),
FuseStatus::NoDevFuse => i18n("/dev/fuse not available - AppImage mount will fail"),
FuseStatus::MissingLibfuse2 => i18n("libfuse2 not installed - fallback extraction will be used"),
_ => i18n("FUSE issue detected"),
};
warnings.push(("FUSE", fuse_msg, fuse_status.label()));
warnings.push(("FUSE".to_string(), fuse_msg, fuse_status.label().to_string()));
}
if !warnings.is_empty() {
@@ -149,7 +158,7 @@ pub fn show_integration_dialog(
warning_icon.set_pixel_size(16);
warning_header.append(&warning_icon);
let warning_title = gtk::Label::builder()
.label("Compatibility Notes")
.label(&i18n("Compatibility Notes"))
.css_classes(["title-4"])
.halign(gtk::Align::Start)
.build();
@@ -162,8 +171,8 @@ pub fn show_integration_dialog(
for (title, subtitle, badge_text) in &warnings {
let row = adw::ActionRow::builder()
.title(*title)
.subtitle(*subtitle)
.title(title.as_str())
.subtitle(subtitle.as_str())
.build();
let badge = widgets::status_badge(badge_text, "warning");
badge.set_valign(gtk::Align::Center);

View File

@@ -96,9 +96,28 @@ impl LibraryView {
.title("Driftwood")
.build();
// Add button (shows drop overlay)
let add_button_icon = gtk::Image::from_icon_name("list-add-symbolic");
let add_button_label = gtk::Label::new(Some(&i18n("Add app")));
let add_button_content = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(6)
.build();
add_button_content.append(&add_button_icon);
add_button_content.append(&add_button_label);
let add_button = gtk::Button::builder()
.child(&add_button_content)
.tooltip_text(&i18n("Add AppImage"))
.build();
add_button.add_css_class("flat");
add_button.set_action_name(Some("win.show-drop-hint"));
add_button.update_property(&[AccessibleProperty::Label("Add AppImage")]);
let header_bar = adw::HeaderBar::builder()
.title_widget(&title_widget)
.build();
header_bar.pack_start(&add_button);
header_bar.pack_end(&menu_button);
header_bar.pack_end(&search_button);
header_bar.pack_end(&view_toggle_box);
@@ -175,8 +194,8 @@ impl LibraryView {
.description(&i18n(
"Driftwood manages your AppImage collection - scanning for apps, \
integrating them into your desktop, and keeping them up to date.\n\n\
Add AppImages to ~/Applications or ~/Downloads, or configure \
custom scan locations in Preferences.",
Drag AppImage files here, or add them to ~/Applications or ~/Downloads, \
then use Scan Now to find them.",
))
.child(&empty_button_box)
.build();
@@ -196,13 +215,13 @@ impl LibraryView {
.selection_mode(gtk::SelectionMode::None)
.homogeneous(true)
.min_children_per_line(2)
.max_children_per_line(4)
.row_spacing(14)
.column_spacing(14)
.margin_top(14)
.margin_bottom(14)
.margin_start(14)
.margin_end(14)
.max_children_per_line(5)
.row_spacing(12)
.column_spacing(12)
.margin_top(12)
.margin_bottom(12)
.margin_start(12)
.margin_end(12)
.build();
flow_box.update_property(&[AccessibleProperty::Label("AppImage library grid")]);
@@ -463,9 +482,14 @@ impl LibraryView {
let name = record.app_name.as_deref().unwrap_or(&record.filename);
// Structured two-line subtitle:
// Line 1: Description snippet or file path
// Line 1: Description snippet or file path (or "Analyzing..." if pending)
// Line 2: Version + size
let line1 = if let Some(ref desc) = record.description {
let is_analyzing = record.app_name.is_none()
&& record.analysis_status.as_deref() != Some("complete");
let line1 = if is_analyzing {
i18n("Analyzing...")
} else if let Some(ref desc) = record.description {
if !desc.is_empty() {
let snippet: String = desc.chars().take(60).collect();
if snippet.len() < desc.len() {
@@ -496,7 +520,7 @@ impl LibraryView {
.activatable(true)
.build();
// Icon prefix (48x48 with rounded clipping and letter fallback)
// Icon prefix with rounded clipping and letter fallback
let icon = widgets::app_icon(
record.icon_path.as_deref(),
name,
@@ -583,19 +607,19 @@ fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu {
// Section 2: Actions
let section2 = gtk::gio::Menu::new();
section2.append(Some("Check for Updates"), Some(&format!("win.check-update(int64 {})", record.id)));
section2.append(Some("Scan for Vulnerabilities"), Some(&format!("win.scan-security(int64 {})", record.id)));
section2.append(Some("Security check"), Some(&format!("win.scan-security(int64 {})", record.id)));
menu.append_section(None, &section2);
// Section 3: Integration + folder
let section3 = gtk::gio::Menu::new();
let integrate_label = if record.integrated { "Remove Integration" } else { "Integrate" };
let integrate_label = if record.integrated { "Remove from app menu" } else { "Add to app menu" };
section3.append(Some(integrate_label), Some(&format!("win.toggle-integration(int64 {})", record.id)));
section3.append(Some("Open Containing Folder"), Some(&format!("win.open-folder(int64 {})", record.id)));
section3.append(Some("Show in file manager"), Some(&format!("win.open-folder(int64 {})", record.id)));
menu.append_section(None, &section3);
// Section 4: Clipboard
let section4 = gtk::gio::Menu::new();
section4.append(Some("Copy Path"), Some(&format!("win.copy-path(int64 {})", record.id)));
section4.append(Some("Copy file location"), Some(&format!("win.copy-path(int64 {})", record.id)));
menu.append_section(None, &section4);
menu

View File

@@ -1,8 +1,12 @@
pub mod app_card;
pub mod cleanup_wizard;
pub mod dashboard;
pub mod detail_view;
pub mod drop_dialog;
pub mod duplicate_dialog;
pub mod integration_dialog;
pub mod library_view;
pub mod preferences;
pub mod security_report;
pub mod update_dialog;
pub mod widgets;

View File

@@ -2,10 +2,11 @@ use adw::prelude::*;
use gtk::gio;
use crate::config::APP_ID;
use crate::i18n::i18n;
pub fn show_preferences_dialog(parent: &impl IsA<gtk::Widget>) {
let dialog = adw::PreferencesDialog::new();
dialog.set_title("Preferences");
dialog.set_title(&i18n("Preferences"));
let settings = gio::Settings::new(APP_ID);
@@ -20,22 +21,22 @@ pub fn show_preferences_dialog(parent: &impl IsA<gtk::Widget>) {
fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog) -> adw::PreferencesPage {
let page = adw::PreferencesPage::builder()
.title("General")
.title(&i18n("General"))
.icon_name("emblem-system-symbolic")
.build();
// Appearance group
let appearance_group = adw::PreferencesGroup::builder()
.title("Appearance")
.description("Visual preferences for the application")
.title(&i18n("Appearance"))
.description(&i18n("Visual preferences for the application"))
.build();
let theme_row = adw::ComboRow::builder()
.title("Color Scheme")
.subtitle("Choose light, dark, or follow system preference")
.title(&i18n("Color Scheme"))
.subtitle(&i18n("Choose light, dark, or follow system preference"))
.build();
let model = gtk::StringList::new(&["Follow System", "Light", "Dark"]);
let model = gtk::StringList::new(&[&i18n("Follow System"), &i18n("Light"), &i18n("Dark")]);
theme_row.set_model(Some(&model));
let current = settings.string("color-scheme");
@@ -58,10 +59,10 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
appearance_group.add(&theme_row);
let view_row = adw::ComboRow::builder()
.title("Default View")
.subtitle("Library display mode")
.title(&i18n("Default View"))
.subtitle(&i18n("Library display mode"))
.build();
let view_model = gtk::StringList::new(&["Grid", "List"]);
let view_model = gtk::StringList::new(&[&i18n("Grid"), &i18n("List")]);
view_row.set_model(Some(&view_model));
let current_view = settings.string("view-mode");
view_row.set_selected(if current_view.as_str() == "list" { 1 } else { 0 });
@@ -73,12 +74,13 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
});
appearance_group.add(&view_row);
page.add(&appearance_group);
// Scan Locations group
let scan_group = adw::PreferencesGroup::builder()
.title("Scan Locations")
.description("Directories to scan for AppImage files")
.title(&i18n("Scan Locations"))
.description(&i18n("Directories to scan for AppImage files"))
.build();
let dirs = settings.strv("scan-directories");
@@ -86,7 +88,7 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
dir_list_box.add_css_class("boxed-list");
dir_list_box.set_selection_mode(gtk::SelectionMode::None);
dir_list_box.update_property(&[
gtk::accessible::Property::Label("Scan directories"),
gtk::accessible::Property::Label(&i18n("Scan directories")),
]);
for dir in &dirs {
@@ -97,11 +99,11 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
// Add location button
let add_button = gtk::Button::builder()
.label("Add Location")
.label(&i18n("Add Location"))
.build();
add_button.add_css_class("flat");
add_button.update_property(&[
gtk::accessible::Property::Label("Add scan directory"),
gtk::accessible::Property::Label(&i18n("Add scan directory")),
]);
let settings_add = settings.clone();
@@ -109,7 +111,7 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
let dialog_weak = dialog.downgrade();
add_button.connect_clicked(move |_| {
let file_dialog = gtk::FileDialog::builder()
.title("Choose a directory")
.title(i18n("Choose a directory"))
.modal(true)
.build();
@@ -158,19 +160,19 @@ fn build_general_page(settings: &gio::Settings, dialog: &adw::PreferencesDialog)
fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
let page = adw::PreferencesPage::builder()
.title("Behavior")
.title(&i18n("Behavior"))
.icon_name("preferences-other-symbolic")
.build();
// Automation group
let automation_group = adw::PreferencesGroup::builder()
.title("Automation")
.description("What Driftwood does automatically")
.title(&i18n("Automation"))
.description(&i18n("What Driftwood does automatically"))
.build();
let auto_scan_row = adw::SwitchRow::builder()
.title("Scan on startup")
.subtitle("Automatically scan for new AppImages when the app starts")
.title(&i18n("Scan on startup"))
.subtitle(&i18n("Automatically scan for new AppImages when the app starts"))
.active(settings.boolean("auto-scan-on-startup"))
.build();
let settings_scan = settings.clone();
@@ -180,8 +182,8 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
automation_group.add(&auto_scan_row);
let auto_update_row = adw::SwitchRow::builder()
.title("Check for updates")
.subtitle("Periodically check if newer versions of your AppImages are available")
.title(&i18n("Check for updates"))
.subtitle(&i18n("Periodically check if newer versions of your AppImages are available"))
.active(settings.boolean("auto-check-updates"))
.build();
let settings_upd = settings.clone();
@@ -191,8 +193,8 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
automation_group.add(&auto_update_row);
let auto_integrate_row = adw::SwitchRow::builder()
.title("Auto-integrate new AppImages")
.subtitle("Automatically add newly discovered AppImages to the desktop menu")
.title(&i18n("Auto-integrate new AppImages"))
.subtitle(&i18n("Automatically add newly discovered AppImages to the desktop menu"))
.active(settings.boolean("auto-integrate"))
.build();
let settings_int = settings.clone();
@@ -205,13 +207,13 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
// Backup group
let backup_group = adw::PreferencesGroup::builder()
.title("Backups")
.description("Config and data backup settings for updates")
.title(&i18n("Backups"))
.description(&i18n("Config and data backup settings for updates"))
.build();
let auto_backup_row = adw::SwitchRow::builder()
.title("Auto-backup before update")
.subtitle("Back up config and data files before updating an AppImage")
.title(&i18n("Auto-backup before update"))
.subtitle(&i18n("Back up config and data files before updating an AppImage"))
.active(settings.boolean("auto-backup-before-update"))
.build();
let settings_backup = settings.clone();
@@ -221,8 +223,8 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
backup_group.add(&auto_backup_row);
let retention_row = adw::SpinRow::builder()
.title("Backup retention")
.subtitle("Days to keep config backups before auto-cleanup")
.title(&i18n("Backup retention"))
.subtitle(&i18n("Days to keep config backups before auto-cleanup"))
.build();
let adjustment = gtk::Adjustment::new(
settings.int("backup-retention-days") as f64,
@@ -243,13 +245,13 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
// Safety group
let safety_group = adw::PreferencesGroup::builder()
.title("Safety")
.description("Confirmation and cleanup behavior")
.title(&i18n("Safety"))
.description(&i18n("Confirmation and cleanup behavior"))
.build();
let confirm_row = adw::SwitchRow::builder()
.title("Confirm before delete")
.subtitle("Show a confirmation dialog before deleting files or cleaning up")
.title(&i18n("Confirm before delete"))
.subtitle(&i18n("Show a confirmation dialog before deleting files or cleaning up"))
.active(settings.boolean("confirm-before-delete"))
.build();
let settings_confirm = settings.clone();
@@ -259,10 +261,10 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
safety_group.add(&confirm_row);
let cleanup_row = adw::ComboRow::builder()
.title("After updating an AppImage")
.subtitle("What to do with the old version after a successful update")
.title(&i18n("After updating an AppImage"))
.subtitle(&i18n("What to do with the old version after a successful update"))
.build();
let cleanup_model = gtk::StringList::new(&["Ask each time", "Remove old version", "Keep backup"]);
let cleanup_model = gtk::StringList::new(&[&i18n("Ask each time"), &i18n("Remove old version"), &i18n("Keep backup")]);
cleanup_row.set_model(Some(&cleanup_model));
let current_cleanup = settings.string("update-cleanup");
@@ -292,18 +294,18 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
fn build_security_page(settings: &gio::Settings) -> adw::PreferencesPage {
let page = adw::PreferencesPage::builder()
.title("Security")
.title(&i18n("Security"))
.icon_name("security-medium-symbolic")
.build();
let scan_group = adw::PreferencesGroup::builder()
.title("Vulnerability Scanning")
.description("Check bundled libraries for known CVEs via OSV.dev")
.title(&i18n("Vulnerability Scanning"))
.description(&i18n("Check bundled libraries for known CVEs via OSV.dev"))
.build();
let auto_security_row = adw::SwitchRow::builder()
.title("Auto-scan new AppImages")
.subtitle("Automatically run a security scan on newly discovered AppImages")
.title(&i18n("Auto-scan new AppImages"))
.subtitle(&i18n("Automatically run a security scan on newly discovered AppImages"))
.active(settings.boolean("auto-security-scan"))
.build();
let settings_sec = settings.clone();
@@ -313,8 +315,8 @@ fn build_security_page(settings: &gio::Settings) -> adw::PreferencesPage {
scan_group.add(&auto_security_row);
let info_row = adw::ActionRow::builder()
.title("Data source")
.subtitle("OSV.dev - Open Source Vulnerability database")
.title(&i18n("Data source"))
.subtitle(&i18n("OSV.dev - Open Source Vulnerability database"))
.build();
scan_group.add(&info_row);
@@ -322,13 +324,13 @@ fn build_security_page(settings: &gio::Settings) -> adw::PreferencesPage {
// Notification settings
let notify_group = adw::PreferencesGroup::builder()
.title("Notifications")
.description("Desktop notification settings for security alerts")
.title(&i18n("Notifications"))
.description(&i18n("Desktop notification settings for security alerts"))
.build();
let notify_row = adw::SwitchRow::builder()
.title("Security notifications")
.subtitle("Send desktop notifications when new CVEs are found")
.title(&i18n("Security notifications"))
.subtitle(&i18n("Send desktop notifications when new CVEs are found"))
.active(settings.boolean("security-notifications"))
.build();
let settings_notify = settings.clone();
@@ -338,10 +340,10 @@ fn build_security_page(settings: &gio::Settings) -> adw::PreferencesPage {
notify_group.add(&notify_row);
let threshold_row = adw::ComboRow::builder()
.title("Notification threshold")
.subtitle("Minimum severity to trigger a notification")
.title(&i18n("Notification threshold"))
.subtitle(&i18n("Minimum severity to trigger a notification"))
.build();
let threshold_model = gtk::StringList::new(&["Critical", "High", "Medium", "Low"]);
let threshold_model = gtk::StringList::new(&[&i18n("Critical"), &i18n("High"), &i18n("Medium"), &i18n("Low")]);
threshold_row.set_model(Some(&threshold_model));
let current_threshold = settings.string("security-notification-threshold");
@@ -369,19 +371,19 @@ fn build_security_page(settings: &gio::Settings) -> adw::PreferencesPage {
// About security scanning
let about_group = adw::PreferencesGroup::builder()
.title("How It Works")
.description("Understanding Driftwood's security scanning")
.title(&i18n("How It Works"))
.description(&i18n("Understanding Driftwood's security scanning"))
.build();
let about_row = adw::ActionRow::builder()
.title("Bundled library detection")
.subtitle("Driftwood extracts the list of shared libraries (.so files) bundled inside each AppImage and checks them against the OSV vulnerability database.")
.title(&i18n("Bundled library detection"))
.subtitle(&i18n("Driftwood extracts the list of shared libraries (.so files) bundled inside each AppImage and checks them against the OSV vulnerability database."))
.build();
about_group.add(&about_row);
let limits_row = adw::ActionRow::builder()
.title("Limitations")
.subtitle("Not all bundled libraries can be identified. Version detection uses heuristics and may not always be accurate. Results should be treated as advisory.")
.title(&i18n("Limitations"))
.subtitle(&i18n("Not all bundled libraries can be identified. Version detection uses heuristics and may not always be accurate. Results should be treated as advisory."))
.build();
about_group.add(&limits_row);
@@ -398,11 +400,11 @@ fn add_directory_row(list_box: &gtk::ListBox, dir: &str, settings: &gio::Setting
let remove_btn = gtk::Button::builder()
.icon_name("edit-delete-symbolic")
.valign(gtk::Align::Center)
.tooltip_text("Remove")
.tooltip_text(&i18n("Remove"))
.build();
remove_btn.add_css_class("flat");
remove_btn.update_property(&[
gtk::accessible::Property::Label(&format!("Remove directory {}", dir)),
gtk::accessible::Property::Label(&format!("{} {}", i18n("Remove directory"), dir)),
]);
let list_ref = list_box.clone();

View File

@@ -5,6 +5,7 @@ use std::rc::Rc;
use crate::config::APP_ID;
use crate::core::database::{AppImageRecord, Database};
use crate::core::updater;
use crate::i18n::{i18n, i18n_f};
/// Show an update check + apply dialog for a single AppImage.
pub fn show_update_dialog(
@@ -13,13 +14,13 @@ pub fn show_update_dialog(
db: &Rc<Database>,
) {
let dialog = adw::AlertDialog::builder()
.heading("Check for Updates")
.body(&format!(
"Checking for updates for {}...",
record.app_name.as_deref().unwrap_or(&record.filename)
.heading(&i18n("Check for Updates"))
.body(&i18n_f(
"Checking for updates for {name}...",
&[("{name}", record.app_name.as_deref().unwrap_or(&record.filename))],
))
.build();
dialog.add_response("close", "Close");
dialog.add_response("close", &i18n("Close"));
dialog.set_default_response(Some("close"));
dialog.set_close_response("close");
@@ -58,15 +59,17 @@ pub fn show_update_dialog(
db_ref.set_update_available(record_id, Some(version), check_result.download_url.as_deref()).ok();
}
let mut body = format!(
"{} -> {}",
record_clone.app_version.as_deref().unwrap_or("unknown"),
check_result.latest_version.as_deref().unwrap_or("unknown"),
let mut body = i18n_f(
"{current} -> {latest}",
&[
("{current}", record_clone.app_version.as_deref().unwrap_or("unknown")),
("{latest}", check_result.latest_version.as_deref().unwrap_or("unknown")),
],
);
if let Some(size) = check_result.file_size {
body.push_str(&format!(" ({})", humansize::format_size(size, humansize::BINARY)));
}
body.push_str("\n\nA new version is available.");
body.push_str(&format!("\n\n{}", i18n("A new version is available.")));
if let Some(ref notes) = check_result.release_notes {
if !notes.is_empty() {
// Truncate long release notes for the dialog
@@ -75,12 +78,12 @@ pub fn show_update_dialog(
body.push_str(&format!("\n\n{}{}", truncated, suffix));
}
}
dialog_ref.set_heading(Some("Update Available"));
dialog_ref.set_heading(Some(&i18n("Update Available")));
dialog_ref.set_body(&body);
// Add "Update Now" button if we have a download URL
if let Some(download_url) = check_result.download_url {
dialog_ref.add_response("update", "Update Now");
dialog_ref.add_response("update", &i18n("Update Now"));
dialog_ref.set_response_appearance("update", adw::ResponseAppearance::Suggested);
dialog_ref.set_default_response(Some("update"));
@@ -101,11 +104,13 @@ pub fn show_update_dialog(
});
}
} else {
dialog_ref.set_heading(Some("Up to Date"));
dialog_ref.set_body(&format!(
"{} is already at the latest version ({}).",
record_clone.app_name.as_deref().unwrap_or(&record_clone.filename),
record_clone.app_version.as_deref().unwrap_or("unknown"),
dialog_ref.set_heading(Some(&i18n("Up to Date")));
dialog_ref.set_body(&i18n_f(
"{name} is already at the latest version ({version}).",
&[
("{name}", record_clone.app_name.as_deref().unwrap_or(&record_clone.filename)),
("{version}", record_clone.app_version.as_deref().unwrap_or("unknown")),
],
));
db_ref.clear_update_available(record_id).ok();
}
@@ -113,19 +118,18 @@ pub fn show_update_dialog(
Ok((type_label, raw_info, None)) => {
if raw_info.is_some() {
db_ref.update_update_info(record_id, raw_info.as_deref(), type_label.as_deref()).ok();
dialog_ref.set_heading(Some("Check Failed"));
dialog_ref.set_body("Could not reach the update server. Try again later.");
dialog_ref.set_heading(Some(&i18n("Check Failed")));
dialog_ref.set_body(&i18n("Could not reach the update server. Try again later."));
} else {
dialog_ref.set_heading(Some("No Update Info"));
dialog_ref.set_heading(Some(&i18n("No Update Info")));
dialog_ref.set_body(
"This app does not support automatic updates. \
Check the developer's website for newer versions.",
&i18n("This app does not support automatic updates. Check the developer's website for newer versions."),
);
}
}
Err(_) => {
dialog_ref.set_heading(Some("Error"));
dialog_ref.set_body("An error occurred while checking for updates.");
dialog_ref.set_heading(Some(&i18n("Error")));
dialog_ref.set_body(&i18n("An error occurred while checking for updates."));
}
}
});
@@ -142,8 +146,8 @@ fn start_update(
new_version: Option<&str>,
db: &Rc<Database>,
) {
dialog.set_heading(Some("Updating..."));
dialog.set_body("Downloading update. This may take a moment.");
dialog.set_heading(Some(&i18n("Updating...")));
dialog.set_body(&i18n("Downloading update. This may take a moment."));
dialog.set_response_enabled("update", false);
let path = appimage_path.to_string();
@@ -171,12 +175,14 @@ fn start_update(
}
db_ref.clear_update_available(record_id).ok();
let success_body = format!(
"Updated to {}\nPath: {}",
applied.new_version.as_deref().unwrap_or("latest"),
applied.new_path.display(),
let success_body = i18n_f(
"Updated to {version}\nPath: {path}",
&[
("{version}", applied.new_version.as_deref().unwrap_or("latest")),
("{path}", &applied.new_path.display().to_string()),
],
);
dialog.set_heading(Some("Update Complete"));
dialog.set_heading(Some(&i18n("Update Complete")));
dialog.set_body(&success_body);
dialog.set_response_enabled("update", false);
@@ -186,12 +192,15 @@ fn start_update(
}
}
Ok(Err(e)) => {
dialog.set_heading(Some("Update Failed"));
dialog.set_body(&format!("The update could not be applied: {}", e));
dialog.set_heading(Some(&i18n("Update Failed")));
dialog.set_body(&i18n_f(
"The update could not be applied: {error}",
&[("{error}", &e.to_string())],
));
}
Err(_) => {
dialog.set_heading(Some("Update Failed"));
dialog.set_body("An unexpected error occurred during the update.");
dialog.set_heading(Some(&i18n("Update Failed")));
dialog.set_body(&i18n("An unexpected error occurred during the update."));
}
}
});
@@ -215,18 +224,18 @@ fn handle_old_version_cleanup(dialog: &adw::AlertDialog, old_path: PathBuf) {
}
"never" => {
// Keep the backup, just inform
dialog.set_body(&format!(
"Update complete. The old version is saved at:\n{}",
old_path.display()
dialog.set_body(&i18n_f(
"Update complete. The old version is saved at:\n{path}",
&[("{path}", &old_path.display().to_string())],
));
}
_ => {
// "ask" - prompt the user
dialog.set_body(&format!(
"Update complete.\n\nRemove the old version?\n{}",
old_path.display()
dialog.set_body(&i18n_f(
"Update complete.\n\nRemove the old version?\n{path}",
&[("{path}", &old_path.display().to_string())],
));
dialog.add_response("remove-old", "Remove Old Version");
dialog.add_response("remove-old", &i18n("Remove Old Version"));
dialog.set_response_appearance("remove-old", adw::ResponseAppearance::Destructive);
let path = old_path.clone();

View File

@@ -1,4 +1,59 @@
use gtk::prelude::*;
use std::sync::OnceLock;
/// Ensures the shared letter-icon CSS provider is registered on the default
/// display exactly once. The provider defines `.letter-icon-a` through
/// `.letter-icon-z` (and `.letter-icon-other`) with distinct hue-based
/// background/foreground colors so that individual `build_letter_icon` calls
/// never need to create their own CssProvider.
fn ensure_letter_icon_css() {
static REGISTERED: OnceLock<bool> = OnceLock::new();
REGISTERED.get_or_init(|| {
let provider = gtk::CssProvider::new();
provider.load_from_string(&generate_letter_icon_css());
if let Some(display) = gtk::gdk::Display::default() {
gtk::style_context_add_provider_for_display(
&display,
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 1,
);
}
true
});
}
/// Generate CSS rules for `.letter-icon-a` through `.letter-icon-z` and a
/// `.letter-icon-other` fallback. Each letter gets a unique hue evenly
/// distributed around the color wheel (saturation 55%, lightness 45% for
/// the background, lightness 97% for the foreground text) so that the 26
/// letter icons are visually distinct while remaining legible.
fn generate_letter_icon_css() -> String {
let mut css = String::with_capacity(4096);
for i in 0u32..26 {
let letter = (b'a' + i as u8) as char;
let hue = (i * 360) / 26;
// HSL background: moderate saturation, medium lightness
// HSL foreground: same hue, very light for contrast
css.push_str(&format!(
"label.letter-icon-{letter} {{ \
background: hsl({hue}, 55%, 45%); \
color: hsl({hue}, 100%, 97%); \
border-radius: 50%; \
font-weight: 700; \
}}\n"
));
}
// Fallback for non-alphabetic first characters
css.push_str(
"label.letter-icon-other { \
background: hsl(0, 0%, 50%); \
color: hsl(0, 0%, 97%); \
border-radius: 50%; \
font-weight: 700; \
}\n"
);
css
}
/// Create a status badge pill label with the given text and style class.
/// Style classes: "success", "warning", "error", "info", "neutral"
@@ -70,61 +125,47 @@ pub fn app_icon(icon_path: Option<&str>, app_name: &str, pixel_size: i32) -> gtk
}
/// Build a colored circle with the first letter of the name as a fallback icon.
///
/// The color CSS classes (`.letter-icon-a` .. `.letter-icon-z`) are registered
/// once via a shared CssProvider. This function only needs to pick the right
/// class and set per-widget sizing, avoiding a new provider per icon.
fn build_letter_icon(name: &str, size: i32) -> gtk::Widget {
let letter = name
// Ensure the shared CSS for all 26 letter classes is loaded
ensure_letter_icon_css();
let first_char = name
.chars()
.find(|c| c.is_alphanumeric())
.unwrap_or('?')
.to_uppercase()
.next()
.unwrap_or('?');
let letter_upper = first_char.to_uppercase().next().unwrap_or('?');
// Pick a color based on the name hash for consistency
let color_index = name.bytes().fold(0u32, |acc, b| acc.wrapping_add(b as u32)) % 6;
let bg_color = match color_index {
0 => "@accent_bg_color",
1 => "@success_bg_color",
2 => "@warning_bg_color",
3 => "@error_bg_color",
4 => "@accent_bg_color",
_ => "@success_bg_color",
};
let fg_color = match color_index {
0 => "@accent_fg_color",
1 => "@success_fg_color",
2 => "@warning_fg_color",
3 => "@error_fg_color",
4 => "@accent_fg_color",
_ => "@success_fg_color",
// Determine the CSS class: letter-icon-a .. letter-icon-z, or letter-icon-other
let css_class = if first_char.is_ascii_alphabetic() {
format!("letter-icon-{}", first_char.to_ascii_lowercase())
} else {
"letter-icon-other".to_string()
};
// Use a label styled as a circle with the letter
// Font size scales with the icon (40% of the circle diameter).
let font_size_pt = size * 4 / 10;
let label = gtk::Label::builder()
.label(&letter.to_string())
.use_markup(true)
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.width_request(size)
.height_request(size)
.build();
// Apply inline CSS via a provider on the display
let css_provider = gtk::CssProvider::new();
let unique_class = format!("letter-icon-{}", color_index);
let css = format!(
"label.{} {{ background: {}; color: {}; border-radius: 50%; min-width: {}px; min-height: {}px; font-size: {}px; font-weight: 700; }}",
unique_class, bg_color, fg_color, size, size, size * 4 / 10
// Use Pango markup to set the font size without a per-widget CssProvider.
let markup = format!(
"<span size='{}pt'>{}</span>",
font_size_pt,
glib::markup_escape_text(&letter_upper.to_string()),
);
css_provider.load_from_string(&css);
label.set_markup(&markup);
if let Some(display) = gtk::gdk::Display::default() {
gtk::style_context_add_provider_for_display(
&display,
&css_provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 1,
);
}
label.add_css_class(&unique_class);
label.add_css_class(&css_class);
label.upcast()
}