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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(©_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(©_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
305
src/ui/drop_dialog.rs
Normal 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)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, §ion2);
|
||||
|
||||
// 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, §ion3);
|
||||
|
||||
// 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, §ion4);
|
||||
|
||||
menu
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¬ify_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: >k::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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user