Card view: 200px cards with 72px icons, .title-3 names, version+size combined line, single priority badge, libadwaita .card class replacing custom .app-card CSS. List view: 48px rounded icons, .rich-list class, structured two-line subtitle (description + version/size), single priority badge. Detail view: restructured into ViewStack/ViewSwitcher with 4 tabs (Overview, System, Security, Storage). 96px hero banner with gradient background. Rows distributed logically across tabs. Context menu: right-click (GestureClick button 3) and long-press on cards and list rows. Menu items: Launch, Check for Updates, Scan for Vulnerabilities, Integrate/Remove Integration, Open Containing Folder, Copy Path. All backed by parameterized window actions. CSS: removed custom .app-card rules (replaced by .card), added .icon-rounded for list icons, .detail-banner gradient, and .detail-view-switcher positioning.
1886 lines
63 KiB
Markdown
1886 lines
63 KiB
Markdown
# UI/UX Overhaul Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Make Driftwood look and feel like a first-class GNOME app by overhauling cards, list rows, detail view, CSS, and adding right-click context menus.
|
|
|
|
**Architecture:** Replace custom `.app-card` CSS with libadwaita `.card` + `.activatable`. Use `adw::ViewStack`/`adw::ViewSwitcher` to split the detail view into 4 tabs. Add `gio::Menu`-driven context menus attached via `GtkGestureClick` on cards and rows. All changes are in 5 files: `style.css`, `app_card.rs`, `library_view.rs`, `detail_view.rs`, `window.rs`.
|
|
|
|
**Tech Stack:** Rust, gtk4-rs 0.11 (v4_18), libadwaita-rs 0.9 (v1_8), GTK CSS
|
|
|
|
---
|
|
|
|
### Task 1: CSS Foundation - Remove .app-card, Add New Classes
|
|
|
|
**Files:**
|
|
- Modify: `data/resources/style.css`
|
|
|
|
**Step 1: Update style.css**
|
|
|
|
Replace the entire `.app-card` block (lines 69-91), the dark mode `.app-card` block (lines 152-167), and the high contrast `.app-card` block (lines 171-177) with new minimal classes. Also update the `.detail-banner` block (lines 133-136).
|
|
|
|
Remove these CSS blocks entirely:
|
|
- `.app-card` (lines 69-85)
|
|
- `flowboxchild:focus-visible .app-card` (lines 87-91)
|
|
- Dark mode `.app-card` overrides (lines 153-166)
|
|
- High contrast `.app-card` and `flowboxchild:focus-visible .app-card` overrides (lines 171-177)
|
|
|
|
Add these new CSS rules in their place:
|
|
|
|
```css
|
|
/* ===== Card View (using libadwaita .card) ===== */
|
|
flowboxchild:focus-visible .card {
|
|
outline: 2px solid @accent_bg_color;
|
|
outline-offset: 3px;
|
|
}
|
|
|
|
/* Rounded icon clipping for list view */
|
|
.icon-rounded {
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Detail banner gradient wash */
|
|
.detail-banner {
|
|
padding: 18px 0;
|
|
background-image: linear-gradient(
|
|
to bottom,
|
|
alpha(@accent_bg_color, 0.08),
|
|
transparent
|
|
);
|
|
border-radius: 12px;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
/* Inline ViewSwitcher positioning */
|
|
.detail-view-switcher {
|
|
margin-top: 6px;
|
|
margin-bottom: 6px;
|
|
}
|
|
```
|
|
|
|
Update the high contrast section to reference `.card` instead of `.app-card`:
|
|
|
|
```css
|
|
/* In high contrast section: */
|
|
flowboxchild:focus-visible .card {
|
|
outline-width: 3px;
|
|
}
|
|
```
|
|
|
|
**Step 2: Build to verify CSS compiles**
|
|
|
|
Run: `cargo build 2>&1 | head -5`
|
|
Expected: Compiles (CSS is loaded at runtime, not compile-checked, but Rust code referencing removed classes will break in later tasks)
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add data/resources/style.css
|
|
git commit -m "style: replace .app-card CSS with libadwaita .card classes
|
|
|
|
Remove custom .app-card hover/active/dark/contrast rules. Add
|
|
.icon-rounded for list view icons, gradient .detail-banner, and
|
|
.detail-view-switcher for tabbed detail layout."
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Card View Overhaul
|
|
|
|
**Files:**
|
|
- Modify: `src/ui/app_card.rs`
|
|
|
|
**Step 1: Rewrite build_app_card function**
|
|
|
|
Replace the entire `build_app_card` function body (lines 10-124) with:
|
|
|
|
```rust
|
|
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)
|
|
.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);
|
|
let icon_widget = widgets::app_icon(
|
|
record.icon_path.as_deref(),
|
|
name,
|
|
72,
|
|
);
|
|
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));
|
|
|
|
let emblem = gtk::Image::from_icon_name("emblem-ok-symbolic");
|
|
emblem.set_pixel_size(16);
|
|
emblem.add_css_class("integration-emblem");
|
|
emblem.set_halign(gtk::Align::End);
|
|
emblem.set_valign(gtk::Align::End);
|
|
emblem.update_property(&[AccessibleProperty::Label("Integrated into desktop menu")]);
|
|
overlay.add_overlay(&emblem);
|
|
|
|
card.append(&overlay);
|
|
} else {
|
|
card.append(&icon_widget);
|
|
}
|
|
|
|
// App name - use .title-3 for more visual weight
|
|
let name_label = gtk::Label::builder()
|
|
.label(name)
|
|
.css_classes(["title-3"])
|
|
.ellipsize(gtk::pango::EllipsizeMode::End)
|
|
.max_width_chars(20)
|
|
.build();
|
|
|
|
// Version + size combined on one line
|
|
let version_text = record.app_version.as_deref().unwrap_or("");
|
|
let size_text = widgets::format_size(record.size_bytes);
|
|
let meta_text = if version_text.is_empty() {
|
|
size_text
|
|
} else {
|
|
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);
|
|
|
|
// Single most important badge (priority: Update > FUSE issue > Wayland issue)
|
|
if let Some(badge) = build_priority_badge(record) {
|
|
let badge_box = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Horizontal)
|
|
.halign(gtk::Align::Center)
|
|
.margin_top(4)
|
|
.build();
|
|
badge_box.append(&badge);
|
|
card.append(&badge_box);
|
|
}
|
|
|
|
let child = gtk::FlowBoxChild::builder()
|
|
.child(&card)
|
|
.build();
|
|
child.add_css_class("activatable");
|
|
|
|
// Accessible label for screen readers
|
|
let accessible_name = build_accessible_label(record);
|
|
child.update_property(&[AccessibleProperty::Label(&accessible_name)]);
|
|
|
|
child
|
|
}
|
|
```
|
|
|
|
**Step 2: Add the priority badge helper function**
|
|
|
|
Add this new function after `build_app_card`, before `build_accessible_label`:
|
|
|
|
```rust
|
|
/// Return the single most important badge for a card.
|
|
/// Priority: Update available > FUSE issue > Wayland issue.
|
|
fn build_priority_badge(record: &AppImageRecord) -> Option<gtk::Label> {
|
|
// 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) {
|
|
return Some(widgets::status_badge("Update", "info"));
|
|
}
|
|
}
|
|
|
|
// 2. FUSE issue
|
|
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()));
|
|
}
|
|
}
|
|
|
|
// 3. Wayland issue (not Native or Unknown)
|
|
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()));
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
```
|
|
|
|
**Step 3: Build and test**
|
|
|
|
Run: `cargo build 2>&1 | tail -3`
|
|
Expected: Compiles successfully
|
|
|
|
Run: `cargo test 2>&1 | tail -5`
|
|
Expected: All tests pass
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/ui/app_card.rs
|
|
git commit -m "ui: overhaul card view with larger icons and single badge
|
|
|
|
72px icons with .icon-dropshadow, .title-3 for app names, version+size
|
|
on one combined line, single priority badge (Update > FUSE > Wayland),
|
|
libadwaita .card class replacing custom .app-card, 200px card width."
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Library View - FlowBox and List Row Changes
|
|
|
|
**Files:**
|
|
- Modify: `src/ui/library_view.rs`
|
|
|
|
**Step 1: Update FlowBox settings**
|
|
|
|
In the `new()` method, change the FlowBox builder (around line 196) from:
|
|
|
|
```rust
|
|
let flow_box = gtk::FlowBox::builder()
|
|
.valign(gtk::Align::Start)
|
|
.selection_mode(gtk::SelectionMode::None)
|
|
.homogeneous(true)
|
|
.min_children_per_line(2)
|
|
.max_children_per_line(6)
|
|
.row_spacing(12)
|
|
.column_spacing(12)
|
|
.margin_top(12)
|
|
.margin_bottom(12)
|
|
.margin_start(12)
|
|
.margin_end(12)
|
|
.build();
|
|
```
|
|
|
|
to:
|
|
|
|
```rust
|
|
let flow_box = gtk::FlowBox::builder()
|
|
.valign(gtk::Align::Start)
|
|
.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)
|
|
.build();
|
|
```
|
|
|
|
Changes: max 4 columns (from 6), 14px spacing (from 12) to match GNOME HIG.
|
|
|
|
**Step 2: Add .rich-list to ListBox**
|
|
|
|
In the `new()` method, after the ListBox builder (around line 221), change:
|
|
|
|
```rust
|
|
list_box.add_css_class("boxed-list");
|
|
```
|
|
|
|
to:
|
|
|
|
```rust
|
|
list_box.add_css_class("boxed-list");
|
|
list_box.add_css_class("rich-list");
|
|
```
|
|
|
|
**Step 3: Rewrite build_list_row for structured subtitle and single badge**
|
|
|
|
Replace the entire `build_list_row` method (lines 459-543) with:
|
|
|
|
```rust
|
|
fn build_list_row(&self, record: &AppImageRecord) -> adw::ActionRow {
|
|
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
|
|
|
// Structured two-line subtitle:
|
|
// Line 1: Description snippet or file path (dimmed)
|
|
// Line 2: Version + size (caption, dimmed, numeric)
|
|
let line1 = if let Some(ref desc) = record.description {
|
|
if !desc.is_empty() {
|
|
let snippet: String = desc.chars().take(60).collect();
|
|
if snippet.len() < desc.len() {
|
|
format!("{}...", snippet.trim_end())
|
|
} else {
|
|
snippet
|
|
}
|
|
} else {
|
|
record.path.clone()
|
|
}
|
|
} else {
|
|
record.path.clone()
|
|
};
|
|
|
|
let mut meta_parts = Vec::new();
|
|
if let Some(ref ver) = record.app_version {
|
|
meta_parts.push(ver.clone());
|
|
}
|
|
meta_parts.push(widgets::format_size(record.size_bytes));
|
|
let line2 = meta_parts.join(" - ");
|
|
|
|
let subtitle = format!("{}\n{}", line1, line2);
|
|
|
|
let row = adw::ActionRow::builder()
|
|
.title(name)
|
|
.subtitle(&subtitle)
|
|
.subtitle_lines(2)
|
|
.activatable(true)
|
|
.build();
|
|
|
|
// Icon prefix (48x48 with rounded clipping and letter fallback)
|
|
let icon = widgets::app_icon(
|
|
record.icon_path.as_deref(),
|
|
name,
|
|
48,
|
|
);
|
|
icon.add_css_class("icon-rounded");
|
|
row.add_prefix(&icon);
|
|
|
|
// Single most important badge as suffix (same priority as cards)
|
|
if let Some(badge) = app_card::build_priority_badge(record) {
|
|
badge.set_valign(gtk::Align::Center);
|
|
row.add_suffix(&badge);
|
|
}
|
|
|
|
// Navigate arrow
|
|
let arrow = gtk::Image::from_icon_name("go-next-symbolic");
|
|
row.add_suffix(&arrow);
|
|
|
|
row
|
|
}
|
|
```
|
|
|
|
**Step 4: Make build_priority_badge public in app_card.rs**
|
|
|
|
In `src/ui/app_card.rs`, change:
|
|
|
|
```rust
|
|
fn build_priority_badge(record: &AppImageRecord) -> Option<gtk::Label> {
|
|
```
|
|
|
|
to:
|
|
|
|
```rust
|
|
pub fn build_priority_badge(record: &AppImageRecord) -> Option<gtk::Label> {
|
|
```
|
|
|
|
**Step 5: Build and test**
|
|
|
|
Run: `cargo build 2>&1 | tail -3`
|
|
Expected: Compiles successfully
|
|
|
|
Run: `cargo test 2>&1 | tail -5`
|
|
Expected: All tests pass
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/ui/library_view.rs src/ui/app_card.rs
|
|
git commit -m "ui: overhaul list view and FlowBox grid settings
|
|
|
|
FlowBox max 4 columns with 14px spacing. ListBox gets .rich-list class.
|
|
List rows use 48px rounded icons, structured two-line subtitle (desc +
|
|
version/size), single priority badge instead of all badges."
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Detail View - Tabbed Layout with ViewStack
|
|
|
|
**Files:**
|
|
- Modify: `src/ui/detail_view.rs`
|
|
|
|
This is the largest task. The detail view currently has one scrolling page with 3 PreferencesGroups. We restructure it into a hero banner + 4-tab ViewStack (Overview, System, Security, Storage).
|
|
|
|
**Step 1: Rewrite build_detail_page to use ViewStack**
|
|
|
|
Replace the `build_detail_page` function (lines 19-156) with:
|
|
|
|
```rust
|
|
pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::NavigationPage {
|
|
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
|
|
|
// 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 (not scrolled, always visible at top)
|
|
let banner = build_banner(record);
|
|
content.append(&banner);
|
|
|
|
// ViewSwitcher (tab bar) - inline style, between banner and tab 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");
|
|
if let Some(page) = view_stack.page(&overview_page) {
|
|
page.set_icon_name(Some("info-symbolic"));
|
|
}
|
|
|
|
let system_page = build_system_tab(record, db);
|
|
view_stack.add_titled(&system_page, Some("system"), "System");
|
|
if let Some(page) = view_stack.page(&system_page) {
|
|
page.set_icon_name(Some("system-run-symbolic"));
|
|
}
|
|
|
|
let security_page = build_security_tab(record, db);
|
|
view_stack.add_titled(&security_page, Some("security"), "Security");
|
|
if let Some(page) = view_stack.page(&security_page) {
|
|
page.set_icon_name(Some("security-medium-symbolic"));
|
|
}
|
|
|
|
let storage_page = build_storage_tab(record, db, &toast_overlay);
|
|
view_stack.add_titled(&storage_page, Some("storage"), "Storage");
|
|
if let Some(page) = view_stack.page(&storage_page) {
|
|
page.set_icon_name(Some("drive-harddisk-symbolic"));
|
|
}
|
|
|
|
// Scrollable area for tab content
|
|
let scrolled = gtk::ScrolledWindow::builder()
|
|
.child(&view_stack)
|
|
.vexpand(true)
|
|
.build();
|
|
content.append(&scrolled);
|
|
|
|
toast_overlay.set_child(Some(&content));
|
|
|
|
// Header bar with per-app actions
|
|
let header = adw::HeaderBar::new();
|
|
|
|
let launch_button = gtk::Button::builder()
|
|
.label("Launch")
|
|
.tooltip_text("Launch this AppImage")
|
|
.build();
|
|
launch_button.add_css_class("suggested-action");
|
|
launch_button.update_property(&[
|
|
gtk::accessible::Property::Label("Launch application"),
|
|
]);
|
|
let record_id = record.id;
|
|
let path = record.path.clone();
|
|
let db_launch = db.clone();
|
|
launch_button.connect_clicked(move |_| {
|
|
let appimage_path = std::path::Path::new(&path);
|
|
let result = launcher::launch_appimage(
|
|
&db_launch,
|
|
record_id,
|
|
appimage_path,
|
|
"gui_detail",
|
|
&[],
|
|
&[],
|
|
);
|
|
match result {
|
|
launcher::LaunchResult::Started { child, method } => {
|
|
let pid = child.id();
|
|
log::info!("Launched AppImage: {} (PID: {}, method: {})", path, pid, method.as_str());
|
|
|
|
let db_wayland = db_launch.clone();
|
|
let path_clone = path.clone();
|
|
glib::spawn_future_local(async move {
|
|
glib::timeout_future(std::time::Duration::from_secs(3)).await;
|
|
|
|
let analysis_result = gio::spawn_blocking(move || {
|
|
wayland::analyze_running_process(pid)
|
|
}).await;
|
|
|
|
match analysis_result {
|
|
Ok(Ok(analysis)) => {
|
|
let status_label = analysis.status_label();
|
|
let status_str = analysis.as_status_str();
|
|
log::info!(
|
|
"Runtime Wayland analysis for {} (PID {}): {} (wayland_socket={}, x11={}, env_vars={})",
|
|
path_clone, analysis.pid, status_label,
|
|
analysis.has_wayland_socket,
|
|
analysis.has_x11_connection,
|
|
analysis.env_vars.len(),
|
|
);
|
|
db_wayland.update_runtime_wayland_status(
|
|
record_id, status_str,
|
|
).ok();
|
|
}
|
|
Ok(Err(e)) => {
|
|
log::debug!("Runtime analysis failed for PID {}: {}", pid, e);
|
|
}
|
|
Err(_) => {
|
|
log::debug!("Runtime analysis task failed for PID {}", pid);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
launcher::LaunchResult::Failed(msg) => {
|
|
log::error!("Failed to launch: {}", msg);
|
|
}
|
|
}
|
|
});
|
|
header.pack_end(&launch_button);
|
|
|
|
let update_button = gtk::Button::builder()
|
|
.icon_name("software-update-available-symbolic")
|
|
.tooltip_text("Check for updates")
|
|
.build();
|
|
update_button.update_property(&[
|
|
gtk::accessible::Property::Label("Check for updates"),
|
|
]);
|
|
let record_for_update = record.clone();
|
|
let db_update = db.clone();
|
|
update_button.connect_clicked(move |btn| {
|
|
update_dialog::show_update_dialog(btn, &record_for_update, &db_update);
|
|
});
|
|
header.pack_end(&update_button);
|
|
|
|
let toolbar = adw::ToolbarView::new();
|
|
toolbar.add_top_bar(&header);
|
|
toolbar.set_content(Some(&toast_overlay));
|
|
|
|
adw::NavigationPage::builder()
|
|
.title(name)
|
|
.tag("detail")
|
|
.child(&toolbar)
|
|
.build()
|
|
}
|
|
```
|
|
|
|
**Step 2: Update the banner to use 96px icon**
|
|
|
|
Replace the `build_banner` function (lines 158-247) with:
|
|
|
|
```rust
|
|
fn build_banner(record: &AppImageRecord) -> gtk::Box {
|
|
let banner = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Horizontal)
|
|
.spacing(16)
|
|
.margin_start(18)
|
|
.margin_end(18)
|
|
.build();
|
|
banner.add_css_class("detail-banner");
|
|
banner.set_accessible_role(gtk::AccessibleRole::Banner);
|
|
|
|
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
|
|
|
// Large icon (96x96) 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");
|
|
banner.append(&icon);
|
|
|
|
// Text column
|
|
let text_col = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(4)
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
|
|
let name_label = gtk::Label::builder()
|
|
.label(name)
|
|
.css_classes(["title-1"])
|
|
.halign(gtk::Align::Start)
|
|
.build();
|
|
text_col.append(&name_label);
|
|
|
|
// Version + architecture inline
|
|
let meta_parts: Vec<String> = [
|
|
record.app_version.as_deref().map(|v| v.to_string()),
|
|
record.architecture.as_deref().map(|a| a.to_string()),
|
|
]
|
|
.iter()
|
|
.filter_map(|p| p.clone())
|
|
.collect();
|
|
|
|
if !meta_parts.is_empty() {
|
|
let meta_label = gtk::Label::builder()
|
|
.label(&meta_parts.join(" - "))
|
|
.css_classes(["dimmed"])
|
|
.halign(gtk::Align::Start)
|
|
.build();
|
|
text_col.append(&meta_label);
|
|
}
|
|
|
|
// Description
|
|
if let Some(ref desc) = record.description {
|
|
if !desc.is_empty() {
|
|
let desc_label = gtk::Label::builder()
|
|
.label(desc)
|
|
.css_classes(["body"])
|
|
.halign(gtk::Align::Start)
|
|
.wrap(true)
|
|
.xalign(0.0)
|
|
.build();
|
|
text_col.append(&desc_label);
|
|
}
|
|
}
|
|
|
|
// Key status badges inline
|
|
let badge_box = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Horizontal)
|
|
.spacing(6)
|
|
.margin_top(4)
|
|
.build();
|
|
|
|
if record.integrated {
|
|
badge_box.append(&widgets::status_badge("Integrated", "success"));
|
|
}
|
|
|
|
if let Some(ref ws) = record.wayland_status {
|
|
let status = WaylandStatus::from_str(ws);
|
|
if status != WaylandStatus::Unknown {
|
|
badge_box.append(&widgets::status_badge(status.label(), status.badge_class()));
|
|
}
|
|
}
|
|
|
|
if let (Some(ref latest), Some(ref current)) = (&record.latest_version, &record.app_version) {
|
|
if crate::core::updater::version_is_newer(latest, current) {
|
|
badge_box.append(&widgets::status_badge("Update available", "info"));
|
|
}
|
|
}
|
|
|
|
text_col.append(&badge_box);
|
|
banner.append(&text_col);
|
|
banner
|
|
}
|
|
```
|
|
|
|
**Step 3: Create the 4 tab builder functions**
|
|
|
|
Replace `build_system_integration_group` (lines 249-471), `build_updates_usage_group` (lines 493-580), and `build_security_storage_group` (lines 582-878) with these 4 new functions. Keep the `wayland_description` and `fuse_description` helper functions.
|
|
|
|
**Tab 1 - Overview:**
|
|
|
|
```rust
|
|
/// Tab 1: Overview - most commonly needed info at a glance
|
|
fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|
let tab = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(24)
|
|
.margin_top(18)
|
|
.margin_bottom(24)
|
|
.margin_start(18)
|
|
.margin_end(18)
|
|
.build();
|
|
|
|
let clamp = adw::Clamp::builder()
|
|
.maximum_size(800)
|
|
.tightening_threshold(600)
|
|
.build();
|
|
|
|
let inner = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(24)
|
|
.build();
|
|
|
|
// Updates section
|
|
let updates_group = adw::PreferencesGroup::builder()
|
|
.title("Updates")
|
|
.build();
|
|
|
|
if let Some(ref update_type) = record.update_type {
|
|
let display_label = updater::parse_update_info(update_type)
|
|
.map(|ut| ut.type_label_display())
|
|
.unwrap_or("Unknown format");
|
|
let row = adw::ActionRow::builder()
|
|
.title("Update method")
|
|
.subtitle(display_label)
|
|
.build();
|
|
updates_group.add(&row);
|
|
} else {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Update method")
|
|
.subtitle("This app cannot check for updates automatically")
|
|
.build();
|
|
let badge = widgets::status_badge("None", "neutral");
|
|
badge.set_valign(gtk::Align::Center);
|
|
row.add_suffix(&badge);
|
|
updates_group.add(&row);
|
|
}
|
|
|
|
if let Some(ref latest) = record.latest_version {
|
|
let is_newer = record
|
|
.app_version
|
|
.as_deref()
|
|
.map(|current| crate::core::updater::version_is_newer(latest, current))
|
|
.unwrap_or(true);
|
|
|
|
if is_newer {
|
|
let subtitle = format!(
|
|
"{} -> {}",
|
|
record.app_version.as_deref().unwrap_or("unknown"),
|
|
latest
|
|
);
|
|
let row = adw::ActionRow::builder()
|
|
.title("Update available")
|
|
.subtitle(&subtitle)
|
|
.build();
|
|
let badge = widgets::status_badge("Update", "info");
|
|
badge.set_valign(gtk::Align::Center);
|
|
row.add_suffix(&badge);
|
|
updates_group.add(&row);
|
|
} else {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Status")
|
|
.subtitle("Up to date")
|
|
.build();
|
|
let badge = widgets::status_badge("Latest", "success");
|
|
badge.set_valign(gtk::Align::Center);
|
|
row.add_suffix(&badge);
|
|
updates_group.add(&row);
|
|
}
|
|
}
|
|
|
|
if let Some(ref checked) = record.update_checked {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Last checked")
|
|
.subtitle(checked)
|
|
.build();
|
|
updates_group.add(&row);
|
|
}
|
|
inner.append(&updates_group);
|
|
|
|
// Usage section
|
|
let usage_group = adw::PreferencesGroup::builder()
|
|
.title("Usage")
|
|
.build();
|
|
|
|
let stats = launcher::get_launch_stats(db, record.id);
|
|
|
|
let launches_row = adw::ActionRow::builder()
|
|
.title("Total launches")
|
|
.subtitle(&stats.total_launches.to_string())
|
|
.build();
|
|
usage_group.add(&launches_row);
|
|
|
|
if let Some(ref last) = stats.last_launched {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Last launched")
|
|
.subtitle(last)
|
|
.build();
|
|
usage_group.add(&row);
|
|
}
|
|
inner.append(&usage_group);
|
|
|
|
// File info section
|
|
let info_group = adw::PreferencesGroup::builder()
|
|
.title("File Information")
|
|
.build();
|
|
|
|
let type_str = match record.appimage_type {
|
|
Some(1) => "Type 1",
|
|
Some(2) => "Type 2",
|
|
_ => "Unknown",
|
|
};
|
|
let type_row = adw::ActionRow::builder()
|
|
.title("AppImage type")
|
|
.subtitle(type_str)
|
|
.tooltip_text("Type 1 uses ISO9660, Type 2 uses SquashFS")
|
|
.build();
|
|
info_group.add(&type_row);
|
|
|
|
let exec_row = adw::ActionRow::builder()
|
|
.title("Executable")
|
|
.subtitle(if record.is_executable { "Yes" } else { "No" })
|
|
.build();
|
|
info_group.add(&exec_row);
|
|
|
|
let seen_row = adw::ActionRow::builder()
|
|
.title("First seen")
|
|
.subtitle(&record.first_seen)
|
|
.build();
|
|
info_group.add(&seen_row);
|
|
|
|
let scanned_row = adw::ActionRow::builder()
|
|
.title("Last scanned")
|
|
.subtitle(&record.last_scanned)
|
|
.build();
|
|
info_group.add(&scanned_row);
|
|
|
|
if let Some(ref notes) = record.notes {
|
|
if !notes.is_empty() {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Notes")
|
|
.subtitle(notes)
|
|
.build();
|
|
info_group.add(&row);
|
|
}
|
|
}
|
|
inner.append(&info_group);
|
|
|
|
clamp.set_child(Some(&inner));
|
|
tab.append(&clamp);
|
|
tab
|
|
}
|
|
```
|
|
|
|
**Tab 2 - System:**
|
|
|
|
```rust
|
|
/// Tab 2: System - integration, compatibility, sandboxing
|
|
fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
|
let tab = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(24)
|
|
.margin_top(18)
|
|
.margin_bottom(24)
|
|
.margin_start(18)
|
|
.margin_end(18)
|
|
.build();
|
|
|
|
let clamp = adw::Clamp::builder()
|
|
.maximum_size(800)
|
|
.tightening_threshold(600)
|
|
.build();
|
|
|
|
let inner = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(24)
|
|
.build();
|
|
|
|
// Desktop Integration group
|
|
let integration_group = adw::PreferencesGroup::builder()
|
|
.title("Desktop Integration")
|
|
.description("Add this app to your application menu")
|
|
.build();
|
|
|
|
let switch_row = adw::SwitchRow::builder()
|
|
.title("Add to application menu")
|
|
.subtitle("Creates a .desktop file and installs the icon")
|
|
.active(record.integrated)
|
|
.build();
|
|
|
|
let record_id = record.id;
|
|
let record_clone = record.clone();
|
|
let db_ref = db.clone();
|
|
let db_dialog = db.clone();
|
|
let record_dialog = record.clone();
|
|
let suppress = Rc::new(Cell::new(false));
|
|
let suppress_ref = suppress.clone();
|
|
switch_row.connect_active_notify(move |row| {
|
|
if suppress_ref.get() {
|
|
return;
|
|
}
|
|
if row.is_active() {
|
|
let row_clone = row.clone();
|
|
let suppress_inner = suppress_ref.clone();
|
|
integration_dialog::show_integration_dialog(
|
|
row,
|
|
&record_dialog,
|
|
&db_dialog,
|
|
move |success| {
|
|
if !success {
|
|
suppress_inner.set(true);
|
|
row_clone.set_active(false);
|
|
suppress_inner.set(false);
|
|
}
|
|
},
|
|
);
|
|
} else {
|
|
integrator::remove_integration(&record_clone).ok();
|
|
db_ref.set_integrated(record_id, false, None).ok();
|
|
}
|
|
});
|
|
integration_group.add(&switch_row);
|
|
|
|
if record.integrated {
|
|
if let Some(ref desktop_file) = record.desktop_file {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Desktop file")
|
|
.subtitle(desktop_file)
|
|
.subtitle_selectable(true)
|
|
.build();
|
|
row.add_css_class("property");
|
|
integration_group.add(&row);
|
|
}
|
|
}
|
|
inner.append(&integration_group);
|
|
|
|
// Runtime Compatibility group
|
|
let compat_group = adw::PreferencesGroup::builder()
|
|
.title("Runtime Compatibility")
|
|
.description("Wayland support and FUSE status")
|
|
.build();
|
|
|
|
let wayland_status = record
|
|
.wayland_status
|
|
.as_deref()
|
|
.map(WaylandStatus::from_str)
|
|
.unwrap_or(WaylandStatus::Unknown);
|
|
|
|
let wayland_row = adw::ActionRow::builder()
|
|
.title("Wayland")
|
|
.subtitle(wayland_description(&wayland_status))
|
|
.tooltip_text("Display protocol for Linux desktops")
|
|
.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);
|
|
|
|
let analyze_row = adw::ActionRow::builder()
|
|
.title("Analyze toolkit")
|
|
.subtitle("Inspect bundled libraries to detect UI toolkit")
|
|
.activatable(true)
|
|
.build();
|
|
let analyze_icon = gtk::Image::from_icon_name("system-search-symbolic");
|
|
analyze_icon.set_valign(gtk::Align::Center);
|
|
analyze_row.add_suffix(&analyze_icon);
|
|
|
|
let record_path_wayland = record.path.clone();
|
|
analyze_row.connect_activated(move |row| {
|
|
row.set_sensitive(false);
|
|
row.update_state(&[gtk::accessible::State::Busy(true)]);
|
|
row.set_subtitle("Analyzing...");
|
|
let row_clone = row.clone();
|
|
let path = record_path_wayland.clone();
|
|
glib::spawn_future_local(async move {
|
|
let result = gio::spawn_blocking(move || {
|
|
let appimage_path = std::path::Path::new(&path);
|
|
wayland::analyze_appimage(appimage_path)
|
|
})
|
|
.await;
|
|
|
|
row_clone.set_sensitive(true);
|
|
row_clone.update_state(&[gtk::accessible::State::Busy(false)]);
|
|
match result {
|
|
Ok(analysis) => {
|
|
let toolkit_label = analysis.toolkit.label();
|
|
let lib_count = analysis.libraries_found.len();
|
|
row_clone.set_subtitle(&format!(
|
|
"Toolkit: {} ({} libraries scanned)",
|
|
toolkit_label, lib_count,
|
|
));
|
|
}
|
|
Err(_) => {
|
|
row_clone.set_subtitle("Analysis failed");
|
|
}
|
|
}
|
|
});
|
|
});
|
|
compat_group.add(&analyze_row);
|
|
|
|
if let Some(ref runtime_status) = record.runtime_wayland_status {
|
|
let runtime_row = adw::ActionRow::builder()
|
|
.title("Runtime display protocol")
|
|
.subtitle(runtime_status)
|
|
.build();
|
|
if let Some(ref checked) = record.runtime_wayland_checked {
|
|
let info = gtk::Label::builder()
|
|
.label(checked)
|
|
.css_classes(["dimmed", "caption"])
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
runtime_row.add_suffix(&info);
|
|
}
|
|
compat_group.add(&runtime_row);
|
|
}
|
|
|
|
let fuse_system = fuse::detect_system_fuse();
|
|
let fuse_status = record
|
|
.fuse_status
|
|
.as_deref()
|
|
.map(FuseStatus::from_str)
|
|
.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")
|
|
.build();
|
|
let fuse_badge = widgets::status_badge_with_icon(
|
|
if fuse_status.is_functional() { "emblem-ok-symbolic" } else { "dialog-warning-symbolic" },
|
|
fuse_status.label(),
|
|
fuse_status.badge_class(),
|
|
);
|
|
fuse_badge.set_valign(gtk::Align::Center);
|
|
fuse_row.add_suffix(&fuse_badge);
|
|
compat_group.add(&fuse_row);
|
|
|
|
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())
|
|
.build();
|
|
let launch_badge = widgets::status_badge(
|
|
fuse_system.status.as_str(),
|
|
app_fuse_status.badge_class(),
|
|
);
|
|
launch_badge.set_valign(gtk::Align::Center);
|
|
launch_method_row.add_suffix(&launch_badge);
|
|
compat_group.add(&launch_method_row);
|
|
inner.append(&compat_group);
|
|
|
|
// Sandboxing group
|
|
let sandbox_group = adw::PreferencesGroup::builder()
|
|
.title("Sandboxing")
|
|
.description("Isolate this app with Firejail")
|
|
.build();
|
|
|
|
let current_mode = record
|
|
.sandbox_mode
|
|
.as_deref()
|
|
.map(SandboxMode::from_str)
|
|
.unwrap_or(SandboxMode::None);
|
|
|
|
let firejail_available = launcher::has_firejail();
|
|
|
|
let sandbox_subtitle = if firejail_available {
|
|
format!("Current mode: {}", current_mode.label())
|
|
} else {
|
|
"Firejail is not installed".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)
|
|
.build();
|
|
|
|
let record_id = record.id;
|
|
let db_ref = db.clone();
|
|
firejail_row.connect_active_notify(move |row| {
|
|
let mode = if row.is_active() {
|
|
SandboxMode::Firejail
|
|
} else {
|
|
SandboxMode::None
|
|
};
|
|
if let Err(e) = db_ref.update_sandbox_mode(record_id, Some(mode.as_str())) {
|
|
log::warn!("Failed to update sandbox mode: {}", e);
|
|
}
|
|
});
|
|
sandbox_group.add(&firejail_row);
|
|
|
|
if !firejail_available {
|
|
let info_row = adw::ActionRow::builder()
|
|
.title("Install Firejail")
|
|
.subtitle("sudo apt install firejail")
|
|
.build();
|
|
let badge = widgets::status_badge("Missing", "warning");
|
|
badge.set_valign(gtk::Align::Center);
|
|
info_row.add_suffix(&badge);
|
|
sandbox_group.add(&info_row);
|
|
}
|
|
inner.append(&sandbox_group);
|
|
|
|
clamp.set_child(Some(&inner));
|
|
tab.append(&clamp);
|
|
tab
|
|
}
|
|
```
|
|
|
|
**Tab 3 - Security:**
|
|
|
|
```rust
|
|
/// 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)
|
|
.spacing(24)
|
|
.margin_top(18)
|
|
.margin_bottom(24)
|
|
.margin_start(18)
|
|
.margin_end(18)
|
|
.build();
|
|
|
|
let clamp = adw::Clamp::builder()
|
|
.maximum_size(800)
|
|
.tightening_threshold(600)
|
|
.build();
|
|
|
|
let inner = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(24)
|
|
.build();
|
|
|
|
let group = adw::PreferencesGroup::builder()
|
|
.title("Vulnerability Scanning")
|
|
.description("Check bundled libraries for known CVEs")
|
|
.build();
|
|
|
|
let libs = db.get_bundled_libraries(record.id).unwrap_or_default();
|
|
let summary = db.get_cve_summary(record.id).unwrap_or_default();
|
|
|
|
if libs.is_empty() {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Security scan")
|
|
.subtitle("Not yet scanned for vulnerabilities")
|
|
.build();
|
|
let badge = widgets::status_badge("Not scanned", "neutral");
|
|
badge.set_valign(gtk::Align::Center);
|
|
row.add_suffix(&badge);
|
|
group.add(&row);
|
|
} else {
|
|
let lib_row = adw::ActionRow::builder()
|
|
.title("Bundled libraries")
|
|
.subtitle(&libs.len().to_string())
|
|
.build();
|
|
group.add(&lib_row);
|
|
|
|
if summary.total() == 0 {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Vulnerabilities")
|
|
.subtitle("No known vulnerabilities")
|
|
.build();
|
|
let badge = widgets::status_badge("Clean", "success");
|
|
badge.set_valign(gtk::Align::Center);
|
|
row.add_suffix(&badge);
|
|
group.add(&row);
|
|
} else {
|
|
let row = adw::ActionRow::builder()
|
|
.title("Vulnerabilities")
|
|
.subtitle(&format!("{} found", summary.total()))
|
|
.build();
|
|
let badge = widgets::status_badge(summary.max_severity(), summary.badge_class());
|
|
badge.set_valign(gtk::Align::Center);
|
|
row.add_suffix(&badge);
|
|
group.add(&row);
|
|
}
|
|
}
|
|
|
|
// Scan button
|
|
let scan_row = adw::ActionRow::builder()
|
|
.title("Scan this AppImage")
|
|
.subtitle("Check bundled libraries for known CVEs")
|
|
.activatable(true)
|
|
.build();
|
|
let scan_icon = gtk::Image::from_icon_name("security-medium-symbolic");
|
|
scan_icon.set_valign(gtk::Align::Center);
|
|
scan_row.add_suffix(&scan_icon);
|
|
|
|
let record_id = record.id;
|
|
let record_path = record.path.clone();
|
|
scan_row.connect_activated(move |row| {
|
|
row.set_sensitive(false);
|
|
row.update_state(&[gtk::accessible::State::Busy(true)]);
|
|
row.set_subtitle("Scanning...");
|
|
let row_clone = row.clone();
|
|
let path = record_path.clone();
|
|
glib::spawn_future_local(async move {
|
|
let result = gio::spawn_blocking(move || {
|
|
let bg_db = Database::open().expect("Failed to open database");
|
|
let appimage_path = std::path::Path::new(&path);
|
|
security::scan_and_store(&bg_db, record_id, appimage_path)
|
|
})
|
|
.await;
|
|
|
|
row_clone.set_sensitive(true);
|
|
row_clone.update_state(&[gtk::accessible::State::Busy(false)]);
|
|
match result {
|
|
Ok(scan_result) => {
|
|
let total = scan_result.total_cves();
|
|
if total == 0 {
|
|
row_clone.set_subtitle("No vulnerabilities found");
|
|
} else {
|
|
row_clone.set_subtitle(&format!(
|
|
"Found {} CVE{}", total, if total == 1 { "" } else { "s" }
|
|
));
|
|
}
|
|
}
|
|
Err(_) => {
|
|
row_clone.set_subtitle("Scan failed");
|
|
}
|
|
}
|
|
});
|
|
});
|
|
group.add(&scan_row);
|
|
inner.append(&group);
|
|
|
|
// Integrity group
|
|
let integrity_group = adw::PreferencesGroup::builder()
|
|
.title("Integrity")
|
|
.build();
|
|
|
|
if let Some(ref hash) = record.sha256 {
|
|
let hash_row = adw::ActionRow::builder()
|
|
.title("SHA256 checksum")
|
|
.subtitle(hash)
|
|
.subtitle_selectable(true)
|
|
.tooltip_text("Cryptographic hash for verifying file integrity")
|
|
.build();
|
|
hash_row.add_css_class("property");
|
|
integrity_group.add(&hash_row);
|
|
}
|
|
inner.append(&integrity_group);
|
|
|
|
clamp.set_child(Some(&inner));
|
|
tab.append(&clamp);
|
|
tab
|
|
}
|
|
```
|
|
|
|
**Tab 4 - Storage:**
|
|
|
|
```rust
|
|
/// Tab 4: Storage - disk usage and data discovery
|
|
fn build_storage_tab(
|
|
record: &AppImageRecord,
|
|
db: &Rc<Database>,
|
|
toast_overlay: &adw::ToastOverlay,
|
|
) -> gtk::Box {
|
|
let tab = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(24)
|
|
.margin_top(18)
|
|
.margin_bottom(24)
|
|
.margin_start(18)
|
|
.margin_end(18)
|
|
.build();
|
|
|
|
let clamp = adw::Clamp::builder()
|
|
.maximum_size(800)
|
|
.tightening_threshold(600)
|
|
.build();
|
|
|
|
let inner = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(24)
|
|
.build();
|
|
|
|
// Disk usage group
|
|
let size_group = adw::PreferencesGroup::builder()
|
|
.title("Disk Usage")
|
|
.build();
|
|
|
|
let fp = footprint::get_footprint(db, record.id, record.size_bytes as u64);
|
|
|
|
let appimage_row = adw::ActionRow::builder()
|
|
.title("AppImage file size")
|
|
.subtitle(&widgets::format_size(record.size_bytes))
|
|
.build();
|
|
size_group.add(&appimage_row);
|
|
|
|
if !fp.paths.is_empty() {
|
|
let data_total = fp.data_total();
|
|
if data_total > 0 {
|
|
let total_row = adw::ActionRow::builder()
|
|
.title("Total disk footprint")
|
|
.subtitle(&format!(
|
|
"{} (AppImage) + {} (data) = {}",
|
|
widgets::format_size(record.size_bytes),
|
|
widgets::format_size(data_total as i64),
|
|
widgets::format_size(fp.total_size() as i64),
|
|
))
|
|
.build();
|
|
size_group.add(&total_row);
|
|
}
|
|
}
|
|
inner.append(&size_group);
|
|
|
|
// Data paths group
|
|
let paths_group = adw::PreferencesGroup::builder()
|
|
.title("Data Paths")
|
|
.description("Config, data, and cache directories for this app")
|
|
.build();
|
|
|
|
// Discover button
|
|
let discover_row = adw::ActionRow::builder()
|
|
.title("Discover data paths")
|
|
.subtitle("Search for config, data, and cache directories")
|
|
.activatable(true)
|
|
.build();
|
|
let discover_icon = gtk::Image::from_icon_name("folder-saved-search-symbolic");
|
|
discover_icon.set_valign(gtk::Align::Center);
|
|
discover_row.add_suffix(&discover_icon);
|
|
|
|
let record_clone = record.clone();
|
|
let record_id = record.id;
|
|
discover_row.connect_activated(move |row| {
|
|
row.set_sensitive(false);
|
|
row.set_subtitle("Discovering...");
|
|
let row_clone = row.clone();
|
|
let rec = record_clone.clone();
|
|
glib::spawn_future_local(async move {
|
|
let result = gio::spawn_blocking(move || {
|
|
let bg_db = Database::open().expect("Failed to open database");
|
|
footprint::discover_and_store(&bg_db, record_id, &rec);
|
|
footprint::get_footprint(&bg_db, record_id, rec.size_bytes as u64)
|
|
})
|
|
.await;
|
|
|
|
row_clone.set_sensitive(true);
|
|
match result {
|
|
Ok(fp) => {
|
|
let count = fp.paths.len();
|
|
if count == 0 {
|
|
row_clone.set_subtitle("No associated paths found");
|
|
} else {
|
|
row_clone.set_subtitle(&format!(
|
|
"Found {} path{} ({})",
|
|
count,
|
|
if count == 1 { "" } else { "s" },
|
|
widgets::format_size(fp.data_total() as i64),
|
|
));
|
|
}
|
|
}
|
|
Err(_) => {
|
|
row_clone.set_subtitle("Discovery failed");
|
|
}
|
|
}
|
|
});
|
|
});
|
|
paths_group.add(&discover_row);
|
|
|
|
// Individual discovered paths
|
|
for dp in &fp.paths {
|
|
if dp.exists {
|
|
let row = adw::ActionRow::builder()
|
|
.title(dp.path_type.label())
|
|
.subtitle(&*dp.path.to_string_lossy())
|
|
.subtitle_selectable(true)
|
|
.build();
|
|
let icon = gtk::Image::from_icon_name(dp.path_type.icon_name());
|
|
icon.set_pixel_size(16);
|
|
row.add_prefix(&icon);
|
|
let conf_badge = widgets::status_badge(
|
|
dp.confidence.as_str(),
|
|
dp.confidence.badge_class(),
|
|
);
|
|
conf_badge.set_valign(gtk::Align::Center);
|
|
row.add_suffix(&conf_badge);
|
|
let size_label = gtk::Label::builder()
|
|
.label(&widgets::format_size(dp.size_bytes as i64))
|
|
.css_classes(["dimmed", "caption"])
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
row.add_suffix(&size_label);
|
|
paths_group.add(&row);
|
|
}
|
|
}
|
|
inner.append(&paths_group);
|
|
|
|
// File location group
|
|
let location_group = adw::PreferencesGroup::builder()
|
|
.title("File Location")
|
|
.build();
|
|
|
|
let path_row = adw::ActionRow::builder()
|
|
.title("Path")
|
|
.subtitle(&record.path)
|
|
.subtitle_selectable(true)
|
|
.build();
|
|
path_row.add_css_class("property");
|
|
let copy_path_btn = widgets::copy_button(&record.path, Some(toast_overlay));
|
|
copy_path_btn.set_valign(gtk::Align::Center);
|
|
path_row.add_suffix(©_path_btn);
|
|
|
|
let folder_path = std::path::Path::new(&record.path)
|
|
.parent()
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
.unwrap_or_default();
|
|
if !folder_path.is_empty() {
|
|
let open_folder_btn = gtk::Button::builder()
|
|
.icon_name("folder-open-symbolic")
|
|
.tooltip_text("Open containing folder")
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
open_folder_btn.add_css_class("flat");
|
|
open_folder_btn.update_property(&[
|
|
gtk::accessible::Property::Label("Open containing folder"),
|
|
]);
|
|
let folder = folder_path.clone();
|
|
open_folder_btn.connect_clicked(move |_| {
|
|
let file = gio::File::for_path(&folder);
|
|
let launcher = gtk::FileLauncher::new(Some(&file));
|
|
launcher.open_containing_folder(gtk::Window::NONE, None::<&gio::Cancellable>, |_| {});
|
|
});
|
|
path_row.add_suffix(&open_folder_btn);
|
|
}
|
|
location_group.add(&path_row);
|
|
inner.append(&location_group);
|
|
|
|
clamp.set_child(Some(&inner));
|
|
tab.append(&clamp);
|
|
tab
|
|
}
|
|
```
|
|
|
|
**Step 4: Build and test**
|
|
|
|
Run: `cargo build 2>&1 | tail -5`
|
|
Expected: Compiles successfully
|
|
|
|
Run: `cargo test 2>&1 | tail -5`
|
|
Expected: All tests pass
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/ui/detail_view.rs
|
|
git commit -m "ui: restructure detail view into tabbed layout
|
|
|
|
Replace single scrolling page with ViewStack/ViewSwitcher tabs:
|
|
Overview (updates, usage, file info), System (integration, compatibility,
|
|
sandboxing), Security (CVE scanning, integrity), Storage (disk usage,
|
|
data paths, file location). 96px hero banner with gradient background."
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Right-Click Context Menu - Window Actions
|
|
|
|
**Files:**
|
|
- Modify: `src/window.rs`
|
|
|
|
**Step 1: Add parameterized context menu actions**
|
|
|
|
In `setup_window_actions()`, after the existing action entries and before the `self.add_action_entries(...)` call (around line 361), add new parameterized actions. Since `ActionEntry` doesn't support parameterized actions, we create them separately using `gio::SimpleAction`.
|
|
|
|
Add the following code right before the `self.add_action_entries([...])` call:
|
|
|
|
```rust
|
|
// --- Context menu actions (parameterized with record ID) ---
|
|
let param_type = Some(glib::VariantTy::INT64);
|
|
|
|
// Launch action
|
|
let launch_action = gio::SimpleAction::new("launch-appimage", param_type);
|
|
{
|
|
let window_weak = self.downgrade();
|
|
launch_action.connect_activate(move |_, param| {
|
|
let Some(window) = window_weak.upgrade() else { return };
|
|
let Some(record_id) = param.and_then(|p| p.get::<i64>()) else { return };
|
|
let db = window.database().clone();
|
|
if let Ok(Some(record)) = db.get_appimage_by_id(record_id) {
|
|
let appimage_path = std::path::Path::new(&record.path);
|
|
match launcher::launch_appimage(&db, record_id, appimage_path, "gui_context", &[], &[]) {
|
|
launcher::LaunchResult::Started { child, method } => {
|
|
log::info!("Context menu launched: {} (PID: {}, method: {})", record.path, child.id(), method.as_str());
|
|
}
|
|
launcher::LaunchResult::Failed(msg) => {
|
|
log::error!("Failed to launch: {}", msg);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
self.add_action(&launch_action);
|
|
|
|
// Check for updates action (per-app)
|
|
let check_update_action = gio::SimpleAction::new("check-update", param_type);
|
|
{
|
|
let window_weak = self.downgrade();
|
|
check_update_action.connect_activate(move |_, param| {
|
|
let Some(window) = window_weak.upgrade() else { return };
|
|
let Some(record_id) = param.and_then(|p| p.get::<i64>()) else { return };
|
|
let db = window.database().clone();
|
|
let toast_overlay = window.imp().toast_overlay.get().unwrap().clone();
|
|
if let Ok(Some(record)) = db.get_appimage_by_id(record_id) {
|
|
glib::spawn_future_local(async move {
|
|
let path = record.path.clone();
|
|
let result = gio::spawn_blocking(move || {
|
|
let bg_db = Database::open().expect("DB open failed");
|
|
update_dialog::check_single_update(&bg_db, &record)
|
|
}).await;
|
|
match result {
|
|
Ok(true) => toast_overlay.add_toast(adw::Toast::new("Update available!")),
|
|
Ok(false) => toast_overlay.add_toast(adw::Toast::new("Already up to date")),
|
|
Err(_) => toast_overlay.add_toast(adw::Toast::new("Update check failed")),
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
self.add_action(&check_update_action);
|
|
|
|
// Scan for vulnerabilities (per-app)
|
|
let scan_security_action = gio::SimpleAction::new("scan-security", param_type);
|
|
{
|
|
let window_weak = self.downgrade();
|
|
scan_security_action.connect_activate(move |_, param| {
|
|
let Some(window) = window_weak.upgrade() else { return };
|
|
let Some(record_id) = param.and_then(|p| p.get::<i64>()) else { return };
|
|
let db = window.database().clone();
|
|
let toast_overlay = window.imp().toast_overlay.get().unwrap().clone();
|
|
if let Ok(Some(record)) = db.get_appimage_by_id(record_id) {
|
|
glib::spawn_future_local(async move {
|
|
let path = record.path.clone();
|
|
let result = gio::spawn_blocking(move || {
|
|
let bg_db = Database::open().expect("DB open failed");
|
|
let appimage_path = std::path::Path::new(&path);
|
|
security::scan_and_store(&bg_db, record_id, appimage_path)
|
|
}).await;
|
|
match result {
|
|
Ok(scan_result) => {
|
|
let total = scan_result.total_cves();
|
|
if total == 0 {
|
|
toast_overlay.add_toast(adw::Toast::new("No vulnerabilities found"));
|
|
} else {
|
|
let msg = format!("Found {} CVE{}", total, if total == 1 { "" } else { "s" });
|
|
toast_overlay.add_toast(adw::Toast::new(&msg));
|
|
}
|
|
}
|
|
Err(_) => toast_overlay.add_toast(adw::Toast::new("Security scan failed")),
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
self.add_action(&scan_security_action);
|
|
|
|
// Toggle integration
|
|
let toggle_integration_action = gio::SimpleAction::new("toggle-integration", param_type);
|
|
{
|
|
let window_weak = self.downgrade();
|
|
toggle_integration_action.connect_activate(move |_, param| {
|
|
let Some(window) = window_weak.upgrade() else { return };
|
|
let Some(record_id) = param.and_then(|p| p.get::<i64>()) else { return };
|
|
let db = window.database().clone();
|
|
let toast_overlay = window.imp().toast_overlay.get().unwrap().clone();
|
|
if let Ok(Some(record)) = db.get_appimage_by_id(record_id) {
|
|
if record.integrated {
|
|
integrator::remove_integration(&record).ok();
|
|
db.set_integrated(record_id, false, None).ok();
|
|
toast_overlay.add_toast(adw::Toast::new("Integration removed"));
|
|
} else {
|
|
// For context menu, do a quick integrate without dialog
|
|
match integrator::integrate_appimage(&record) {
|
|
Ok(desktop_path) => {
|
|
db.set_integrated(record_id, true, Some(&desktop_path)).ok();
|
|
toast_overlay.add_toast(adw::Toast::new("Integrated into desktop menu"));
|
|
}
|
|
Err(e) => {
|
|
log::error!("Integration failed: {}", e);
|
|
toast_overlay.add_toast(adw::Toast::new("Integration failed"));
|
|
}
|
|
}
|
|
}
|
|
// Refresh library view
|
|
let lib_view = window.imp().library_view.get().unwrap();
|
|
match db.get_all_appimages() {
|
|
Ok(records) => lib_view.populate(records),
|
|
Err(_) => {}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
self.add_action(&toggle_integration_action);
|
|
|
|
// Open containing folder
|
|
let open_folder_action = gio::SimpleAction::new("open-folder", param_type);
|
|
{
|
|
let window_weak = self.downgrade();
|
|
open_folder_action.connect_activate(move |_, param| {
|
|
let Some(window) = window_weak.upgrade() else { return };
|
|
let Some(record_id) = param.and_then(|p| p.get::<i64>()) else { return };
|
|
let db = window.database().clone();
|
|
if let Ok(Some(record)) = db.get_appimage_by_id(record_id) {
|
|
let file = gio::File::for_path(&record.path);
|
|
let launcher = gtk::FileLauncher::new(Some(&file));
|
|
launcher.open_containing_folder(gtk::Window::NONE, None::<&gio::Cancellable>, |_| {});
|
|
}
|
|
});
|
|
}
|
|
self.add_action(&open_folder_action);
|
|
|
|
// Copy path to clipboard
|
|
let copy_path_action = gio::SimpleAction::new("copy-path", param_type);
|
|
{
|
|
let window_weak = self.downgrade();
|
|
copy_path_action.connect_activate(move |_, param| {
|
|
let Some(window) = window_weak.upgrade() else { return };
|
|
let Some(record_id) = param.and_then(|p| p.get::<i64>()) else { return };
|
|
let db = window.database().clone();
|
|
let toast_overlay = window.imp().toast_overlay.get().unwrap().clone();
|
|
if let Ok(Some(record)) = db.get_appimage_by_id(record_id) {
|
|
if let Some(display) = window.display().into() {
|
|
let clipboard = gtk::gdk::Display::clipboard(&display);
|
|
clipboard.set_text(&record.path);
|
|
toast_overlay.add_toast(adw::Toast::new("Path copied to clipboard"));
|
|
}
|
|
}
|
|
});
|
|
}
|
|
self.add_action(©_path_action);
|
|
```
|
|
|
|
**Step 2: Add missing imports at the top of window.rs**
|
|
|
|
Add these imports to the use block at the top (after line 14):
|
|
|
|
```rust
|
|
use crate::core::integrator;
|
|
use crate::core::launcher;
|
|
use crate::core::security;
|
|
```
|
|
|
|
**Step 3: Build and verify**
|
|
|
|
Run: `cargo build 2>&1 | tail -5`
|
|
Expected: Compiles. (Note: if `update_dialog::check_single_update` doesn't exist, we need to add it or use a toast-based approach instead.)
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/window.rs
|
|
git commit -m "ui: add parameterized context menu actions to window
|
|
|
|
Define launch-appimage, check-update, scan-security, toggle-integration,
|
|
open-folder, and copy-path actions with i64 record ID parameter for
|
|
the right-click context menu on cards and list rows."
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Right-Click Context Menu - Attach to Cards and List Rows
|
|
|
|
**Files:**
|
|
- Modify: `src/ui/library_view.rs`
|
|
|
|
**Step 1: Add context menu builder function**
|
|
|
|
Add a helper function at the end of `library_view.rs` (module-level, outside the `impl LibraryView` block):
|
|
|
|
```rust
|
|
/// Build the right-click context menu model for an AppImage.
|
|
fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu {
|
|
let menu = gtk::gio::Menu::new();
|
|
|
|
// Section 1: Launch
|
|
let section1 = gtk::gio::Menu::new();
|
|
section1.append(Some("Launch"), Some(&format!("win.launch-appimage(int64 {})", record.id)));
|
|
menu.append_section(None, §ion1);
|
|
|
|
// 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)));
|
|
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" };
|
|
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)));
|
|
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)));
|
|
menu.append_section(None, §ion4);
|
|
|
|
menu
|
|
}
|
|
|
|
/// Attach a right-click context menu to a widget.
|
|
fn attach_context_menu(widget: &impl gtk::prelude::IsA<gtk::Widget>, menu_model: >k::gio::Menu) {
|
|
let popover = gtk::PopoverMenu::from_model(Some(menu_model));
|
|
popover.set_parent(widget.as_ref());
|
|
popover.set_has_arrow(false);
|
|
|
|
let click = gtk::GestureClick::new();
|
|
click.set_button(3); // Right click
|
|
let popover_ref = popover.clone();
|
|
click.connect_pressed(move |gesture, _, x, y| {
|
|
gesture.set_state(gtk::EventSequenceState::Claimed);
|
|
popover_ref.set_pointing_to(Some(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
|
|
popover_ref.popup();
|
|
});
|
|
widget.as_ref().add_controller(click);
|
|
|
|
// Long press for touch
|
|
let long_press = gtk::GestureLongPress::new();
|
|
let popover_ref = popover;
|
|
long_press.connect_pressed(move |gesture, x, y| {
|
|
gesture.set_state(gtk::EventSequenceState::Claimed);
|
|
popover_ref.set_pointing_to(Some(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
|
|
popover_ref.popup();
|
|
});
|
|
widget.as_ref().add_controller(long_press);
|
|
}
|
|
```
|
|
|
|
**Step 2: Wire context menu to grid cards in populate()**
|
|
|
|
In the `populate()` method, after building the card (around line 432-433), add context menu attachment:
|
|
|
|
Change:
|
|
|
|
```rust
|
|
for record in &new_records {
|
|
// Grid card
|
|
let card = app_card::build_app_card(record);
|
|
self.flow_box.append(&card);
|
|
|
|
// List row
|
|
let row = self.build_list_row(record);
|
|
self.list_box.append(&row);
|
|
}
|
|
```
|
|
|
|
to:
|
|
|
|
```rust
|
|
for record in &new_records {
|
|
// Grid card
|
|
let card = app_card::build_app_card(record);
|
|
let card_menu = build_context_menu(record);
|
|
attach_context_menu(&card, &card_menu);
|
|
self.flow_box.append(&card);
|
|
|
|
// List row
|
|
let row = self.build_list_row(record);
|
|
let row_menu = build_context_menu(record);
|
|
attach_context_menu(&row, &row_menu);
|
|
self.list_box.append(&row);
|
|
}
|
|
```
|
|
|
|
**Step 3: Build and test**
|
|
|
|
Run: `cargo build 2>&1 | tail -5`
|
|
Expected: Compiles successfully
|
|
|
|
Run: `cargo test 2>&1 | tail -5`
|
|
Expected: All tests pass
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/ui/library_view.rs
|
|
git commit -m "ui: attach right-click context menu to cards and list rows
|
|
|
|
GtkPopoverMenu with gio::Menu model, triggered by GestureClick (button 3)
|
|
and GestureLongPress for touch. Menu sections: Launch, Check Updates /
|
|
Scan Vulnerabilities, Integrate / Open Folder, Copy Path."
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Handle check_single_update Helper
|
|
|
|
**Files:**
|
|
- Modify: `src/ui/update_dialog.rs`
|
|
|
|
The context menu's check-update action calls `update_dialog::check_single_update`. We need to verify this function exists or add it.
|
|
|
|
**Step 1: Check if function exists**
|
|
|
|
Read `src/ui/update_dialog.rs` and look for `check_single_update` or `batch_check_updates`.
|
|
|
|
If `check_single_update` does not exist, add it:
|
|
|
|
```rust
|
|
/// Check for updates for a single AppImage. Returns true if update available.
|
|
pub fn check_single_update(db: &Database, record: &AppImageRecord) -> bool {
|
|
if record.update_type.is_none() {
|
|
return false;
|
|
}
|
|
match crate::core::updater::check_for_update(record) {
|
|
Ok(Some(latest)) => {
|
|
let is_newer = record
|
|
.app_version
|
|
.as_deref()
|
|
.map(|current| crate::core::updater::version_is_newer(&latest, current))
|
|
.unwrap_or(true);
|
|
if is_newer {
|
|
db.update_latest_version(record.id, &latest).ok();
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Build and test**
|
|
|
|
Run: `cargo build 2>&1 | tail -5`
|
|
Expected: Compiles
|
|
|
|
Run: `cargo test 2>&1 | tail -5`
|
|
Expected: All tests pass
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/ui/update_dialog.rs
|
|
git commit -m "ui: add check_single_update helper for context menu"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Final Build Verification and Polish
|
|
|
|
**Files:**
|
|
- All modified files
|
|
|
|
**Step 1: Full build**
|
|
|
|
Run: `cargo build 2>&1`
|
|
Expected: Zero errors. Fix any warnings.
|
|
|
|
**Step 2: Run tests**
|
|
|
|
Run: `cargo test 2>&1`
|
|
Expected: All 128+ tests pass.
|
|
|
|
**Step 3: Visual verification**
|
|
|
|
Run: `cargo run`
|
|
|
|
Verify:
|
|
1. Card view: 200px cards, 72px icons, .title-3 names, version+size combined, single badge
|
|
2. List view: 48px rounded icons, .rich-list spacing, structured subtitle, single badge
|
|
3. Detail view: 96px icon banner with gradient, ViewSwitcher with 4 tabs
|
|
4. Right-click on card or list row opens context menu
|
|
5. All context menu actions work (Launch, Check Updates, Scan, Integrate, Open Folder, Copy Path)
|
|
6. Light and dark mode look correct
|
|
7. Keyboard navigation works (Tab, Enter, Escape)
|
|
|
|
**Step 4: Commit any final fixes**
|
|
|
|
```bash
|
|
git add -u
|
|
git commit -m "ui: final polish and fixes for UI/UX overhaul"
|
|
```
|
|
|
|
---
|
|
|
|
## Files Modified (Summary)
|
|
|
|
| File | Task | Changes |
|
|
|------|------|---------|
|
|
| `data/resources/style.css` | 1 | Remove .app-card CSS, add .icon-rounded, .detail-banner gradient, .detail-view-switcher |
|
|
| `src/ui/app_card.rs` | 2 | 72px icon, .title-3, version+size combined, single priority badge, .card class, 200px width |
|
|
| `src/ui/library_view.rs` | 3, 6 | FlowBox max 4 cols, .rich-list, list row restructure, context menu build + attach |
|
|
| `src/ui/detail_view.rs` | 4 | 96px banner, ViewStack/ViewSwitcher, 4 tab pages (Overview, System, Security, Storage) |
|
|
| `src/window.rs` | 5 | 6 parameterized actions for context menu |
|
|
| `src/ui/update_dialog.rs` | 7 | check_single_update helper |
|
|
|
|
## Verification
|
|
|
|
After all tasks:
|
|
1. `cargo build` - zero errors, zero warnings
|
|
2. `cargo test` - all 128+ tests pass
|
|
3. Visual verification of all three views in light + dark mode
|
|
4. Right-click context menu works on cards and list rows
|
|
5. Detail view tabs switch correctly, content is correctly distributed
|
|
6. Keyboard navigation preserved
|
|
7. All WCAG AAA compliance preserved
|