Implement Driftwood AppImage manager - Phases 1 and 2
Phase 1 - Application scaffolding: - GTK4/libadwaita application window with AdwNavigationView - GSettings-backed window state persistence - GResource-compiled CSS and schema - Library view with grid/list toggle, search, sorting, filtering - Detail view with file info, desktop integration controls - Preferences window with scan directories, theme, behavior settings - CLI with list, scan, integrate, remove, clean, inspect commands - AppImage discovery, metadata extraction, desktop integration - Orphaned desktop entry detection and cleanup - AppImage packaging script Phase 2 - Intelligence layer: - Database schema v2 with migration for status tracking columns - FUSE detection engine (libfuse2/3, fusermount, /dev/fuse, AppImageLauncher) - Wayland awareness engine (session type, toolkit detection, XWayland) - Update info parsing from AppImage ELF sections (.upd_info) - GitHub/GitLab Releases API integration for update checking - Update download with progress tracking and atomic apply - Launch wrapper with FUSE auto-detection and usage tracking - Duplicate and multi-version detection with recommendations - Dashboard with system health, library stats, disk usage - Update check dialog (single and batch) - Duplicate resolution dialog - Status badges on library cards and detail view - Extended CLI: status, check-updates, duplicates, launch commands 49 tests passing across all modules.
This commit is contained in:
2295
Cargo.lock
generated
Normal file
2295
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
Cargo.toml
Normal file
46
Cargo.toml
Normal file
@@ -0,0 +1,46 @@
|
||||
[package]
|
||||
name = "driftwood"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[dependencies]
|
||||
gtk = { version = "0.11", package = "gtk4", features = ["v4_16"] }
|
||||
adw = { version = "0.9", package = "libadwaita", features = ["v1_6"] }
|
||||
glib = "0.22"
|
||||
gio = "0.22"
|
||||
|
||||
# Database
|
||||
rusqlite = { version = "0.33", features = ["bundled"] }
|
||||
|
||||
# CLI
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
# File hashing
|
||||
sha2 = "0.10"
|
||||
|
||||
# Time
|
||||
chrono = "0.4"
|
||||
|
||||
# XDG directories
|
||||
dirs = "6"
|
||||
|
||||
# Human-readable sizes
|
||||
humansize = "2"
|
||||
|
||||
# HTTP client (sync, lightweight - for update checks)
|
||||
ureq = { version = "3", features = ["json"] }
|
||||
|
||||
# JSON parsing
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Logging
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
# Temp directories (for AppImage extraction)
|
||||
tempfile = "3"
|
||||
|
||||
[build-dependencies]
|
||||
glib-build-tools = "0.22"
|
||||
36
build.rs
Normal file
36
build.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
// Compile GResources
|
||||
glib_build_tools::compile_resources(
|
||||
&["data"],
|
||||
"data/resources.gresource.xml",
|
||||
"driftwood.gresource",
|
||||
);
|
||||
|
||||
// Compile GSettings schema for development builds
|
||||
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
|
||||
let schema_dir = out_dir.join("gschemas");
|
||||
std::fs::create_dir_all(&schema_dir).expect("Failed to create schema dir");
|
||||
|
||||
std::fs::copy(
|
||||
"data/app.driftwood.Driftwood.gschema.xml",
|
||||
schema_dir.join("app.driftwood.Driftwood.gschema.xml"),
|
||||
)
|
||||
.expect("Failed to copy schema");
|
||||
|
||||
let status = Command::new("glib-compile-schemas")
|
||||
.arg(&schema_dir)
|
||||
.status()
|
||||
.expect("Failed to run glib-compile-schemas");
|
||||
|
||||
if !status.success() {
|
||||
panic!("glib-compile-schemas failed");
|
||||
}
|
||||
|
||||
println!(
|
||||
"cargo::rustc-env=GSETTINGS_SCHEMA_DIR={}",
|
||||
schema_dir.display()
|
||||
);
|
||||
}
|
||||
10
data/app.driftwood.Driftwood.desktop
Normal file
10
data/app.driftwood.Driftwood.desktop
Normal file
@@ -0,0 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Name=Driftwood
|
||||
Comment=Modern AppImage manager for GNOME desktops
|
||||
Exec=driftwood
|
||||
Icon=app.driftwood.Driftwood
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=System;PackageManager;GTK;
|
||||
Keywords=AppImage;Application;Manager;Package;
|
||||
StartupNotify=true
|
||||
35
data/app.driftwood.Driftwood.gschema.xml
Normal file
35
data/app.driftwood.Driftwood.gschema.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<schemalist gettext-domain="driftwood">
|
||||
<schema id="app.driftwood.Driftwood" path="/app/driftwood/Driftwood/">
|
||||
<key name="window-width" type="i">
|
||||
<default>900</default>
|
||||
<summary>Window width</summary>
|
||||
<description>The width of the main application window.</description>
|
||||
</key>
|
||||
<key name="window-height" type="i">
|
||||
<default>600</default>
|
||||
<summary>Window height</summary>
|
||||
<description>The height of the main application window.</description>
|
||||
</key>
|
||||
<key name="window-maximized" type="b">
|
||||
<default>false</default>
|
||||
<summary>Window maximized</summary>
|
||||
<description>Whether the main application window is maximized.</description>
|
||||
</key>
|
||||
<key name="scan-directories" type="as">
|
||||
<default>['~/Applications', '~/Downloads']</default>
|
||||
<summary>Scan directories</summary>
|
||||
<description>Directories to scan for AppImage files.</description>
|
||||
</key>
|
||||
<key name="view-mode" type="s">
|
||||
<default>'grid'</default>
|
||||
<summary>Library view mode</summary>
|
||||
<description>The library view mode: grid or list.</description>
|
||||
</key>
|
||||
<key name="color-scheme" type="s">
|
||||
<default>'default'</default>
|
||||
<summary>Color scheme</summary>
|
||||
<description>Application color scheme: default (follow system), force-light, or force-dark.</description>
|
||||
</key>
|
||||
</schema>
|
||||
</schemalist>
|
||||
6
data/resources.gresource.xml
Normal file
6
data/resources.gresource.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gresources>
|
||||
<gresource prefix="/app/driftwood/Driftwood">
|
||||
<file alias="style.css">resources/style.css</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
72
data/resources/style.css
Normal file
72
data/resources/style.css
Normal file
@@ -0,0 +1,72 @@
|
||||
/* Status badges */
|
||||
.status-badge {
|
||||
border-radius: 8px;
|
||||
padding: 2px 8px;
|
||||
font-size: 8pt;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge.success {
|
||||
background: @success_bg_color;
|
||||
color: @success_fg_color;
|
||||
}
|
||||
|
||||
.status-badge.warning {
|
||||
background: @warning_bg_color;
|
||||
color: @warning_fg_color;
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: @error_bg_color;
|
||||
color: @error_fg_color;
|
||||
}
|
||||
|
||||
.status-badge.info {
|
||||
background: @accent_bg_color;
|
||||
color: @accent_fg_color;
|
||||
}
|
||||
|
||||
.status-badge.neutral {
|
||||
background: @card_shade_color;
|
||||
color: @window_fg_color;
|
||||
}
|
||||
|
||||
/* App cards in grid view */
|
||||
.app-card {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: @card_bg_color;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
|
||||
.app-card:hover {
|
||||
background: @headerbar_shade_color;
|
||||
}
|
||||
|
||||
.app-card-name {
|
||||
font-weight: 600;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.app-card-version {
|
||||
font-size: 8pt;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Badge row in app cards */
|
||||
.badge-row {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Detail view headings */
|
||||
.heading {
|
||||
font-weight: 700;
|
||||
font-size: 11pt;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Monospace text for paths and hashes */
|
||||
.monospace {
|
||||
font-family: monospace;
|
||||
font-size: 9pt;
|
||||
}
|
||||
110
packaging/build-appimage.sh
Executable file
110
packaging/build-appimage.sh
Executable file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Build Driftwood as an AppImage using linuxdeploy
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Rust toolchain (cargo)
|
||||
# - linuxdeploy (https://github.com/linuxdeploy/linuxdeploy)
|
||||
# - linuxdeploy-plugin-gtk (for GTK4/libadwaita bundling)
|
||||
#
|
||||
# Usage:
|
||||
# ./packaging/build-appimage.sh
|
||||
#
|
||||
# The resulting .AppImage will be in the project root.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
APP_ID="app.driftwood.Driftwood"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
echo "=== Building Driftwood (release) ==="
|
||||
cargo build --release
|
||||
|
||||
echo "=== Preparing AppDir ==="
|
||||
APPDIR="$PROJECT_DIR/AppDir"
|
||||
rm -rf "$APPDIR"
|
||||
mkdir -p "$APPDIR/usr/bin"
|
||||
mkdir -p "$APPDIR/usr/share/applications"
|
||||
mkdir -p "$APPDIR/usr/share/glib-2.0/schemas"
|
||||
mkdir -p "$APPDIR/usr/share/icons/hicolor/scalable/apps"
|
||||
|
||||
# Binary
|
||||
cp "target/release/driftwood" "$APPDIR/usr/bin/driftwood"
|
||||
|
||||
# Desktop file
|
||||
cp "data/$APP_ID.desktop" "$APPDIR/usr/share/applications/$APP_ID.desktop"
|
||||
|
||||
# GSettings schema
|
||||
cp "data/$APP_ID.gschema.xml" "$APPDIR/usr/share/glib-2.0/schemas/$APP_ID.gschema.xml"
|
||||
glib-compile-schemas "$APPDIR/usr/share/glib-2.0/schemas/"
|
||||
|
||||
# Icon - use a placeholder SVG if no real icon exists yet
|
||||
ICON_FILE="data/icons/$APP_ID.svg"
|
||||
if [ -f "$ICON_FILE" ]; then
|
||||
cp "$ICON_FILE" "$APPDIR/usr/share/icons/hicolor/scalable/apps/$APP_ID.svg"
|
||||
else
|
||||
echo "Warning: No app icon found at $ICON_FILE"
|
||||
echo "Creating a minimal placeholder icon..."
|
||||
cat > "$APPDIR/usr/share/icons/hicolor/scalable/apps/$APP_ID.svg" << 'SVGEOF'
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<rect width="128" height="128" rx="16" fill="#3584e4"/>
|
||||
<text x="64" y="78" font-family="sans-serif" font-size="64" font-weight="bold"
|
||||
fill="white" text-anchor="middle">D</text>
|
||||
</svg>
|
||||
SVGEOF
|
||||
fi
|
||||
|
||||
# Check for linuxdeploy
|
||||
LINUXDEPLOY="${LINUXDEPLOY:-linuxdeploy}"
|
||||
if ! command -v "$LINUXDEPLOY" &>/dev/null; then
|
||||
# Try to find it in the current directory
|
||||
if [ -x "./linuxdeploy-x86_64.AppImage" ]; then
|
||||
LINUXDEPLOY="./linuxdeploy-x86_64.AppImage"
|
||||
else
|
||||
echo ""
|
||||
echo "Error: linuxdeploy not found."
|
||||
echo ""
|
||||
echo "Download it from:"
|
||||
echo " https://github.com/linuxdeploy/linuxdeploy/releases"
|
||||
echo ""
|
||||
echo "Then either:"
|
||||
echo " 1. Place linuxdeploy-x86_64.AppImage in the project root, or"
|
||||
echo " 2. Set LINUXDEPLOY=/path/to/linuxdeploy"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for GTK plugin
|
||||
GTK_PLUGIN="${LINUXDEPLOY_PLUGIN_GTK:-}"
|
||||
if [ -z "$GTK_PLUGIN" ]; then
|
||||
if [ -x "./linuxdeploy-plugin-gtk.sh" ]; then
|
||||
export DEPLOY_GTK_VERSION=4
|
||||
else
|
||||
echo ""
|
||||
echo "Warning: linuxdeploy-plugin-gtk not found."
|
||||
echo "GTK4 libraries will not be bundled."
|
||||
echo "The AppImage may only work on systems with GTK4 and libadwaita installed."
|
||||
echo ""
|
||||
echo "Download the plugin from:"
|
||||
echo " https://github.com/nickvdp/linuxdeploy-plugin-gtk"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== Building AppImage ==="
|
||||
export ARCH="x86_64"
|
||||
export OUTPUT="Driftwood-x86_64.AppImage"
|
||||
export GSETTINGS_SCHEMA_DIR="$APPDIR/usr/share/glib-2.0/schemas"
|
||||
|
||||
"$LINUXDEPLOY" \
|
||||
--appdir "$APPDIR" \
|
||||
--desktop-file "$APPDIR/usr/share/applications/$APP_ID.desktop" \
|
||||
--icon-file "$APPDIR/usr/share/icons/hicolor/scalable/apps/$APP_ID.svg" \
|
||||
--output appimage
|
||||
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
echo "AppImage created: $OUTPUT"
|
||||
echo "You can run it with: ./$OUTPUT"
|
||||
145
src/application.rs
Normal file
145
src/application.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
use adw::prelude::*;
|
||||
use adw::subclass::prelude::*;
|
||||
use gtk::gio;
|
||||
use std::cell::OnceCell;
|
||||
|
||||
use crate::config::{APP_ID, VERSION};
|
||||
use crate::window::DriftwoodWindow;
|
||||
|
||||
mod imp {
|
||||
use super::*;
|
||||
|
||||
pub struct DriftwoodApplication {
|
||||
pub settings: OnceCell<gio::Settings>,
|
||||
}
|
||||
|
||||
impl Default for DriftwoodApplication {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
settings: OnceCell::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for DriftwoodApplication {
|
||||
const NAME: &'static str = "DriftwoodApplication";
|
||||
type Type = super::DriftwoodApplication;
|
||||
type ParentType = adw::Application;
|
||||
}
|
||||
|
||||
impl ObjectImpl for DriftwoodApplication {}
|
||||
|
||||
impl ApplicationImpl for DriftwoodApplication {
|
||||
fn startup(&self) {
|
||||
self.parent_startup();
|
||||
let app = self.obj();
|
||||
app.setup_css();
|
||||
app.setup_theme();
|
||||
app.setup_actions();
|
||||
}
|
||||
|
||||
fn activate(&self) {
|
||||
self.parent_activate();
|
||||
let app = self.obj();
|
||||
|
||||
// Present existing window or create a new one
|
||||
if let Some(window) = app.active_window() {
|
||||
window.present();
|
||||
} else {
|
||||
let window = DriftwoodWindow::new(&*app);
|
||||
window.present();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GtkApplicationImpl for DriftwoodApplication {}
|
||||
impl AdwApplicationImpl for DriftwoodApplication {}
|
||||
}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct DriftwoodApplication(ObjectSubclass<imp::DriftwoodApplication>)
|
||||
@extends adw::Application, gtk::Application, gio::Application,
|
||||
@implements gio::ActionGroup, gio::ActionMap;
|
||||
}
|
||||
|
||||
impl DriftwoodApplication {
|
||||
pub fn new(app_id: &str, flags: &gio::ApplicationFlags) -> Self {
|
||||
glib::Object::builder()
|
||||
.property("application-id", app_id)
|
||||
.property("flags", flags)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn setup_css(&self) {
|
||||
let provider = gtk::CssProvider::new();
|
||||
provider.load_from_resource("/app/driftwood/Driftwood/style.css");
|
||||
gtk::style_context_add_provider_for_display(
|
||||
>k::gdk::Display::default().expect("Could not get default display"),
|
||||
&provider,
|
||||
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
}
|
||||
|
||||
fn setup_theme(&self) {
|
||||
let settings = gio::Settings::new(APP_ID);
|
||||
Self::apply_color_scheme(&settings);
|
||||
|
||||
settings.connect_changed(Some("color-scheme"), |settings, _| {
|
||||
Self::apply_color_scheme(settings);
|
||||
});
|
||||
|
||||
// Store settings on the imp struct so it stays alive
|
||||
// (otherwise the connect_changed signal gets dropped)
|
||||
self.imp()
|
||||
.settings
|
||||
.set(settings)
|
||||
.expect("Theme settings already initialized");
|
||||
}
|
||||
|
||||
fn apply_color_scheme(settings: &gio::Settings) {
|
||||
let value = settings.string("color-scheme");
|
||||
let scheme = match value.as_str() {
|
||||
"force-light" => adw::ColorScheme::ForceLight,
|
||||
"force-dark" => adw::ColorScheme::ForceDark,
|
||||
_ => adw::ColorScheme::Default,
|
||||
};
|
||||
adw::StyleManager::default().set_color_scheme(scheme);
|
||||
}
|
||||
|
||||
fn setup_actions(&self) {
|
||||
// Quit action (Ctrl+Q)
|
||||
let quit_action = gio::ActionEntry::builder("quit")
|
||||
.activate(|app: &Self, _, _| {
|
||||
if let Some(window) = app.active_window() {
|
||||
window.close();
|
||||
}
|
||||
app.quit();
|
||||
})
|
||||
.build();
|
||||
|
||||
// About action
|
||||
let about_action = gio::ActionEntry::builder("about")
|
||||
.activate(|app: &Self, _, _| {
|
||||
app.show_about_dialog();
|
||||
})
|
||||
.build();
|
||||
|
||||
self.add_action_entries([quit_action, about_action]);
|
||||
self.set_accels_for_action("app.quit", &["<Control>q"]);
|
||||
}
|
||||
|
||||
fn show_about_dialog(&self) {
|
||||
let dialog = adw::AboutDialog::builder()
|
||||
.application_name("Driftwood")
|
||||
.application_icon(APP_ID)
|
||||
.version(VERSION)
|
||||
.developer_name("Driftwood Contributors")
|
||||
.license_type(gtk::License::Gpl30)
|
||||
.comments("A modern AppImage manager for GNOME desktops")
|
||||
.website("https://github.com/driftwood-app/driftwood")
|
||||
.build();
|
||||
|
||||
dialog.present(self.active_window().as_ref());
|
||||
}
|
||||
}
|
||||
663
src/cli.rs
Normal file
663
src/cli.rs
Normal file
@@ -0,0 +1,663 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use glib::ExitCode;
|
||||
use gtk::prelude::*;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::core::database::Database;
|
||||
use crate::core::discovery;
|
||||
use crate::core::duplicates;
|
||||
use crate::core::fuse;
|
||||
use crate::core::inspector;
|
||||
use crate::core::integrator;
|
||||
use crate::core::launcher;
|
||||
use crate::core::orphan;
|
||||
use crate::core::updater;
|
||||
use crate::core::wayland;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "driftwood", version, about = "Modern AppImage manager for GNOME desktops")]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Commands>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands {
|
||||
/// List all known AppImages
|
||||
List {
|
||||
/// Output format: table or json
|
||||
#[arg(long, default_value = "table")]
|
||||
format: String,
|
||||
},
|
||||
/// Scan for AppImages in configured directories
|
||||
Scan,
|
||||
/// Integrate an AppImage (create .desktop and install icon)
|
||||
Integrate {
|
||||
/// Path to the AppImage
|
||||
path: String,
|
||||
},
|
||||
/// Remove integration for an AppImage
|
||||
Remove {
|
||||
/// Path to the AppImage
|
||||
path: String,
|
||||
},
|
||||
/// Clean orphaned desktop entries
|
||||
Clean,
|
||||
/// Inspect an AppImage and show its metadata
|
||||
Inspect {
|
||||
/// Path to the AppImage
|
||||
path: String,
|
||||
},
|
||||
/// Show system status (FUSE, Wayland, desktop environment)
|
||||
Status,
|
||||
/// Check all AppImages for updates
|
||||
CheckUpdates,
|
||||
/// Find duplicate and multi-version AppImages
|
||||
Duplicates,
|
||||
/// Launch an AppImage (with tracking and FUSE detection)
|
||||
Launch {
|
||||
/// Path to the AppImage
|
||||
path: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn run_command(command: Commands) -> ExitCode {
|
||||
let db = match Database::open() {
|
||||
Ok(db) => db,
|
||||
Err(e) => {
|
||||
eprintln!("Error: Failed to open database: {}", e);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
match command {
|
||||
Commands::List { format } => cmd_list(&db, &format),
|
||||
Commands::Scan => cmd_scan(&db),
|
||||
Commands::Integrate { path } => cmd_integrate(&db, &path),
|
||||
Commands::Remove { path } => cmd_remove(&db, &path),
|
||||
Commands::Clean => cmd_clean(),
|
||||
Commands::Inspect { path } => cmd_inspect(&path),
|
||||
Commands::Status => cmd_status(),
|
||||
Commands::CheckUpdates => cmd_check_updates(&db),
|
||||
Commands::Duplicates => cmd_duplicates(&db),
|
||||
Commands::Launch { path } => cmd_launch(&db, &path),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_list(db: &Database, format: &str) -> ExitCode {
|
||||
let records = match db.get_all_appimages() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
if records.is_empty() {
|
||||
println!("No AppImages found. Run 'driftwood scan' first.");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
if format == "json" {
|
||||
// Simple JSON output
|
||||
println!("[");
|
||||
for (i, r) in records.iter().enumerate() {
|
||||
let comma = if i + 1 < records.len() { "," } else { "" };
|
||||
println!(
|
||||
" {{\"name\": \"{}\", \"version\": \"{}\", \"path\": \"{}\", \"size\": {}, \"integrated\": {}}}{}",
|
||||
r.app_name.as_deref().unwrap_or(&r.filename),
|
||||
r.app_version.as_deref().unwrap_or(""),
|
||||
r.path,
|
||||
r.size_bytes,
|
||||
r.integrated,
|
||||
comma,
|
||||
);
|
||||
}
|
||||
println!("]");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
// Table output
|
||||
let name_width = records
|
||||
.iter()
|
||||
.map(|r| r.app_name.as_deref().unwrap_or(&r.filename).len())
|
||||
.max()
|
||||
.unwrap_or(4)
|
||||
.max(4)
|
||||
.min(30);
|
||||
|
||||
let ver_width = records
|
||||
.iter()
|
||||
.map(|r| r.app_version.as_deref().unwrap_or("").len())
|
||||
.max()
|
||||
.unwrap_or(7)
|
||||
.max(7)
|
||||
.min(15);
|
||||
|
||||
println!(
|
||||
" {:<name_w$} {:<ver_w$} {:>10} {}",
|
||||
"Name", "Version", "Size", "Integrated",
|
||||
name_w = name_width,
|
||||
ver_w = ver_width,
|
||||
);
|
||||
println!(
|
||||
" {:-<name_w$} {:-<ver_w$} {:->10} ----------",
|
||||
"", "", "",
|
||||
name_w = name_width,
|
||||
ver_w = ver_width,
|
||||
);
|
||||
|
||||
let mut integrated_count = 0;
|
||||
for r in &records {
|
||||
let name = r.app_name.as_deref().unwrap_or(&r.filename);
|
||||
let display_name = if name.len() > name_width {
|
||||
&name[..name_width]
|
||||
} else {
|
||||
name
|
||||
};
|
||||
let version = r.app_version.as_deref().unwrap_or("");
|
||||
let size = humansize::format_size(r.size_bytes as u64, humansize::BINARY);
|
||||
let status = if r.integrated { "Yes" } else { "No" };
|
||||
if r.integrated {
|
||||
integrated_count += 1;
|
||||
}
|
||||
println!(
|
||||
" {:<name_w$} {:<ver_w$} {:>10} {}",
|
||||
display_name, version, size, status,
|
||||
name_w = name_width,
|
||||
ver_w = ver_width,
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
println!(
|
||||
" {} AppImages found, {} integrated",
|
||||
records.len(),
|
||||
integrated_count,
|
||||
);
|
||||
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
fn cmd_scan(db: &Database) -> ExitCode {
|
||||
let settings = gtk::gio::Settings::new(crate::config::APP_ID);
|
||||
let dirs: Vec<String> = settings
|
||||
.strv("scan-directories")
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
println!("Scanning directories:");
|
||||
for d in &dirs {
|
||||
let expanded = discovery::expand_tilde(d);
|
||||
println!(" {}", expanded.display());
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
let discovered = discovery::scan_directories(&dirs);
|
||||
let total = discovered.len();
|
||||
let mut new_count = 0;
|
||||
|
||||
for d in &discovered {
|
||||
let existing = db
|
||||
.get_appimage_by_path(&d.path.to_string_lossy())
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let modified = d.modified_time
|
||||
.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 id = db.upsert_appimage(
|
||||
&d.path.to_string_lossy(),
|
||||
&d.filename,
|
||||
Some(d.appimage_type.as_i32()),
|
||||
d.size_bytes as i64,
|
||||
d.is_executable,
|
||||
modified.as_deref(),
|
||||
).unwrap_or(0);
|
||||
|
||||
if existing.is_none() {
|
||||
new_count += 1;
|
||||
println!(" [NEW] {}", d.filename);
|
||||
}
|
||||
|
||||
let needs_metadata = existing
|
||||
.as_ref()
|
||||
.map(|r| r.app_name.is_none())
|
||||
.unwrap_or(true);
|
||||
|
||||
if needs_metadata {
|
||||
print!(" Inspecting {}... ", d.filename);
|
||||
match inspector::inspect_appimage(&d.path, &d.appimage_type) {
|
||||
Ok(metadata) => {
|
||||
let categories = if metadata.categories.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(metadata.categories.join(";"))
|
||||
};
|
||||
db.update_metadata(
|
||||
id,
|
||||
metadata.app_name.as_deref(),
|
||||
metadata.app_version.as_deref(),
|
||||
metadata.description.as_deref(),
|
||||
metadata.developer.as_deref(),
|
||||
categories.as_deref(),
|
||||
metadata.architecture.as_deref(),
|
||||
metadata.cached_icon_path.as_ref().map(|p| p.to_string_lossy()).as_deref(),
|
||||
Some(&metadata.desktop_entry_content),
|
||||
).ok();
|
||||
println!(
|
||||
"{}",
|
||||
metadata.app_name.as_deref().unwrap_or("(no name)")
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let duration = start.elapsed();
|
||||
db.log_scan(
|
||||
"cli",
|
||||
&dirs,
|
||||
total as i32,
|
||||
new_count,
|
||||
0,
|
||||
duration.as_millis() as i64,
|
||||
).ok();
|
||||
|
||||
println!();
|
||||
println!(
|
||||
"Scan complete: {} found, {} new ({:.1}s)",
|
||||
total,
|
||||
new_count,
|
||||
duration.as_secs_f64(),
|
||||
);
|
||||
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
fn cmd_integrate(db: &Database, path: &str) -> ExitCode {
|
||||
let record = match db.get_appimage_by_path(path) {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => {
|
||||
eprintln!("Error: '{}' is not in the database. Run 'driftwood scan' first.", path);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
if record.integrated {
|
||||
println!("{} is already integrated.", record.app_name.as_deref().unwrap_or(&record.filename));
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
match integrator::integrate(&record) {
|
||||
Ok(result) => {
|
||||
db.set_integrated(
|
||||
record.id,
|
||||
true,
|
||||
Some(&result.desktop_file_path.to_string_lossy()),
|
||||
).ok();
|
||||
println!(
|
||||
"Integrated {} -> {}",
|
||||
record.app_name.as_deref().unwrap_or(&record.filename),
|
||||
result.desktop_file_path.display(),
|
||||
);
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_remove(db: &Database, path: &str) -> ExitCode {
|
||||
let record = match db.get_appimage_by_path(path) {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => {
|
||||
eprintln!("Error: '{}' is not in the database.", path);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
if !record.integrated {
|
||||
println!("{} is not integrated.", record.app_name.as_deref().unwrap_or(&record.filename));
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
match integrator::remove_integration(&record) {
|
||||
Ok(()) => {
|
||||
db.set_integrated(record.id, false, None).ok();
|
||||
println!(
|
||||
"Removed integration for {}",
|
||||
record.app_name.as_deref().unwrap_or(&record.filename),
|
||||
);
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_clean() -> ExitCode {
|
||||
let orphans = orphan::detect_orphans();
|
||||
|
||||
if orphans.is_empty() {
|
||||
println!("No orphaned desktop entries found.");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
println!("Found {} orphaned entries:", orphans.len());
|
||||
for entry in &orphans {
|
||||
println!(
|
||||
" {} (was: {})",
|
||||
entry.app_name.as_deref().unwrap_or("Unknown"),
|
||||
entry.original_appimage_path,
|
||||
);
|
||||
}
|
||||
|
||||
match orphan::clean_all_orphans() {
|
||||
Ok(summary) => {
|
||||
println!(
|
||||
"Cleaned {} desktop entries, {} icons",
|
||||
summary.entries_removed,
|
||||
summary.icons_removed,
|
||||
);
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error during cleanup: {}", e);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_inspect(path: &str) -> ExitCode {
|
||||
let file_path = std::path::Path::new(path);
|
||||
if !file_path.exists() {
|
||||
eprintln!("Error: file not found: {}", path);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
// Detect AppImage type
|
||||
let discovered = discovery::scan_directories(&[path.to_string()]);
|
||||
if discovered.is_empty() {
|
||||
// Try scanning the parent directory and finding by path
|
||||
let parent = file_path.parent().unwrap_or(std::path::Path::new("."));
|
||||
let all = discovery::scan_directories(&[parent.to_string_lossy().to_string()]);
|
||||
let found = all.iter().find(|d| d.path == file_path);
|
||||
if let Some(d) = found {
|
||||
return do_inspect(file_path, &d.appimage_type);
|
||||
}
|
||||
eprintln!("Error: '{}' does not appear to be an AppImage", path);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
let d = &discovered[0];
|
||||
do_inspect(file_path, &d.appimage_type)
|
||||
}
|
||||
|
||||
fn cmd_status() -> ExitCode {
|
||||
println!("System Status");
|
||||
println!("=============");
|
||||
println!();
|
||||
|
||||
// Display server
|
||||
let session = wayland::detect_session_type();
|
||||
println!(" Display server: {}", session.label());
|
||||
|
||||
// Desktop environment
|
||||
let de = wayland::detect_desktop_environment();
|
||||
println!(" Desktop: {}", de);
|
||||
|
||||
// XWayland
|
||||
println!(
|
||||
" XWayland: {}",
|
||||
if wayland::has_xwayland() {
|
||||
"running"
|
||||
} else {
|
||||
"not detected"
|
||||
}
|
||||
);
|
||||
println!();
|
||||
|
||||
// FUSE
|
||||
let fuse_info = fuse::detect_system_fuse();
|
||||
println!(" FUSE status: {}", fuse_info.status.label());
|
||||
println!(" libfuse2: {}", if fuse_info.has_libfuse2 { "yes" } else { "no" });
|
||||
println!(" libfuse3: {}", if fuse_info.has_libfuse3 { "yes" } else { "no" });
|
||||
println!(" fusermount: {}", fuse_info.fusermount_path.as_deref().unwrap_or("not found"));
|
||||
println!(" /dev/fuse: {}", if fuse_info.has_dev_fuse { "present" } else { "missing" });
|
||||
|
||||
if let Some(ref hint) = fuse_info.install_hint {
|
||||
println!();
|
||||
println!(" Fix: {}", hint);
|
||||
}
|
||||
|
||||
// AppImageLauncher
|
||||
if let Some(version) = fuse::detect_appimagelauncher() {
|
||||
println!();
|
||||
println!(" WARNING: AppImageLauncher v{} detected (may conflict)", version);
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
// AppImageUpdate tool
|
||||
println!(
|
||||
" AppImageUpdate: {}",
|
||||
if updater::has_appimage_update_tool() {
|
||||
"available (delta updates enabled)"
|
||||
} else {
|
||||
"not found (full downloads only)"
|
||||
}
|
||||
);
|
||||
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
fn cmd_check_updates(db: &Database) -> ExitCode {
|
||||
let records = match db.get_all_appimages() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {}", e);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
if records.is_empty() {
|
||||
println!("No AppImages found. Run 'driftwood scan' first.");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
println!("Checking {} AppImages for updates...", records.len());
|
||||
println!();
|
||||
|
||||
let mut updates_found = 0;
|
||||
|
||||
for record in &records {
|
||||
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||
let appimage_path = std::path::Path::new(&record.path);
|
||||
|
||||
if !appimage_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
print!(" {} ... ", name);
|
||||
|
||||
let (type_label, raw_info, check_result) = updater::check_appimage_for_update(
|
||||
appimage_path,
|
||||
record.app_version.as_deref(),
|
||||
);
|
||||
|
||||
// Store update info
|
||||
if raw_info.is_some() || type_label.is_some() {
|
||||
db.update_update_info(record.id, raw_info.as_deref(), type_label.as_deref()).ok();
|
||||
}
|
||||
|
||||
match check_result {
|
||||
Some(result) if result.update_available => {
|
||||
let latest = result.latest_version.as_deref().unwrap_or("unknown");
|
||||
println!(
|
||||
"UPDATE AVAILABLE ({} -> {})",
|
||||
record.app_version.as_deref().unwrap_or("?"),
|
||||
latest,
|
||||
);
|
||||
db.set_update_available(record.id, Some(latest), result.download_url.as_deref()).ok();
|
||||
updates_found += 1;
|
||||
}
|
||||
Some(_) => {
|
||||
println!("up to date");
|
||||
db.clear_update_available(record.id).ok();
|
||||
}
|
||||
None => {
|
||||
if raw_info.is_none() {
|
||||
println!("no update info");
|
||||
} else {
|
||||
println!("check failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
if updates_found == 0 {
|
||||
println!("All AppImages are up to date.");
|
||||
} else {
|
||||
println!("{} update{} available.", updates_found, if updates_found == 1 { "" } else { "s" });
|
||||
}
|
||||
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
fn cmd_duplicates(db: &Database) -> ExitCode {
|
||||
let groups = duplicates::detect_duplicates(db);
|
||||
|
||||
if groups.is_empty() {
|
||||
println!("No duplicate or multi-version AppImages found.");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
|
||||
let summary = duplicates::summarize_duplicates(&groups);
|
||||
println!(
|
||||
"Found {} duplicate groups ({} exact, {} multi-version)",
|
||||
summary.total_groups,
|
||||
summary.exact_duplicates,
|
||||
summary.multi_version,
|
||||
);
|
||||
println!(
|
||||
"Potential savings: {}",
|
||||
humansize::format_size(summary.total_potential_savings, humansize::BINARY),
|
||||
);
|
||||
println!();
|
||||
|
||||
for group in &groups {
|
||||
println!(" {} ({})", group.app_name, group.match_reason.label());
|
||||
for member in &group.members {
|
||||
let r = &member.record;
|
||||
let version = r.app_version.as_deref().unwrap_or("?");
|
||||
let size = humansize::format_size(r.size_bytes as u64, humansize::BINARY);
|
||||
let rec = member.recommendation.label();
|
||||
println!(" {} v{} ({}) - {}", r.path, version, size, rec);
|
||||
}
|
||||
if group.potential_savings > 0 {
|
||||
println!(
|
||||
" Savings: {}",
|
||||
humansize::format_size(group.potential_savings, humansize::BINARY),
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
fn cmd_launch(db: &Database, path: &str) -> ExitCode {
|
||||
let file_path = std::path::Path::new(path);
|
||||
if !file_path.exists() {
|
||||
eprintln!("Error: file not found: {}", path);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
// Try to find in database for tracking
|
||||
let record = db.get_appimage_by_path(path).ok().flatten();
|
||||
|
||||
if let Some(ref record) = record {
|
||||
match launcher::launch_appimage(db, record.id, file_path, "cli", &[], &[]) {
|
||||
launcher::LaunchResult::Started { method, .. } => {
|
||||
println!(
|
||||
"Launched {} ({})",
|
||||
record.app_name.as_deref().unwrap_or(&record.filename),
|
||||
method.as_str(),
|
||||
);
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
launcher::LaunchResult::Failed(msg) => {
|
||||
eprintln!("Error: {}", msg);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not in database - launch without tracking
|
||||
match launcher::launch_appimage_simple(file_path, &[]) {
|
||||
launcher::LaunchResult::Started { method, .. } => {
|
||||
println!("Launched {} ({})", path, method.as_str());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
launcher::LaunchResult::Failed(msg) => {
|
||||
eprintln!("Error: {}", msg);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn do_inspect(path: &std::path::Path, appimage_type: &discovery::AppImageType) -> ExitCode {
|
||||
println!("Inspecting: {}", path.display());
|
||||
println!("Type: {:?}", appimage_type);
|
||||
|
||||
match inspector::inspect_appimage(path, appimage_type) {
|
||||
Ok(metadata) => {
|
||||
println!("Name: {}", metadata.app_name.as_deref().unwrap_or("(unknown)"));
|
||||
println!("Version: {}", metadata.app_version.as_deref().unwrap_or("(unknown)"));
|
||||
if let Some(ref desc) = metadata.description {
|
||||
println!("Description: {}", desc);
|
||||
}
|
||||
if let Some(ref arch) = metadata.architecture {
|
||||
println!("Architecture: {}", arch);
|
||||
}
|
||||
if !metadata.categories.is_empty() {
|
||||
println!("Categories: {}", metadata.categories.join(", "));
|
||||
}
|
||||
if let Some(ref icon) = metadata.cached_icon_path {
|
||||
println!("Icon: {}", icon.display());
|
||||
}
|
||||
if !metadata.desktop_entry_content.is_empty() {
|
||||
println!();
|
||||
println!("--- Desktop Entry ---");
|
||||
println!("{}", metadata.desktop_entry_content);
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Inspection failed: {}", e);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/config.rs
Normal file
3
src/config.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub const APP_ID: &str = "app.driftwood.Driftwood";
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub const GSETTINGS_SCHEMA_DIR: &str = env!("GSETTINGS_SCHEMA_DIR");
|
||||
777
src/core/database.rs
Normal file
777
src/core/database.rs
Normal file
@@ -0,0 +1,777 @@
|
||||
use rusqlite::{params, Connection, Result as SqlResult};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct Database {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppImageRecord {
|
||||
pub id: i64,
|
||||
pub path: String,
|
||||
pub filename: String,
|
||||
pub app_name: Option<String>,
|
||||
pub app_version: Option<String>,
|
||||
pub appimage_type: Option<i32>,
|
||||
pub size_bytes: i64,
|
||||
pub sha256: Option<String>,
|
||||
pub icon_path: Option<String>,
|
||||
pub desktop_file: Option<String>,
|
||||
pub integrated: bool,
|
||||
pub integrated_at: Option<String>,
|
||||
pub is_executable: bool,
|
||||
pub desktop_entry_content: Option<String>,
|
||||
pub categories: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub developer: Option<String>,
|
||||
pub architecture: Option<String>,
|
||||
pub first_seen: String,
|
||||
pub last_scanned: String,
|
||||
pub file_modified: Option<String>,
|
||||
// Phase 2 fields
|
||||
pub fuse_status: Option<String>,
|
||||
pub wayland_status: Option<String>,
|
||||
pub update_info: Option<String>,
|
||||
pub update_type: Option<String>,
|
||||
pub latest_version: Option<String>,
|
||||
pub update_checked: Option<String>,
|
||||
pub update_url: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OrphanedEntry {
|
||||
pub id: i64,
|
||||
pub desktop_file: String,
|
||||
pub original_path: Option<String>,
|
||||
pub app_name: Option<String>,
|
||||
pub detected_at: String,
|
||||
pub cleaned: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaunchEvent {
|
||||
pub id: i64,
|
||||
pub appimage_id: i64,
|
||||
pub launched_at: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpdateHistoryEntry {
|
||||
pub id: i64,
|
||||
pub appimage_id: i64,
|
||||
pub from_version: Option<String>,
|
||||
pub to_version: Option<String>,
|
||||
pub update_method: Option<String>,
|
||||
pub download_size: Option<i64>,
|
||||
pub updated_at: String,
|
||||
pub success: bool,
|
||||
}
|
||||
|
||||
fn db_path() -> PathBuf {
|
||||
let data_dir = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
.join("driftwood");
|
||||
std::fs::create_dir_all(&data_dir).ok();
|
||||
data_dir.join("driftwood.db")
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn open() -> SqlResult<Self> {
|
||||
let path = db_path();
|
||||
let conn = Connection::open(&path)?;
|
||||
let db = Self { conn };
|
||||
db.init_schema()?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
pub fn open_in_memory() -> SqlResult<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
let db = Self { conn };
|
||||
db.init_schema()?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
fn init_schema(&self) -> SqlResult<()> {
|
||||
// Phase 1 base tables
|
||||
self.conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS appimages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
filename TEXT NOT NULL,
|
||||
app_name TEXT,
|
||||
app_version TEXT,
|
||||
appimage_type INTEGER,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
sha256 TEXT,
|
||||
icon_path TEXT,
|
||||
desktop_file TEXT,
|
||||
integrated INTEGER NOT NULL DEFAULT 0,
|
||||
integrated_at TEXT,
|
||||
is_executable INTEGER NOT NULL DEFAULT 0,
|
||||
desktop_entry_content TEXT,
|
||||
categories TEXT,
|
||||
description TEXT,
|
||||
developer TEXT,
|
||||
architecture TEXT,
|
||||
first_seen TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_scanned TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
file_modified TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orphaned_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
desktop_file TEXT NOT NULL,
|
||||
original_path TEXT,
|
||||
app_name TEXT,
|
||||
detected_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
cleaned INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scan_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scan_type TEXT NOT NULL,
|
||||
directories TEXT,
|
||||
found INTEGER NOT NULL DEFAULT 0,
|
||||
new_count INTEGER NOT NULL DEFAULT 0,
|
||||
removed INTEGER NOT NULL DEFAULT 0,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
scanned_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);"
|
||||
)?;
|
||||
|
||||
// Check current schema version and migrate
|
||||
let count: i32 = self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM schema_version",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
let current_version = if count == 0 {
|
||||
self.conn.execute(
|
||||
"INSERT INTO schema_version (version) VALUES (?1)",
|
||||
params![1],
|
||||
)?;
|
||||
1
|
||||
} else {
|
||||
self.conn.query_row(
|
||||
"SELECT version FROM schema_version LIMIT 1",
|
||||
[],
|
||||
|row| row.get::<_, i32>(0),
|
||||
)?
|
||||
};
|
||||
|
||||
if current_version < 2 {
|
||||
self.migrate_to_v2()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_to_v2(&self) -> SqlResult<()> {
|
||||
// Add Phase 2 columns to appimages table
|
||||
let phase2_columns = [
|
||||
"fuse_status TEXT",
|
||||
"wayland_status TEXT",
|
||||
"update_info TEXT",
|
||||
"update_type TEXT",
|
||||
"latest_version TEXT",
|
||||
"update_checked TEXT",
|
||||
"update_url TEXT",
|
||||
"notes TEXT",
|
||||
];
|
||||
for col in &phase2_columns {
|
||||
let sql = format!("ALTER TABLE appimages ADD COLUMN {}", col);
|
||||
// Ignore errors from columns that already exist
|
||||
self.conn.execute_batch(&sql).ok();
|
||||
}
|
||||
|
||||
// Phase 2 tables
|
||||
self.conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS launch_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE,
|
||||
launched_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
source TEXT NOT NULL DEFAULT 'desktop_entry'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS update_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE,
|
||||
from_version TEXT,
|
||||
to_version TEXT,
|
||||
update_method TEXT,
|
||||
download_size INTEGER,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
success INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS duplicate_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
canonical_name TEXT NOT NULL,
|
||||
duplicate_type TEXT,
|
||||
detected_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS duplicate_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER REFERENCES duplicate_groups(id) ON DELETE CASCADE,
|
||||
appimage_id INTEGER REFERENCES appimages(id) ON DELETE CASCADE,
|
||||
is_recommended INTEGER NOT NULL DEFAULT 0
|
||||
);"
|
||||
)?;
|
||||
|
||||
// Update schema version
|
||||
self.conn.execute(
|
||||
"UPDATE schema_version SET version = ?1",
|
||||
params![2],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_appimage(
|
||||
&self,
|
||||
path: &str,
|
||||
filename: &str,
|
||||
appimage_type: Option<i32>,
|
||||
size_bytes: i64,
|
||||
is_executable: bool,
|
||||
file_modified: Option<&str>,
|
||||
) -> SqlResult<i64> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO appimages (path, filename, appimage_type, size_bytes, is_executable, file_modified)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
filename = excluded.filename,
|
||||
appimage_type = excluded.appimage_type,
|
||||
size_bytes = excluded.size_bytes,
|
||||
is_executable = excluded.is_executable,
|
||||
file_modified = excluded.file_modified,
|
||||
last_scanned = datetime('now')",
|
||||
params![path, filename, appimage_type, size_bytes, is_executable, file_modified],
|
||||
)?;
|
||||
Ok(self.conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn update_metadata(
|
||||
&self,
|
||||
id: i64,
|
||||
app_name: Option<&str>,
|
||||
app_version: Option<&str>,
|
||||
description: Option<&str>,
|
||||
developer: Option<&str>,
|
||||
categories: Option<&str>,
|
||||
architecture: Option<&str>,
|
||||
icon_path: Option<&str>,
|
||||
desktop_entry_content: Option<&str>,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE appimages SET
|
||||
app_name = ?2,
|
||||
app_version = ?3,
|
||||
description = ?4,
|
||||
developer = ?5,
|
||||
categories = ?6,
|
||||
architecture = ?7,
|
||||
icon_path = ?8,
|
||||
desktop_entry_content = ?9
|
||||
WHERE id = ?1",
|
||||
params![
|
||||
id, app_name, app_version, description, developer,
|
||||
categories, architecture, icon_path, desktop_entry_content,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_sha256(&self, id: i64, sha256: &str) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE appimages SET sha256 = ?2 WHERE id = ?1",
|
||||
params![id, sha256],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_integrated(
|
||||
&self,
|
||||
id: i64,
|
||||
integrated: bool,
|
||||
desktop_file: Option<&str>,
|
||||
) -> SqlResult<()> {
|
||||
let integrated_at = if integrated {
|
||||
Some(chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.conn.execute(
|
||||
"UPDATE appimages SET integrated = ?2, desktop_file = ?3, integrated_at = ?4 WHERE id = ?1",
|
||||
params![id, integrated, desktop_file, integrated_at],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const APPIMAGE_COLUMNS: &str =
|
||||
"id, path, filename, app_name, app_version, appimage_type,
|
||||
size_bytes, sha256, icon_path, desktop_file, integrated,
|
||||
integrated_at, is_executable, desktop_entry_content,
|
||||
categories, description, developer, architecture,
|
||||
first_seen, last_scanned, file_modified,
|
||||
fuse_status, wayland_status, update_info, update_type,
|
||||
latest_version, update_checked, update_url, notes";
|
||||
|
||||
fn row_to_record(row: &rusqlite::Row) -> rusqlite::Result<AppImageRecord> {
|
||||
Ok(AppImageRecord {
|
||||
id: row.get(0)?,
|
||||
path: row.get(1)?,
|
||||
filename: row.get(2)?,
|
||||
app_name: row.get(3)?,
|
||||
app_version: row.get(4)?,
|
||||
appimage_type: row.get(5)?,
|
||||
size_bytes: row.get(6)?,
|
||||
sha256: row.get(7)?,
|
||||
icon_path: row.get(8)?,
|
||||
desktop_file: row.get(9)?,
|
||||
integrated: row.get(10)?,
|
||||
integrated_at: row.get(11)?,
|
||||
is_executable: row.get(12)?,
|
||||
desktop_entry_content: row.get(13)?,
|
||||
categories: row.get(14)?,
|
||||
description: row.get(15)?,
|
||||
developer: row.get(16)?,
|
||||
architecture: row.get(17)?,
|
||||
first_seen: row.get(18)?,
|
||||
last_scanned: row.get(19)?,
|
||||
file_modified: row.get(20)?,
|
||||
fuse_status: row.get(21)?,
|
||||
wayland_status: row.get(22)?,
|
||||
update_info: row.get(23)?,
|
||||
update_type: row.get(24)?,
|
||||
latest_version: row.get(25)?,
|
||||
update_checked: row.get(26)?,
|
||||
update_url: row.get(27)?,
|
||||
notes: row.get(28)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_all_appimages(&self) -> SqlResult<Vec<AppImageRecord>> {
|
||||
let sql = format!(
|
||||
"SELECT {} FROM appimages ORDER BY app_name COLLATE NOCASE, filename",
|
||||
Self::APPIMAGE_COLUMNS
|
||||
);
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map([], Self::row_to_record)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn get_appimage_by_id(&self, id: i64) -> SqlResult<Option<AppImageRecord>> {
|
||||
let sql = format!(
|
||||
"SELECT {} FROM appimages WHERE id = ?1",
|
||||
Self::APPIMAGE_COLUMNS
|
||||
);
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let mut rows = stmt.query_map(params![id], Self::row_to_record)?;
|
||||
Ok(rows.next().transpose()?)
|
||||
}
|
||||
|
||||
pub fn get_appimage_by_path(&self, path: &str) -> SqlResult<Option<AppImageRecord>> {
|
||||
let sql = format!(
|
||||
"SELECT {} FROM appimages WHERE path = ?1",
|
||||
Self::APPIMAGE_COLUMNS
|
||||
);
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let mut rows = stmt.query_map(params![path], Self::row_to_record)?;
|
||||
Ok(rows.next().transpose()?)
|
||||
}
|
||||
|
||||
pub fn remove_appimage(&self, id: i64) -> SqlResult<()> {
|
||||
self.conn.execute("DELETE FROM appimages WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_missing_appimages(&self) -> SqlResult<Vec<AppImageRecord>> {
|
||||
let all = self.get_all_appimages()?;
|
||||
let mut removed = Vec::new();
|
||||
for record in all {
|
||||
if !std::path::Path::new(&record.path).exists() {
|
||||
self.remove_appimage(record.id)?;
|
||||
removed.push(record);
|
||||
}
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
pub fn add_orphaned_entry(
|
||||
&self,
|
||||
desktop_file: &str,
|
||||
original_path: Option<&str>,
|
||||
app_name: Option<&str>,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO orphaned_entries (desktop_file, original_path, app_name) VALUES (?1, ?2, ?3)",
|
||||
params![desktop_file, original_path, app_name],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_orphaned_entries(&self) -> SqlResult<Vec<OrphanedEntry>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, desktop_file, original_path, app_name, detected_at, cleaned
|
||||
FROM orphaned_entries WHERE cleaned = 0"
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(OrphanedEntry {
|
||||
id: row.get(0)?,
|
||||
desktop_file: row.get(1)?,
|
||||
original_path: row.get(2)?,
|
||||
app_name: row.get(3)?,
|
||||
detected_at: row.get(4)?,
|
||||
cleaned: row.get(5)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn mark_orphan_cleaned(&self, id: i64) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE orphaned_entries SET cleaned = 1 WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn log_scan(
|
||||
&self,
|
||||
scan_type: &str,
|
||||
directories: &[String],
|
||||
found: i32,
|
||||
new_count: i32,
|
||||
removed: i32,
|
||||
duration_ms: i64,
|
||||
) -> SqlResult<()> {
|
||||
let dirs_joined = directories.join(";");
|
||||
self.conn.execute(
|
||||
"INSERT INTO scan_log (scan_type, directories, found, new_count, removed, duration_ms)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![scan_type, dirs_joined, found, new_count, removed, duration_ms],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn appimage_count(&self) -> SqlResult<i64> {
|
||||
self.conn.query_row("SELECT COUNT(*) FROM appimages", [], |row| row.get(0))
|
||||
}
|
||||
|
||||
// --- Phase 2: Status updates ---
|
||||
|
||||
pub fn update_fuse_status(&self, id: i64, status: &str) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE appimages SET fuse_status = ?2 WHERE id = ?1",
|
||||
params![id, status],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_wayland_status(&self, id: i64, status: &str) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE appimages SET wayland_status = ?2 WHERE id = ?1",
|
||||
params![id, status],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_update_info(
|
||||
&self,
|
||||
id: i64,
|
||||
update_info: Option<&str>,
|
||||
update_type: Option<&str>,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE appimages SET update_info = ?2, update_type = ?3 WHERE id = ?1",
|
||||
params![id, update_info, update_type],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_update_available(
|
||||
&self,
|
||||
id: i64,
|
||||
latest_version: Option<&str>,
|
||||
update_url: Option<&str>,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE appimages SET latest_version = ?2, update_url = ?3,
|
||||
update_checked = datetime('now') WHERE id = ?1",
|
||||
params![id, latest_version, update_url],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_update_available(&self, id: i64) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE appimages SET latest_version = NULL, update_url = NULL,
|
||||
update_checked = datetime('now') WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_appimages_with_updates(&self) -> SqlResult<Vec<AppImageRecord>> {
|
||||
let sql = format!(
|
||||
"SELECT {} FROM appimages WHERE latest_version IS NOT NULL
|
||||
ORDER BY app_name COLLATE NOCASE, filename",
|
||||
Self::APPIMAGE_COLUMNS
|
||||
);
|
||||
let mut stmt = self.conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map([], Self::row_to_record)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
// --- Phase 2: Launch tracking ---
|
||||
|
||||
pub fn record_launch(&self, appimage_id: i64, source: &str) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO launch_events (appimage_id, source) VALUES (?1, ?2)",
|
||||
params![appimage_id, source],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_launch_count(&self, appimage_id: i64) -> SqlResult<i64> {
|
||||
self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM launch_events WHERE appimage_id = ?1",
|
||||
params![appimage_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_last_launched(&self, appimage_id: i64) -> SqlResult<Option<String>> {
|
||||
self.conn.query_row(
|
||||
"SELECT MAX(launched_at) FROM launch_events WHERE appimage_id = ?1",
|
||||
params![appimage_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_launch_events(&self, appimage_id: i64) -> SqlResult<Vec<LaunchEvent>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, appimage_id, launched_at, source
|
||||
FROM launch_events WHERE appimage_id = ?1
|
||||
ORDER BY launched_at DESC"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![appimage_id], |row| {
|
||||
Ok(LaunchEvent {
|
||||
id: row.get(0)?,
|
||||
appimage_id: row.get(1)?,
|
||||
launched_at: row.get(2)?,
|
||||
source: row.get(3)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
// --- Phase 2: Update history ---
|
||||
|
||||
pub fn record_update(
|
||||
&self,
|
||||
appimage_id: i64,
|
||||
from_version: Option<&str>,
|
||||
to_version: Option<&str>,
|
||||
update_method: Option<&str>,
|
||||
download_size: Option<i64>,
|
||||
success: bool,
|
||||
) -> SqlResult<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO update_history
|
||||
(appimage_id, from_version, to_version, update_method, download_size, success)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![appimage_id, from_version, to_version, update_method, download_size, success],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_update_history(&self, appimage_id: i64) -> SqlResult<Vec<UpdateHistoryEntry>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, appimage_id, from_version, to_version, update_method,
|
||||
download_size, updated_at, success
|
||||
FROM update_history WHERE appimage_id = ?1
|
||||
ORDER BY updated_at DESC"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![appimage_id], |row| {
|
||||
Ok(UpdateHistoryEntry {
|
||||
id: row.get(0)?,
|
||||
appimage_id: row.get(1)?,
|
||||
from_version: row.get(2)?,
|
||||
to_version: row.get(3)?,
|
||||
update_method: row.get(4)?,
|
||||
download_size: row.get(5)?,
|
||||
updated_at: row.get(6)?,
|
||||
success: row.get(7)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_create_and_query() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
assert_eq!(db.appimage_count().unwrap(), 0);
|
||||
|
||||
db.upsert_appimage(
|
||||
"/home/user/Apps/test.AppImage",
|
||||
"test.AppImage",
|
||||
Some(2),
|
||||
1024000,
|
||||
true,
|
||||
None,
|
||||
).unwrap();
|
||||
|
||||
assert_eq!(db.appimage_count().unwrap(), 1);
|
||||
|
||||
let all = db.get_all_appimages().unwrap();
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].filename, "test.AppImage");
|
||||
assert_eq!(all[0].size_bytes, 1024000);
|
||||
assert!(all[0].is_executable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upsert_updates_existing() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.upsert_appimage("/path/test.AppImage", "test.AppImage", Some(2), 1000, true, None).unwrap();
|
||||
db.upsert_appimage("/path/test.AppImage", "test.AppImage", Some(2), 2000, true, None).unwrap();
|
||||
|
||||
assert_eq!(db.appimage_count().unwrap(), 1);
|
||||
let record = db.get_appimage_by_path("/path/test.AppImage").unwrap().unwrap();
|
||||
assert_eq!(record.size_bytes, 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_metadata_update() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let id = db.upsert_appimage("/path/test.AppImage", "test.AppImage", Some(2), 1000, true, None).unwrap();
|
||||
|
||||
db.update_metadata(
|
||||
id,
|
||||
Some("Test App"),
|
||||
Some("1.0.0"),
|
||||
Some("A test application"),
|
||||
Some("Test Dev"),
|
||||
Some("Utility;Development"),
|
||||
Some("x86_64"),
|
||||
Some("/path/to/icon.png"),
|
||||
Some("[Desktop Entry]\nName=Test App"),
|
||||
).unwrap();
|
||||
|
||||
let record = db.get_appimage_by_id(id).unwrap().unwrap();
|
||||
assert_eq!(record.app_name.as_deref(), Some("Test App"));
|
||||
assert_eq!(record.app_version.as_deref(), Some("1.0.0"));
|
||||
assert_eq!(record.architecture.as_deref(), Some("x86_64"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_integration_toggle() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let id = db.upsert_appimage("/path/test.AppImage", "test.AppImage", Some(2), 1000, true, None).unwrap();
|
||||
|
||||
assert!(!db.get_appimage_by_id(id).unwrap().unwrap().integrated);
|
||||
|
||||
db.set_integrated(id, true, Some("/path/to/desktop")).unwrap();
|
||||
let record = db.get_appimage_by_id(id).unwrap().unwrap();
|
||||
assert!(record.integrated);
|
||||
assert!(record.integrated_at.is_some());
|
||||
|
||||
db.set_integrated(id, false, None).unwrap();
|
||||
assert!(!db.get_appimage_by_id(id).unwrap().unwrap().integrated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_orphaned_entries() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.add_orphaned_entry("/path/to/desktop", Some("/path/app.AppImage"), Some("App")).unwrap();
|
||||
|
||||
let orphans = db.get_orphaned_entries().unwrap();
|
||||
assert_eq!(orphans.len(), 1);
|
||||
assert_eq!(orphans[0].app_name.as_deref(), Some("App"));
|
||||
|
||||
db.mark_orphan_cleaned(orphans[0].id).unwrap();
|
||||
assert_eq!(db.get_orphaned_entries().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_log() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.log_scan("manual", &["~/Applications".into()], 5, 3, 0, 250).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_phase2_status_updates() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let id = db.upsert_appimage("/path/app.AppImage", "app.AppImage", Some(2), 1000, true, None).unwrap();
|
||||
|
||||
db.update_fuse_status(id, "fully_functional").unwrap();
|
||||
db.update_wayland_status(id, "native").unwrap();
|
||||
db.update_update_info(id, Some("gh-releases-zsync|user|repo|latest|*.zsync"), Some("github")).unwrap();
|
||||
db.set_update_available(id, Some("2.0.0"), Some("https://example.com/app-2.0.AppImage")).unwrap();
|
||||
|
||||
let record = db.get_appimage_by_id(id).unwrap().unwrap();
|
||||
assert_eq!(record.fuse_status.as_deref(), Some("fully_functional"));
|
||||
assert_eq!(record.wayland_status.as_deref(), Some("native"));
|
||||
assert_eq!(record.update_type.as_deref(), Some("github"));
|
||||
assert_eq!(record.latest_version.as_deref(), Some("2.0.0"));
|
||||
assert!(record.update_checked.is_some());
|
||||
|
||||
// Updates available query
|
||||
let with_updates = db.get_appimages_with_updates().unwrap();
|
||||
assert_eq!(with_updates.len(), 1);
|
||||
|
||||
db.clear_update_available(id).unwrap();
|
||||
let record = db.get_appimage_by_id(id).unwrap().unwrap();
|
||||
assert!(record.latest_version.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_launch_tracking() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let id = db.upsert_appimage("/path/app.AppImage", "app.AppImage", Some(2), 1000, true, None).unwrap();
|
||||
|
||||
assert_eq!(db.get_launch_count(id).unwrap(), 0);
|
||||
assert!(db.get_last_launched(id).unwrap().is_none());
|
||||
|
||||
db.record_launch(id, "desktop_entry").unwrap();
|
||||
db.record_launch(id, "cli").unwrap();
|
||||
|
||||
assert_eq!(db.get_launch_count(id).unwrap(), 2);
|
||||
assert!(db.get_last_launched(id).unwrap().is_some());
|
||||
|
||||
let events = db.get_launch_events(id).unwrap();
|
||||
assert_eq!(events.len(), 2);
|
||||
let sources: Vec<&str> = events.iter().map(|e| e.source.as_str()).collect();
|
||||
assert!(sources.contains(&"desktop_entry"));
|
||||
assert!(sources.contains(&"cli"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_history() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
let id = db.upsert_appimage("/path/app.AppImage", "app.AppImage", Some(2), 1000, true, None).unwrap();
|
||||
|
||||
db.record_update(id, Some("1.0"), Some("2.0"), Some("full_download"), Some(50_000_000), true).unwrap();
|
||||
|
||||
let history = db.get_update_history(id).unwrap();
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(history[0].from_version.as_deref(), Some("1.0"));
|
||||
assert_eq!(history[0].to_version.as_deref(), Some("2.0"));
|
||||
assert!(history[0].success);
|
||||
}
|
||||
}
|
||||
238
src/core/discovery.rs
Normal file
238
src/core/discovery.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
use std::fs::{self, File};
|
||||
use std::io::Read;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AppImageType {
|
||||
Type1,
|
||||
Type2,
|
||||
}
|
||||
|
||||
impl AppImageType {
|
||||
pub fn as_i32(&self) -> i32 {
|
||||
match self {
|
||||
Self::Type1 => 1,
|
||||
Self::Type2 => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveredAppImage {
|
||||
pub path: PathBuf,
|
||||
pub filename: String,
|
||||
pub appimage_type: AppImageType,
|
||||
pub size_bytes: u64,
|
||||
pub modified_time: Option<SystemTime>,
|
||||
pub is_executable: bool,
|
||||
}
|
||||
|
||||
/// Expand ~ to home directory.
|
||||
pub fn expand_tilde(path: &str) -> PathBuf {
|
||||
if let Some(rest) = path.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(rest);
|
||||
}
|
||||
}
|
||||
if path == "~" {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home;
|
||||
}
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
/// Check a single file for AppImage magic bytes.
|
||||
/// ELF magic at offset 0: 0x7F 'E' 'L' 'F'
|
||||
/// AppImage Type 2 at offset 8: 'A' 'I' 0x02
|
||||
/// AppImage Type 1 at offset 8: 'A' 'I' 0x01
|
||||
fn detect_appimage(path: &Path) -> Option<AppImageType> {
|
||||
let mut file = File::open(path).ok()?;
|
||||
let mut header = [0u8; 16];
|
||||
file.read_exact(&mut header).ok()?;
|
||||
|
||||
// Check ELF magic
|
||||
if header[0..4] != [0x7F, 0x45, 0x4C, 0x46] {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Check AppImage magic at offset 8
|
||||
if header[8] == 0x41 && header[9] == 0x49 {
|
||||
match header[10] {
|
||||
0x02 => return Some(AppImageType::Type2),
|
||||
0x01 => return Some(AppImageType::Type1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Scan a single directory for AppImage files (non-recursive).
|
||||
fn scan_directory(dir: &Path) -> Vec<DiscoveredAppImage> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
log::warn!("Cannot read directory {}: {}", dir.display(), e);
|
||||
return results;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
// Skip directories and symlinks to directories
|
||||
if path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip very small files (AppImages are at least a few KB)
|
||||
let metadata = match fs::metadata(&path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if metadata.len() < 4096 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for AppImage magic bytes
|
||||
if let Some(appimage_type) = detect_appimage(&path) {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
let is_executable = metadata.permissions().mode() & 0o111 != 0;
|
||||
let modified_time = metadata.modified().ok();
|
||||
|
||||
results.push(DiscoveredAppImage {
|
||||
path,
|
||||
filename,
|
||||
appimage_type,
|
||||
size_bytes: metadata.len(),
|
||||
modified_time,
|
||||
is_executable,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// Scan all configured directories for AppImages.
|
||||
/// Directories are expanded (~ -> home) and deduplicated.
|
||||
pub fn scan_directories(dirs: &[String]) -> Vec<DiscoveredAppImage> {
|
||||
let mut results = Vec::new();
|
||||
let mut seen_paths = std::collections::HashSet::new();
|
||||
|
||||
for dir_str in dirs {
|
||||
let dir = expand_tilde(dir_str);
|
||||
if !dir.exists() {
|
||||
log::info!("Scan directory does not exist: {}", dir.display());
|
||||
continue;
|
||||
}
|
||||
if !dir.is_dir() {
|
||||
log::warn!("Scan path is not a directory: {}", dir.display());
|
||||
continue;
|
||||
}
|
||||
|
||||
for discovered in scan_directory(&dir) {
|
||||
// Deduplicate by canonical path
|
||||
let canonical = discovered.path.canonicalize()
|
||||
.unwrap_or_else(|_| discovered.path.clone());
|
||||
if seen_paths.insert(canonical) {
|
||||
results.push(discovered);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn create_fake_appimage(dir: &Path, name: &str, appimage_type: u8) -> PathBuf {
|
||||
let path = dir.join(name);
|
||||
let mut f = File::create(&path).unwrap();
|
||||
|
||||
// ELF magic
|
||||
f.write_all(&[0x7F, 0x45, 0x4C, 0x46]).unwrap();
|
||||
// ELF class, data, version, OS/ABI (padding to offset 8)
|
||||
f.write_all(&[0x02, 0x01, 0x01, 0x00]).unwrap();
|
||||
// AppImage magic at offset 8
|
||||
f.write_all(&[0x41, 0x49, appimage_type]).unwrap();
|
||||
// Pad to make it bigger than 4096 bytes
|
||||
f.write_all(&vec![0u8; 8192]).unwrap();
|
||||
|
||||
// Make executable
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_type2() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = create_fake_appimage(dir.path(), "test.AppImage", 0x02);
|
||||
assert_eq!(detect_appimage(&path), Some(AppImageType::Type2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_type1() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = create_fake_appimage(dir.path(), "test.AppImage", 0x01);
|
||||
assert_eq!(detect_appimage(&path), Some(AppImageType::Type1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_not_appimage() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("not_appimage");
|
||||
let mut f = File::create(&path).unwrap();
|
||||
f.write_all(&[0x7F, 0x45, 0x4C, 0x46, 0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00]).unwrap();
|
||||
f.write_all(&vec![0u8; 8192]).unwrap();
|
||||
assert_eq!(detect_appimage(&path), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_non_elf() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("text.txt");
|
||||
let mut f = File::create(&path).unwrap();
|
||||
f.write_all(b"Hello world, this is not an ELF file at all").unwrap();
|
||||
f.write_all(&vec![0u8; 8192]).unwrap();
|
||||
assert_eq!(detect_appimage(&path), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
create_fake_appimage(dir.path(), "app1.AppImage", 0x02);
|
||||
create_fake_appimage(dir.path(), "app2.AppImage", 0x02);
|
||||
// Create a non-AppImage file
|
||||
let non_ai = dir.path().join("readme.txt");
|
||||
fs::write(&non_ai, &vec![0u8; 8192]).unwrap();
|
||||
|
||||
let results = scan_directory(dir.path());
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(results.iter().all(|r| r.appimage_type == AppImageType::Type2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_tilde() {
|
||||
let expanded = expand_tilde("~/Applications");
|
||||
assert!(!expanded.to_string_lossy().starts_with('~'));
|
||||
assert!(expanded.to_string_lossy().ends_with("Applications"));
|
||||
}
|
||||
}
|
||||
439
src/core/duplicates.rs
Normal file
439
src/core/duplicates.rs
Normal file
@@ -0,0 +1,439 @@
|
||||
use super::database::{AppImageRecord, Database};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A group of AppImages that appear to be the same application.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DuplicateGroup {
|
||||
/// Canonical app name for this group.
|
||||
pub app_name: String,
|
||||
/// All records in this group, sorted by version (newest first).
|
||||
pub members: Vec<DuplicateMember>,
|
||||
/// Reason these were grouped together.
|
||||
pub match_reason: MatchReason,
|
||||
/// Total disk space used by all members.
|
||||
pub total_size: u64,
|
||||
/// Potential space savings if only keeping the newest.
|
||||
pub potential_savings: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DuplicateMember {
|
||||
pub record: AppImageRecord,
|
||||
/// Whether this is the recommended one to keep.
|
||||
pub is_recommended: bool,
|
||||
/// Why we recommend keeping or removing this one.
|
||||
pub recommendation: MemberRecommendation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum MatchReason {
|
||||
/// Same app name, different versions.
|
||||
MultiVersion,
|
||||
/// Same SHA256 hash (exact duplicates in different locations).
|
||||
ExactDuplicate,
|
||||
/// Same app name, same version, different paths.
|
||||
SameVersionDifferentPath,
|
||||
}
|
||||
|
||||
impl MatchReason {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::MultiVersion => "Multiple versions",
|
||||
Self::ExactDuplicate => "Exact duplicates",
|
||||
Self::SameVersionDifferentPath => "Same version, different locations",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum MemberRecommendation {
|
||||
/// This is the newest version - keep it.
|
||||
KeepNewest,
|
||||
/// This is the only integrated copy - keep it.
|
||||
KeepIntegrated,
|
||||
/// Older version that can be removed.
|
||||
RemoveOlder,
|
||||
/// Duplicate that can be removed.
|
||||
RemoveDuplicate,
|
||||
/// No clear recommendation.
|
||||
UserChoice,
|
||||
}
|
||||
|
||||
impl MemberRecommendation {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::KeepNewest => "Keep (newest)",
|
||||
Self::KeepIntegrated => "Keep (integrated)",
|
||||
Self::RemoveOlder => "Remove (older version)",
|
||||
Self::RemoveDuplicate => "Remove (duplicate)",
|
||||
Self::UserChoice => "Your choice",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect duplicate and multi-version AppImages from the database.
|
||||
pub fn detect_duplicates(db: &Database) -> Vec<DuplicateGroup> {
|
||||
let records = match db.get_all_appimages() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::error!("Failed to query appimages for duplicate detection: {}", e);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
if records.len() < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut groups = Vec::new();
|
||||
|
||||
// Phase 1: Find exact duplicates by SHA256 hash
|
||||
let hash_groups = group_by_hash(&records);
|
||||
for (hash, members) in &hash_groups {
|
||||
if members.len() > 1 {
|
||||
groups.push(build_exact_duplicate_group(hash, members));
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Find same app name groups (excluding already-found exact dupes)
|
||||
let exact_dupe_ids: std::collections::HashSet<i64> = groups
|
||||
.iter()
|
||||
.flat_map(|g| g.members.iter().map(|m| m.record.id))
|
||||
.collect();
|
||||
|
||||
let name_groups = group_by_name(&records);
|
||||
for (name, members) in &name_groups {
|
||||
// Skip if all members are already in exact duplicate groups
|
||||
let remaining: Vec<&AppImageRecord> = members
|
||||
.iter()
|
||||
.filter(|r| !exact_dupe_ids.contains(&r.id))
|
||||
.collect();
|
||||
|
||||
if remaining.len() > 1 {
|
||||
groups.push(build_name_group(name, &remaining));
|
||||
}
|
||||
}
|
||||
|
||||
// Sort groups by potential savings (largest first)
|
||||
groups.sort_by(|a, b| b.potential_savings.cmp(&a.potential_savings));
|
||||
|
||||
groups
|
||||
}
|
||||
|
||||
/// Group records by SHA256 hash.
|
||||
fn group_by_hash(records: &[AppImageRecord]) -> HashMap<String, Vec<AppImageRecord>> {
|
||||
let mut map: HashMap<String, Vec<AppImageRecord>> = HashMap::new();
|
||||
for record in records {
|
||||
if let Some(ref hash) = record.sha256 {
|
||||
if !hash.is_empty() {
|
||||
map.entry(hash.clone())
|
||||
.or_default()
|
||||
.push(record.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Group records by normalized app name.
|
||||
fn group_by_name(records: &[AppImageRecord]) -> HashMap<String, Vec<AppImageRecord>> {
|
||||
let mut map: HashMap<String, Vec<AppImageRecord>> = HashMap::new();
|
||||
for record in records {
|
||||
let name = normalize_app_name(record);
|
||||
map.entry(name).or_default().push(record.clone());
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Normalize an app name for grouping purposes.
|
||||
/// Strips version numbers, architecture suffixes, and normalizes case.
|
||||
fn normalize_app_name(record: &AppImageRecord) -> String {
|
||||
let name = record
|
||||
.app_name
|
||||
.as_deref()
|
||||
.unwrap_or(&record.filename);
|
||||
|
||||
// Lowercase and trim
|
||||
let mut normalized = name.to_lowercase().trim().to_string();
|
||||
|
||||
// Remove common suffixes
|
||||
for suffix in &[
|
||||
".appimage",
|
||||
"-x86_64",
|
||||
"-aarch64",
|
||||
"-armhf",
|
||||
"-i386",
|
||||
"-i686",
|
||||
"_x86_64",
|
||||
"_aarch64",
|
||||
] {
|
||||
if let Some(stripped) = normalized.strip_suffix(suffix) {
|
||||
normalized = stripped.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Remove trailing version-like patterns (e.g., "-1.2.3", "_v2.0")
|
||||
if let Some(pos) = find_version_suffix(&normalized) {
|
||||
normalized = normalized[..pos].to_string();
|
||||
}
|
||||
|
||||
// Remove trailing hyphens/underscores
|
||||
normalized = normalized.trim_end_matches(|c: char| c == '-' || c == '_').to_string();
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
/// Find the start position of a trailing version suffix.
|
||||
fn find_version_suffix(s: &str) -> Option<usize> {
|
||||
// Look for patterns like -1.2.3, _v2.0, -24.02.1 at the end
|
||||
let bytes = s.as_bytes();
|
||||
let mut i = bytes.len();
|
||||
|
||||
// Walk backwards past version characters (digits, dots)
|
||||
while i > 0 && (bytes[i - 1].is_ascii_digit() || bytes[i - 1] == b'.') {
|
||||
i -= 1;
|
||||
}
|
||||
|
||||
// Check if we found a version separator
|
||||
if i > 0 && i < bytes.len() {
|
||||
// Skip optional 'v' prefix
|
||||
if i > 0 && bytes[i - 1] == b'v' {
|
||||
i -= 1;
|
||||
}
|
||||
// Must have a separator before the version
|
||||
if i > 0 && (bytes[i - 1] == b'-' || bytes[i - 1] == b'_') {
|
||||
// Verify it looks like a version (has at least one dot)
|
||||
let version_part = &s[i..];
|
||||
if version_part.contains('.') || version_part.starts_with('v') {
|
||||
return Some(i - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Build a DuplicateGroup for exact hash duplicates.
|
||||
fn build_exact_duplicate_group(_hash: &str, records: &[AppImageRecord]) -> DuplicateGroup {
|
||||
let total_size: u64 = records.iter().map(|r| r.size_bytes as u64).sum();
|
||||
|
||||
// Keep the one that's integrated, or the one with the shortest path
|
||||
let keep_idx = records
|
||||
.iter()
|
||||
.position(|r| r.integrated)
|
||||
.unwrap_or(0);
|
||||
|
||||
let members: Vec<DuplicateMember> = records
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| DuplicateMember {
|
||||
record: r.clone(),
|
||||
is_recommended: i == keep_idx,
|
||||
recommendation: if i == keep_idx {
|
||||
if r.integrated {
|
||||
MemberRecommendation::KeepIntegrated
|
||||
} else {
|
||||
MemberRecommendation::UserChoice
|
||||
}
|
||||
} else {
|
||||
MemberRecommendation::RemoveDuplicate
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
let savings = total_size - records[keep_idx].size_bytes as u64;
|
||||
let app_name = records[0]
|
||||
.app_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| records[0].filename.clone());
|
||||
|
||||
DuplicateGroup {
|
||||
app_name,
|
||||
members,
|
||||
match_reason: MatchReason::ExactDuplicate,
|
||||
total_size,
|
||||
potential_savings: savings,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a DuplicateGroup for same-name groups.
|
||||
fn build_name_group(name: &str, records: &[&AppImageRecord]) -> DuplicateGroup {
|
||||
let total_size: u64 = records.iter().map(|r| r.size_bytes as u64).sum();
|
||||
|
||||
// Sort by version (newest first)
|
||||
let mut sorted: Vec<&AppImageRecord> = records.to_vec();
|
||||
sorted.sort_by(|a, b| {
|
||||
let va = a.app_version.as_deref().unwrap_or("0");
|
||||
let vb = b.app_version.as_deref().unwrap_or("0");
|
||||
// Compare versions - newer should come first
|
||||
compare_versions(vb, va)
|
||||
});
|
||||
|
||||
// Determine if this is multi-version or same-version-different-path
|
||||
let versions: std::collections::HashSet<String> = sorted
|
||||
.iter()
|
||||
.filter_map(|r| r.app_version.clone())
|
||||
.collect();
|
||||
|
||||
let match_reason = if versions.len() <= 1 {
|
||||
MatchReason::SameVersionDifferentPath
|
||||
} else {
|
||||
MatchReason::MultiVersion
|
||||
};
|
||||
|
||||
let members: Vec<DuplicateMember> = sorted
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| {
|
||||
let (is_recommended, recommendation) = if i == 0 {
|
||||
// First (newest) version
|
||||
(true, MemberRecommendation::KeepNewest)
|
||||
} else if r.integrated {
|
||||
// Older but integrated
|
||||
(false, MemberRecommendation::KeepIntegrated)
|
||||
} else if match_reason == MatchReason::SameVersionDifferentPath {
|
||||
(false, MemberRecommendation::RemoveDuplicate)
|
||||
} else {
|
||||
(false, MemberRecommendation::RemoveOlder)
|
||||
};
|
||||
|
||||
DuplicateMember {
|
||||
record: (*r).clone(),
|
||||
is_recommended,
|
||||
recommendation,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let savings = if !members.is_empty() {
|
||||
total_size - members[0].record.size_bytes as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Use the prettiest app name from the group
|
||||
let app_name = sorted
|
||||
.iter()
|
||||
.filter_map(|r| r.app_name.as_ref())
|
||||
.next()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| name.to_string());
|
||||
|
||||
DuplicateGroup {
|
||||
app_name,
|
||||
members,
|
||||
match_reason,
|
||||
total_size,
|
||||
potential_savings: savings,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare two version strings for ordering.
|
||||
fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
use super::updater::version_is_newer;
|
||||
|
||||
if a == b {
|
||||
std::cmp::Ordering::Equal
|
||||
} else if version_is_newer(a, b) {
|
||||
std::cmp::Ordering::Greater
|
||||
} else {
|
||||
std::cmp::Ordering::Less
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary of duplicate detection results.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DuplicateSummary {
|
||||
pub total_groups: usize,
|
||||
pub exact_duplicates: usize,
|
||||
pub multi_version: usize,
|
||||
pub total_potential_savings: u64,
|
||||
}
|
||||
|
||||
pub fn summarize_duplicates(groups: &[DuplicateGroup]) -> DuplicateSummary {
|
||||
let exact_duplicates = groups
|
||||
.iter()
|
||||
.filter(|g| g.match_reason == MatchReason::ExactDuplicate)
|
||||
.count();
|
||||
let multi_version = groups
|
||||
.iter()
|
||||
.filter(|g| g.match_reason == MatchReason::MultiVersion)
|
||||
.count();
|
||||
let total_potential_savings: u64 = groups.iter().map(|g| g.potential_savings).sum();
|
||||
|
||||
DuplicateSummary {
|
||||
total_groups: groups.len(),
|
||||
exact_duplicates,
|
||||
multi_version,
|
||||
total_potential_savings,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_normalize_app_name() {
|
||||
let make_record = |name: &str, filename: &str| AppImageRecord {
|
||||
id: 0,
|
||||
path: String::new(),
|
||||
filename: filename.to_string(),
|
||||
app_name: Some(name.to_string()),
|
||||
app_version: None,
|
||||
appimage_type: None,
|
||||
size_bytes: 0,
|
||||
sha256: None,
|
||||
icon_path: None,
|
||||
desktop_file: None,
|
||||
integrated: false,
|
||||
integrated_at: None,
|
||||
is_executable: true,
|
||||
desktop_entry_content: None,
|
||||
categories: None,
|
||||
description: None,
|
||||
developer: None,
|
||||
architecture: None,
|
||||
first_seen: String::new(),
|
||||
last_scanned: String::new(),
|
||||
file_modified: None,
|
||||
fuse_status: None,
|
||||
wayland_status: None,
|
||||
update_info: None,
|
||||
update_type: None,
|
||||
latest_version: None,
|
||||
update_checked: None,
|
||||
update_url: None,
|
||||
notes: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
normalize_app_name(&make_record("Firefox", "Firefox.AppImage")),
|
||||
"firefox"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_app_name(&make_record("Inkscape", "Inkscape-1.3.2-x86_64.AppImage")),
|
||||
"inkscape"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_version_suffix() {
|
||||
assert_eq!(find_version_suffix("firefox-124.0"), Some(7));
|
||||
assert_eq!(find_version_suffix("app-v2.0.0"), Some(3));
|
||||
assert_eq!(find_version_suffix("firefox"), None);
|
||||
assert_eq!(find_version_suffix("app_1.2.3"), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_match_reason_labels() {
|
||||
assert_eq!(MatchReason::MultiVersion.label(), "Multiple versions");
|
||||
assert_eq!(MatchReason::ExactDuplicate.label(), "Exact duplicates");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_member_recommendation_labels() {
|
||||
assert_eq!(MemberRecommendation::KeepNewest.label(), "Keep (newest)");
|
||||
assert_eq!(MemberRecommendation::RemoveOlder.label(), "Remove (older version)");
|
||||
}
|
||||
}
|
||||
355
src/core/fuse.rs
Normal file
355
src/core/fuse.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum FuseStatus {
|
||||
/// libfuse2 available, fusermount present, /dev/fuse exists - fully working
|
||||
FullyFunctional,
|
||||
/// Only libfuse3 installed - most AppImages won't mount natively
|
||||
Fuse3Only,
|
||||
/// fusermount binary not found
|
||||
NoFusermount,
|
||||
/// /dev/fuse device not present (container or WSL)
|
||||
NoDevFuse,
|
||||
/// libfuse2 not installed
|
||||
MissingLibfuse2,
|
||||
}
|
||||
|
||||
impl FuseStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::FullyFunctional => "fully_functional",
|
||||
Self::Fuse3Only => "fuse3_only",
|
||||
Self::NoFusermount => "no_fusermount",
|
||||
Self::NoDevFuse => "no_dev_fuse",
|
||||
Self::MissingLibfuse2 => "missing_libfuse2",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"fully_functional" => Self::FullyFunctional,
|
||||
"fuse3_only" => Self::Fuse3Only,
|
||||
"no_fusermount" => Self::NoFusermount,
|
||||
"no_dev_fuse" => Self::NoDevFuse,
|
||||
_ => Self::MissingLibfuse2,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::FullyFunctional => "OK",
|
||||
Self::Fuse3Only => "FUSE3 only",
|
||||
Self::NoFusermount => "No fusermount",
|
||||
Self::NoDevFuse => "No /dev/fuse",
|
||||
Self::MissingLibfuse2 => "No libfuse2",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn badge_class(&self) -> &'static str {
|
||||
match self {
|
||||
Self::FullyFunctional => "success",
|
||||
Self::Fuse3Only => "warning",
|
||||
Self::NoFusermount | Self::NoDevFuse | Self::MissingLibfuse2 => "error",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_functional(&self) -> bool {
|
||||
matches!(self, Self::FullyFunctional)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FuseSystemInfo {
|
||||
pub status: FuseStatus,
|
||||
pub has_libfuse2: bool,
|
||||
pub has_libfuse3: bool,
|
||||
pub has_fusermount: bool,
|
||||
pub fusermount_path: Option<String>,
|
||||
pub has_dev_fuse: bool,
|
||||
pub install_hint: Option<String>,
|
||||
}
|
||||
|
||||
/// Detect the system FUSE status by checking for libraries, binaries, and device nodes.
|
||||
pub fn detect_system_fuse() -> FuseSystemInfo {
|
||||
let has_libfuse2 = check_library("libfuse.so.2");
|
||||
let has_libfuse3 = check_library("libfuse3.so.3");
|
||||
let fusermount_path = find_fusermount();
|
||||
let has_fusermount = fusermount_path.is_some();
|
||||
let has_dev_fuse = Path::new("/dev/fuse").exists();
|
||||
|
||||
let status = if has_libfuse2 && has_fusermount && has_dev_fuse {
|
||||
FuseStatus::FullyFunctional
|
||||
} else if !has_dev_fuse {
|
||||
FuseStatus::NoDevFuse
|
||||
} else if !has_fusermount {
|
||||
FuseStatus::NoFusermount
|
||||
} else if has_libfuse3 && !has_libfuse2 {
|
||||
FuseStatus::Fuse3Only
|
||||
} else {
|
||||
FuseStatus::MissingLibfuse2
|
||||
};
|
||||
|
||||
let install_hint = if status.is_functional() {
|
||||
None
|
||||
} else {
|
||||
Some(get_install_hint())
|
||||
};
|
||||
|
||||
FuseSystemInfo {
|
||||
status,
|
||||
has_libfuse2,
|
||||
has_libfuse3,
|
||||
has_fusermount,
|
||||
fusermount_path,
|
||||
has_dev_fuse,
|
||||
install_hint,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-AppImage FUSE launch status
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AppImageFuseStatus {
|
||||
/// Will mount natively via FUSE
|
||||
NativeFuse,
|
||||
/// Uses new type2-runtime with static FUSE
|
||||
StaticRuntime,
|
||||
/// Will use extract-and-run fallback (slower startup)
|
||||
ExtractAndRun,
|
||||
/// Cannot launch at all
|
||||
CannotLaunch,
|
||||
}
|
||||
|
||||
impl AppImageFuseStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NativeFuse => "native_fuse",
|
||||
Self::StaticRuntime => "static_runtime",
|
||||
Self::ExtractAndRun => "extract_and_run",
|
||||
Self::CannotLaunch => "cannot_launch",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NativeFuse => "Native FUSE",
|
||||
Self::StaticRuntime => "Static runtime",
|
||||
Self::ExtractAndRun => "Extract & Run",
|
||||
Self::CannotLaunch => "Cannot launch",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn badge_class(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NativeFuse | Self::StaticRuntime => "success",
|
||||
Self::ExtractAndRun => "warning",
|
||||
Self::CannotLaunch => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine launch status for a specific AppImage given system FUSE state.
|
||||
pub fn determine_app_fuse_status(
|
||||
system: &FuseSystemInfo,
|
||||
appimage_path: &Path,
|
||||
) -> AppImageFuseStatus {
|
||||
// Check if the AppImage uses the new static runtime
|
||||
if has_static_runtime(appimage_path) {
|
||||
return AppImageFuseStatus::StaticRuntime;
|
||||
}
|
||||
|
||||
if system.status.is_functional() {
|
||||
return AppImageFuseStatus::NativeFuse;
|
||||
}
|
||||
|
||||
// FUSE not fully functional - check if extract-and-run works
|
||||
if supports_extract_and_run(appimage_path) {
|
||||
AppImageFuseStatus::ExtractAndRun
|
||||
} else {
|
||||
AppImageFuseStatus::CannotLaunch
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the AppImage uses the new type2-runtime with statically linked FUSE.
|
||||
/// The new runtime embeds FUSE support and doesn't need system libfuse.
|
||||
fn has_static_runtime(appimage_path: &Path) -> bool {
|
||||
// The new type2-runtime responds to --appimage-version with a version string
|
||||
// containing "type2-runtime" or a recent date
|
||||
let output = Command::new(appimage_path)
|
||||
.arg("--appimage-version")
|
||||
.env("APPIMAGE_EXTRACT_AND_RUN", "1")
|
||||
.output();
|
||||
|
||||
if let Ok(output) = output {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
// New runtime identifies itself
|
||||
return combined.contains("type2-runtime")
|
||||
|| combined.contains("static")
|
||||
|| combined.contains("libfuse3");
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if --appimage-extract-and-run is supported.
|
||||
fn supports_extract_and_run(appimage_path: &Path) -> bool {
|
||||
// Virtually all Type 2 AppImages support this flag
|
||||
// We check by looking at the appimage type (offset 8 in the file)
|
||||
if let Ok(data) = std::fs::read(appimage_path) {
|
||||
if data.len() > 11 {
|
||||
// Check for AppImage Type 2 magic at offset 8
|
||||
return data[8] == 0x41 && data[9] == 0x49 && data[10] == 0x02;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if a shared library is available on the system via ldconfig.
|
||||
fn check_library(soname: &str) -> bool {
|
||||
let output = Command::new("ldconfig")
|
||||
.arg("-p")
|
||||
.output();
|
||||
|
||||
if let Ok(output) = output {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
return stdout.contains(soname);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Find fusermount or fusermount3 binary.
|
||||
fn find_fusermount() -> Option<String> {
|
||||
for name in &["fusermount", "fusermount3"] {
|
||||
let output = Command::new("which")
|
||||
.arg(name)
|
||||
.output();
|
||||
if let Ok(output) = output {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Detect distro and return the appropriate libfuse2 install command.
|
||||
fn get_install_hint() -> String {
|
||||
if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
|
||||
let id = extract_os_field(&content, "ID");
|
||||
let version_id = extract_os_field(&content, "VERSION_ID");
|
||||
let id_like = extract_os_field(&content, "ID_LIKE");
|
||||
|
||||
return match id.as_deref() {
|
||||
Some("ubuntu") => {
|
||||
let ver: f64 = version_id
|
||||
.as_deref()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0.0);
|
||||
if ver >= 24.04 {
|
||||
"sudo apt install libfuse2t64".to_string()
|
||||
} else {
|
||||
"sudo apt install libfuse2".to_string()
|
||||
}
|
||||
}
|
||||
Some("debian") => "sudo apt install libfuse2".to_string(),
|
||||
Some("fedora") => "sudo dnf install fuse-libs".to_string(),
|
||||
Some("arch") | Some("manjaro") | Some("endeavouros") => {
|
||||
"sudo pacman -S fuse2".to_string()
|
||||
}
|
||||
Some("opensuse-tumbleweed") | Some("opensuse-leap") => {
|
||||
"sudo zypper install libfuse2".to_string()
|
||||
}
|
||||
_ => {
|
||||
// Check ID_LIKE for derivatives
|
||||
if let Some(like) = id_like.as_deref() {
|
||||
if like.contains("ubuntu") || like.contains("debian") {
|
||||
return "sudo apt install libfuse2".to_string();
|
||||
}
|
||||
if like.contains("fedora") {
|
||||
return "sudo dnf install fuse-libs".to_string();
|
||||
}
|
||||
if like.contains("arch") {
|
||||
return "sudo pacman -S fuse2".to_string();
|
||||
}
|
||||
if like.contains("suse") {
|
||||
return "sudo zypper install libfuse2".to_string();
|
||||
}
|
||||
}
|
||||
"Install libfuse2 using your distribution's package manager".to_string()
|
||||
}
|
||||
};
|
||||
}
|
||||
"Install libfuse2 using your distribution's package manager".to_string()
|
||||
}
|
||||
|
||||
fn extract_os_field(content: &str, key: &str) -> Option<String> {
|
||||
for line in content.lines() {
|
||||
if let Some(rest) = line.strip_prefix(&format!("{}=", key)) {
|
||||
return Some(rest.trim_matches('"').to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if AppImageLauncher is installed (known conflicts with new runtime).
|
||||
pub fn detect_appimagelauncher() -> Option<String> {
|
||||
let output = Command::new("dpkg")
|
||||
.args(["-s", "appimagelauncher"])
|
||||
.output();
|
||||
|
||||
if let Ok(output) = output {
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
if let Some(ver) = line.strip_prefix("Version: ") {
|
||||
return Some(ver.trim().to_string());
|
||||
}
|
||||
}
|
||||
return Some("unknown".to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_fuse_status_roundtrip() {
|
||||
let statuses = [
|
||||
FuseStatus::FullyFunctional,
|
||||
FuseStatus::Fuse3Only,
|
||||
FuseStatus::NoFusermount,
|
||||
FuseStatus::NoDevFuse,
|
||||
FuseStatus::MissingLibfuse2,
|
||||
];
|
||||
for status in &statuses {
|
||||
assert_eq!(&FuseStatus::from_str(status.as_str()), status);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_os_field() {
|
||||
let content = r#"NAME="Ubuntu"
|
||||
VERSION_ID="24.04"
|
||||
ID=ubuntu
|
||||
ID_LIKE=debian
|
||||
"#;
|
||||
assert_eq!(extract_os_field(content, "ID"), Some("ubuntu".to_string()));
|
||||
assert_eq!(extract_os_field(content, "VERSION_ID"), Some("24.04".to_string()));
|
||||
assert_eq!(extract_os_field(content, "ID_LIKE"), Some("debian".to_string()));
|
||||
assert_eq!(extract_os_field(content, "MISSING"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuse_status_badges() {
|
||||
assert_eq!(FuseStatus::FullyFunctional.badge_class(), "success");
|
||||
assert_eq!(FuseStatus::Fuse3Only.badge_class(), "warning");
|
||||
assert_eq!(FuseStatus::MissingLibfuse2.badge_class(), "error");
|
||||
}
|
||||
}
|
||||
496
src/core/inspector.rs
Normal file
496
src/core/inspector.rs
Normal file
@@ -0,0 +1,496 @@
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use super::discovery::AppImageType;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum InspectorError {
|
||||
IoError(std::io::Error),
|
||||
NoOffset,
|
||||
UnsquashfsNotFound,
|
||||
UnsquashfsFailed(String),
|
||||
NoDesktopEntry,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InspectorError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::IoError(e) => write!(f, "I/O error: {}", e),
|
||||
Self::NoOffset => write!(f, "Could not determine squashfs offset"),
|
||||
Self::UnsquashfsNotFound => write!(f, "unsquashfs not found - install squashfs-tools"),
|
||||
Self::UnsquashfsFailed(msg) => write!(f, "unsquashfs failed: {}", msg),
|
||||
Self::NoDesktopEntry => write!(f, "No .desktop file found in AppImage"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for InspectorError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
Self::IoError(e)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AppImageMetadata {
|
||||
pub app_name: Option<String>,
|
||||
pub app_version: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub developer: Option<String>,
|
||||
pub icon_name: Option<String>,
|
||||
pub categories: Vec<String>,
|
||||
pub desktop_entry_content: String,
|
||||
pub architecture: Option<String>,
|
||||
pub cached_icon_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct DesktopEntryFields {
|
||||
name: Option<String>,
|
||||
icon: Option<String>,
|
||||
comment: Option<String>,
|
||||
categories: Vec<String>,
|
||||
exec: Option<String>,
|
||||
version: Option<String>,
|
||||
}
|
||||
|
||||
fn icons_cache_dir() -> PathBuf {
|
||||
let dir = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
.join("driftwood")
|
||||
.join("icons");
|
||||
fs::create_dir_all(&dir).ok();
|
||||
dir
|
||||
}
|
||||
|
||||
/// Check if unsquashfs is available.
|
||||
fn has_unsquashfs() -> bool {
|
||||
Command::new("unsquashfs")
|
||||
.arg("--help")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Get the squashfs offset from the AppImage by running it with --appimage-offset.
|
||||
fn get_squashfs_offset(path: &Path) -> Result<u64, InspectorError> {
|
||||
let output = Command::new(path)
|
||||
.arg("--appimage-offset")
|
||||
.env("APPIMAGE_EXTRACT_AND_RUN", "0")
|
||||
.output()?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
stdout
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.map_err(|_| InspectorError::NoOffset)
|
||||
}
|
||||
|
||||
/// Extract specific files from the AppImage squashfs into a temp directory.
|
||||
fn extract_metadata_files(
|
||||
appimage_path: &Path,
|
||||
offset: u64,
|
||||
dest: &Path,
|
||||
) -> Result<(), InspectorError> {
|
||||
let status = Command::new("unsquashfs")
|
||||
.arg("-offset")
|
||||
.arg(offset.to_string())
|
||||
.arg("-no-progress")
|
||||
.arg("-force")
|
||||
.arg("-dest")
|
||||
.arg(dest)
|
||||
.arg(appimage_path)
|
||||
.arg("*.desktop")
|
||||
.arg(".DirIcon")
|
||||
.arg("usr/share/icons/*")
|
||||
.arg("usr/share/metainfo/*.xml")
|
||||
.arg("usr/share/appdata/*.xml")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => Ok(()),
|
||||
Ok(s) => Err(InspectorError::UnsquashfsFailed(
|
||||
format!("exit code {}", s.code().unwrap_or(-1)),
|
||||
)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
Err(InspectorError::UnsquashfsNotFound)
|
||||
}
|
||||
Err(e) => Err(InspectorError::IoError(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Try extraction without offset (for cases where --appimage-offset fails).
|
||||
fn extract_metadata_files_direct(
|
||||
appimage_path: &Path,
|
||||
dest: &Path,
|
||||
) -> Result<(), InspectorError> {
|
||||
let status = Command::new("unsquashfs")
|
||||
.arg("-no-progress")
|
||||
.arg("-force")
|
||||
.arg("-dest")
|
||||
.arg(dest)
|
||||
.arg(appimage_path)
|
||||
.arg("*.desktop")
|
||||
.arg(".DirIcon")
|
||||
.arg("usr/share/icons/*")
|
||||
.arg("usr/share/metainfo/*.xml")
|
||||
.arg("usr/share/appdata/*.xml")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
|
||||
match status {
|
||||
Ok(s) if s.success() => Ok(()),
|
||||
Ok(_) => Err(InspectorError::UnsquashfsFailed(
|
||||
"direct extraction failed".into(),
|
||||
)),
|
||||
Err(e) => Err(InspectorError::IoError(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the first .desktop file in a directory.
|
||||
fn find_desktop_file(dir: &Path) -> Option<PathBuf> {
|
||||
if let Ok(entries) = fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) == Some("desktop") {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse a .desktop file into structured fields.
|
||||
fn parse_desktop_entry(content: &str) -> DesktopEntryFields {
|
||||
let mut fields = DesktopEntryFields::default();
|
||||
let mut in_section = false;
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line == "[Desktop Entry]" {
|
||||
in_section = true;
|
||||
continue;
|
||||
}
|
||||
if line.starts_with('[') {
|
||||
in_section = false;
|
||||
continue;
|
||||
}
|
||||
if !in_section {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
match key {
|
||||
"Name" => fields.name = Some(value.to_string()),
|
||||
"Icon" => fields.icon = Some(value.to_string()),
|
||||
"Comment" => fields.comment = Some(value.to_string()),
|
||||
"Categories" => {
|
||||
fields.categories = value
|
||||
.split(';')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(String::from)
|
||||
.collect();
|
||||
}
|
||||
"Exec" => fields.exec = Some(value.to_string()),
|
||||
"X-AppImage-Version" => fields.version = Some(value.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fields
|
||||
}
|
||||
|
||||
/// Try to extract a version from the filename.
|
||||
/// Common patterns: App-1.2.3-x86_64.AppImage, App_v1.2.3.AppImage
|
||||
fn extract_version_from_filename(filename: &str) -> Option<String> {
|
||||
// Strip .AppImage extension
|
||||
let stem = filename.strip_suffix(".AppImage")
|
||||
.or_else(|| filename.strip_suffix(".appimage"))
|
||||
.unwrap_or(filename);
|
||||
|
||||
// Look for version-like patterns: digits.digits or digits.digits.digits
|
||||
let re_like = |s: &str| -> Option<String> {
|
||||
let mut best: Option<(usize, &str)> = None;
|
||||
for (i, _) in s.match_indices(|c: char| c.is_ascii_digit()) {
|
||||
// Walk back to find start of version (might have leading 'v')
|
||||
let start = if i > 0 && s.as_bytes()[i - 1] == b'v' {
|
||||
i - 1
|
||||
} else {
|
||||
i
|
||||
};
|
||||
|
||||
// Walk forward to consume version string
|
||||
let rest = &s[i..];
|
||||
let end = rest
|
||||
.find(|c: char| !c.is_ascii_digit() && c != '.')
|
||||
.unwrap_or(rest.len());
|
||||
let candidate = &rest[..end];
|
||||
|
||||
// Must contain at least one dot (to be a version, not just a number)
|
||||
if candidate.contains('.') && candidate.len() > 2 {
|
||||
let full = &s[start..i + end];
|
||||
if best.is_none() || full.len() > best.unwrap().1.len() {
|
||||
best = Some((start, full));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, v)| v.to_string())
|
||||
};
|
||||
|
||||
re_like(stem)
|
||||
}
|
||||
|
||||
/// Read the ELF architecture from the header.
|
||||
fn detect_architecture(path: &Path) -> Option<String> {
|
||||
let mut file = fs::File::open(path).ok()?;
|
||||
let mut header = [0u8; 20];
|
||||
file.read_exact(&mut header).ok()?;
|
||||
|
||||
// ELF e_machine at offset 18 (little-endian)
|
||||
let machine = u16::from_le_bytes([header[18], header[19]]);
|
||||
match machine {
|
||||
0x03 => Some("i386".to_string()),
|
||||
0x3E => Some("x86_64".to_string()),
|
||||
0xB7 => Some("aarch64".to_string()),
|
||||
0x28 => Some("armhf".to_string()),
|
||||
_ => Some(format!("unknown(0x{:02X})", machine)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find an icon file in the extracted squashfs directory.
|
||||
fn find_icon(extract_dir: &Path, icon_name: Option<&str>) -> Option<PathBuf> {
|
||||
// First try .DirIcon
|
||||
let dir_icon = extract_dir.join(".DirIcon");
|
||||
if dir_icon.exists() {
|
||||
return Some(dir_icon);
|
||||
}
|
||||
|
||||
// Try icon by name from .desktop
|
||||
if let Some(name) = icon_name {
|
||||
// Check root of extract dir
|
||||
for ext in &["png", "svg", "xpm"] {
|
||||
let candidate = extract_dir.join(format!("{}.{}", name, ext));
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
// Check usr/share/icons recursively
|
||||
let icons_dir = extract_dir.join("usr/share/icons");
|
||||
if icons_dir.exists() {
|
||||
if let Some(found) = find_icon_recursive(&icons_dir, name) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn find_icon_recursive(dir: &Path, name: &str) -> Option<PathBuf> {
|
||||
let entries = fs::read_dir(dir).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(found) = find_icon_recursive(&path, name) {
|
||||
return Some(found);
|
||||
}
|
||||
} else {
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||
if stem == name {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Cache an icon file to the driftwood icons directory.
|
||||
fn cache_icon(source: &Path, app_id: &str) -> Option<PathBuf> {
|
||||
let ext = source
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("png");
|
||||
let dest = icons_cache_dir().join(format!("{}.{}", app_id, ext));
|
||||
fs::copy(source, &dest).ok()?;
|
||||
Some(dest)
|
||||
}
|
||||
|
||||
/// Make a filesystem-safe app ID from a name.
|
||||
fn make_app_id(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == '-' || c == '_' {
|
||||
c.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_matches('-')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Inspect an AppImage and extract its metadata.
|
||||
pub fn inspect_appimage(
|
||||
path: &Path,
|
||||
appimage_type: &AppImageType,
|
||||
) -> Result<AppImageMetadata, InspectorError> {
|
||||
if !has_unsquashfs() {
|
||||
return Err(InspectorError::UnsquashfsNotFound);
|
||||
}
|
||||
|
||||
let temp_dir = tempfile::tempdir()?;
|
||||
let extract_dir = temp_dir.path().join("squashfs-root");
|
||||
|
||||
// Try to extract metadata files
|
||||
let extracted = match appimage_type {
|
||||
AppImageType::Type2 => {
|
||||
match get_squashfs_offset(path) {
|
||||
Ok(offset) => extract_metadata_files(path, offset, &extract_dir),
|
||||
Err(_) => {
|
||||
log::warn!(
|
||||
"Could not get offset for {}, trying direct extraction",
|
||||
path.display()
|
||||
);
|
||||
extract_metadata_files_direct(path, &extract_dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
AppImageType::Type1 => extract_metadata_files_direct(path, &extract_dir),
|
||||
};
|
||||
|
||||
if let Err(e) = extracted {
|
||||
log::warn!("Extraction failed for {}: {}", path.display(), e);
|
||||
// Return minimal metadata from filename/ELF
|
||||
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
return Ok(AppImageMetadata {
|
||||
app_name: Some(
|
||||
filename
|
||||
.strip_suffix(".AppImage")
|
||||
.or_else(|| filename.strip_suffix(".appimage"))
|
||||
.unwrap_or(filename)
|
||||
.split(|c: char| c == '-' || c == '_')
|
||||
.next()
|
||||
.unwrap_or(filename)
|
||||
.to_string(),
|
||||
),
|
||||
app_version: extract_version_from_filename(filename),
|
||||
architecture: detect_architecture(path),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// Find and parse .desktop file
|
||||
let desktop_path = find_desktop_file(&extract_dir)
|
||||
.ok_or(InspectorError::NoDesktopEntry)?;
|
||||
let desktop_content = fs::read_to_string(&desktop_path)?;
|
||||
let fields = parse_desktop_entry(&desktop_content);
|
||||
|
||||
// Determine version (desktop entry > filename heuristic)
|
||||
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
let version = fields
|
||||
.version
|
||||
.or_else(|| extract_version_from_filename(filename));
|
||||
|
||||
// Find and cache icon
|
||||
let icon = find_icon(&extract_dir, fields.icon.as_deref());
|
||||
let app_id = make_app_id(
|
||||
fields.name.as_deref().unwrap_or(
|
||||
filename
|
||||
.strip_suffix(".AppImage")
|
||||
.unwrap_or(filename),
|
||||
),
|
||||
);
|
||||
let cached_icon = icon.and_then(|icon_path| cache_icon(&icon_path, &app_id));
|
||||
|
||||
Ok(AppImageMetadata {
|
||||
app_name: fields.name,
|
||||
app_version: version,
|
||||
description: fields.comment,
|
||||
developer: None,
|
||||
icon_name: fields.icon,
|
||||
categories: fields.categories,
|
||||
desktop_entry_content: desktop_content,
|
||||
architecture: detect_architecture(path),
|
||||
cached_icon_path: cached_icon,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_desktop_entry() {
|
||||
let content = "[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Test App
|
||||
Icon=test-icon
|
||||
Comment=A test application
|
||||
Categories=Utility;Development;
|
||||
Exec=test %U
|
||||
X-AppImage-Version=1.2.3
|
||||
|
||||
[Desktop Action New]
|
||||
Name=New Window
|
||||
";
|
||||
let fields = parse_desktop_entry(content);
|
||||
assert_eq!(fields.name.as_deref(), Some("Test App"));
|
||||
assert_eq!(fields.icon.as_deref(), Some("test-icon"));
|
||||
assert_eq!(fields.comment.as_deref(), Some("A test application"));
|
||||
assert_eq!(fields.categories, vec!["Utility", "Development"]);
|
||||
assert_eq!(fields.exec.as_deref(), Some("test %U"));
|
||||
assert_eq!(fields.version.as_deref(), Some("1.2.3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_from_filename() {
|
||||
assert_eq!(
|
||||
extract_version_from_filename("Firefox-124.0.1-x86_64.AppImage"),
|
||||
Some("124.0.1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_version_from_filename("Kdenlive-24.02.1-x86_64.AppImage"),
|
||||
Some("24.02.1".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_version_from_filename("SimpleApp.AppImage"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
extract_version_from_filename("App_v2.0.0.AppImage"),
|
||||
Some("v2.0.0".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_make_app_id() {
|
||||
assert_eq!(make_app_id("Firefox"), "firefox");
|
||||
assert_eq!(make_app_id("My Cool App"), "my-cool-app");
|
||||
assert_eq!(make_app_id("App 2.0"), "app-2-0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_architecture() {
|
||||
// Create a minimal ELF header for x86_64
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("test_elf");
|
||||
let mut header = vec![0u8; 20];
|
||||
// ELF magic
|
||||
header[0..4].copy_from_slice(&[0x7F, 0x45, 0x4C, 0x46]);
|
||||
// e_machine = 0x3E (x86_64) at offset 18, little-endian
|
||||
header[18] = 0x3E;
|
||||
header[19] = 0x00;
|
||||
fs::write(&path, &header).unwrap();
|
||||
|
||||
assert_eq!(detect_architecture(&path), Some("x86_64".to_string()));
|
||||
}
|
||||
}
|
||||
272
src/core/integrator.rs
Normal file
272
src/core/integrator.rs
Normal file
@@ -0,0 +1,272 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use super::database::AppImageRecord;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum IntegrationError {
|
||||
IoError(std::io::Error),
|
||||
NoAppName,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for IntegrationError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::IoError(e) => write!(f, "I/O error: {}", e),
|
||||
Self::NoAppName => write!(f, "Cannot integrate: no application name"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for IntegrationError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
Self::IoError(e)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct IntegrationResult {
|
||||
pub desktop_file_path: PathBuf,
|
||||
pub icon_install_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn applications_dir() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
.join("applications")
|
||||
}
|
||||
|
||||
fn icons_dir() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
.join("icons/hicolor")
|
||||
}
|
||||
|
||||
/// Generate a sanitized app ID.
|
||||
pub fn make_app_id(app_name: &str) -> String {
|
||||
let id: String = app_name
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == '-' || c == '_' {
|
||||
c.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
id.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
/// Integrate an AppImage: create .desktop file and install icon.
|
||||
pub fn integrate(record: &AppImageRecord) -> Result<IntegrationResult, IntegrationError> {
|
||||
let app_name = record
|
||||
.app_name
|
||||
.as_deref()
|
||||
.or(Some(&record.filename))
|
||||
.ok_or(IntegrationError::NoAppName)?;
|
||||
|
||||
let app_id = make_app_id(app_name);
|
||||
let desktop_filename = format!("driftwood-{}.desktop", app_id);
|
||||
|
||||
let apps_dir = applications_dir();
|
||||
fs::create_dir_all(&apps_dir)?;
|
||||
|
||||
let desktop_path = apps_dir.join(&desktop_filename);
|
||||
|
||||
// Build the .desktop file content
|
||||
let categories = record
|
||||
.categories
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
let comment = record
|
||||
.description
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
let version = record
|
||||
.app_version
|
||||
.as_deref()
|
||||
.unwrap_or("");
|
||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
|
||||
let icon_id = format!("driftwood-{}", app_id);
|
||||
|
||||
let desktop_content = format!(
|
||||
"[Desktop Entry]\n\
|
||||
Type=Application\n\
|
||||
Name={name}\n\
|
||||
Exec={exec} %U\n\
|
||||
Icon={icon}\n\
|
||||
Categories={categories}\n\
|
||||
Comment={comment}\n\
|
||||
Terminal=false\n\
|
||||
X-AppImage-Path={path}\n\
|
||||
X-AppImage-Version={version}\n\
|
||||
X-AppImage-Managed-By=Driftwood\n\
|
||||
X-AppImage-Integrated-Date={date}\n",
|
||||
name = app_name,
|
||||
exec = record.path,
|
||||
icon = icon_id,
|
||||
categories = categories,
|
||||
comment = comment,
|
||||
path = record.path,
|
||||
version = version,
|
||||
date = now,
|
||||
);
|
||||
|
||||
fs::write(&desktop_path, &desktop_content)?;
|
||||
|
||||
// Install icon if we have a cached one
|
||||
let icon_install_path = if let Some(ref cached_icon) = record.icon_path {
|
||||
let cached = Path::new(cached_icon);
|
||||
if cached.exists() {
|
||||
install_icon(cached, &icon_id)?
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Update desktop database (best effort)
|
||||
update_desktop_database();
|
||||
|
||||
Ok(IntegrationResult {
|
||||
desktop_file_path: desktop_path,
|
||||
icon_install_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Install an icon to the hicolor icon theme directory.
|
||||
fn install_icon(source: &Path, icon_id: &str) -> Result<Option<PathBuf>, IntegrationError> {
|
||||
let ext = source
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("png");
|
||||
|
||||
let (subdir, filename) = if ext == "svg" {
|
||||
("scalable/apps", format!("{}.svg", icon_id))
|
||||
} else {
|
||||
("256x256/apps", format!("{}.png", icon_id))
|
||||
};
|
||||
|
||||
let dest_dir = icons_dir().join(subdir);
|
||||
fs::create_dir_all(&dest_dir)?;
|
||||
let dest = dest_dir.join(&filename);
|
||||
fs::copy(source, &dest)?;
|
||||
|
||||
Ok(Some(dest))
|
||||
}
|
||||
|
||||
/// Remove integration for an AppImage.
|
||||
pub fn remove_integration(record: &AppImageRecord) -> Result<(), IntegrationError> {
|
||||
let app_name = record
|
||||
.app_name
|
||||
.as_deref()
|
||||
.or(Some(&record.filename))
|
||||
.ok_or(IntegrationError::NoAppName)?;
|
||||
|
||||
let app_id = make_app_id(app_name);
|
||||
|
||||
// Remove .desktop file
|
||||
if let Some(ref desktop_file) = record.desktop_file {
|
||||
let path = Path::new(desktop_file);
|
||||
if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
} else {
|
||||
// Try the conventional path
|
||||
let desktop_path = applications_dir().join(format!("driftwood-{}.desktop", app_id));
|
||||
if desktop_path.exists() {
|
||||
fs::remove_file(&desktop_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove icon files
|
||||
let icon_id = format!("driftwood-{}", app_id);
|
||||
remove_icon_files(&icon_id);
|
||||
|
||||
update_desktop_database();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_icon_files(icon_id: &str) {
|
||||
let base = icons_dir();
|
||||
let candidates = [
|
||||
base.join(format!("256x256/apps/{}.png", icon_id)),
|
||||
base.join(format!("scalable/apps/{}.svg", icon_id)),
|
||||
base.join(format!("128x128/apps/{}.png", icon_id)),
|
||||
base.join(format!("48x48/apps/{}.png", icon_id)),
|
||||
];
|
||||
for path in &candidates {
|
||||
if path.exists() {
|
||||
fs::remove_file(path).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_desktop_database() {
|
||||
let apps_dir = applications_dir();
|
||||
Command::new("update-desktop-database")
|
||||
.arg(&apps_dir)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.ok();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_make_app_id() {
|
||||
assert_eq!(make_app_id("Firefox"), "firefox");
|
||||
assert_eq!(make_app_id("My Cool App"), "my-cool-app");
|
||||
assert_eq!(make_app_id(" Spaces "), "spaces");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_integrate_creates_desktop_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Override the applications dir for testing by creating the record
|
||||
// with a specific path and testing the desktop content generation
|
||||
let record = AppImageRecord {
|
||||
id: 1,
|
||||
path: "/home/user/Apps/Firefox.AppImage".to_string(),
|
||||
filename: "Firefox.AppImage".to_string(),
|
||||
app_name: Some("Firefox".to_string()),
|
||||
app_version: Some("124.0".to_string()),
|
||||
appimage_type: Some(2),
|
||||
size_bytes: 100_000_000,
|
||||
sha256: None,
|
||||
icon_path: None,
|
||||
desktop_file: None,
|
||||
integrated: false,
|
||||
integrated_at: None,
|
||||
is_executable: true,
|
||||
desktop_entry_content: None,
|
||||
categories: Some("Network;WebBrowser".to_string()),
|
||||
description: Some("Web Browser".to_string()),
|
||||
developer: None,
|
||||
architecture: Some("x86_64".to_string()),
|
||||
first_seen: "2026-01-01".to_string(),
|
||||
last_scanned: "2026-01-01".to_string(),
|
||||
file_modified: None,
|
||||
fuse_status: None,
|
||||
wayland_status: None,
|
||||
update_info: None,
|
||||
update_type: None,
|
||||
latest_version: None,
|
||||
update_checked: None,
|
||||
update_url: None,
|
||||
notes: None,
|
||||
};
|
||||
|
||||
// We can't easily test the full integrate() without mocking dirs,
|
||||
// but we can verify make_app_id and the desktop content format
|
||||
let app_id = make_app_id(record.app_name.as_deref().unwrap());
|
||||
assert_eq!(app_id, "firefox");
|
||||
assert_eq!(format!("driftwood-{}.desktop", app_id), "driftwood-firefox.desktop");
|
||||
}
|
||||
}
|
||||
166
src/core/launcher.rs
Normal file
166
src/core/launcher.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use std::path::Path;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
|
||||
use super::database::Database;
|
||||
use super::fuse::{detect_system_fuse, determine_app_fuse_status, AppImageFuseStatus};
|
||||
|
||||
/// Launch method used for the AppImage.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum LaunchMethod {
|
||||
/// Direct execution via FUSE mount
|
||||
Direct,
|
||||
/// Extract-and-run fallback (APPIMAGE_EXTRACT_AND_RUN=1)
|
||||
ExtractAndRun,
|
||||
/// Via firejail sandbox
|
||||
Sandboxed,
|
||||
}
|
||||
|
||||
impl LaunchMethod {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Direct => "direct",
|
||||
Self::ExtractAndRun => "extract_and_run",
|
||||
Self::Sandboxed => "sandboxed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a launch attempt.
|
||||
#[derive(Debug)]
|
||||
pub enum LaunchResult {
|
||||
/// Successfully spawned the process.
|
||||
Started {
|
||||
child: Child,
|
||||
method: LaunchMethod,
|
||||
},
|
||||
/// Failed to launch.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Launch an AppImage, recording the event in the database.
|
||||
/// Automatically selects the best launch method based on FUSE status.
|
||||
pub fn launch_appimage(
|
||||
db: &Database,
|
||||
record_id: i64,
|
||||
appimage_path: &Path,
|
||||
source: &str,
|
||||
extra_args: &[String],
|
||||
extra_env: &[(&str, &str)],
|
||||
) -> LaunchResult {
|
||||
// Determine launch method based on FUSE status
|
||||
let fuse_info = detect_system_fuse();
|
||||
let fuse_status = determine_app_fuse_status(&fuse_info, appimage_path);
|
||||
|
||||
let method = match fuse_status {
|
||||
AppImageFuseStatus::NativeFuse | AppImageFuseStatus::StaticRuntime => LaunchMethod::Direct,
|
||||
AppImageFuseStatus::ExtractAndRun => LaunchMethod::ExtractAndRun,
|
||||
AppImageFuseStatus::CannotLaunch => {
|
||||
return LaunchResult::Failed(
|
||||
"Cannot launch: FUSE is not available and extract-and-run is not supported".into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let result = execute_appimage(appimage_path, &method, extra_args, extra_env);
|
||||
|
||||
// Record the launch event regardless of success
|
||||
if let Err(e) = db.record_launch(record_id, source) {
|
||||
log::warn!("Failed to record launch event: {}", e);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Launch an AppImage without database tracking (for standalone use).
|
||||
pub fn launch_appimage_simple(
|
||||
appimage_path: &Path,
|
||||
extra_args: &[String],
|
||||
) -> LaunchResult {
|
||||
let fuse_info = detect_system_fuse();
|
||||
let fuse_status = determine_app_fuse_status(&fuse_info, appimage_path);
|
||||
|
||||
let method = match fuse_status {
|
||||
AppImageFuseStatus::NativeFuse | AppImageFuseStatus::StaticRuntime => LaunchMethod::Direct,
|
||||
AppImageFuseStatus::ExtractAndRun => LaunchMethod::ExtractAndRun,
|
||||
AppImageFuseStatus::CannotLaunch => {
|
||||
return LaunchResult::Failed(
|
||||
"Cannot launch: FUSE is not available and this AppImage doesn't support extract-and-run".into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
execute_appimage(appimage_path, &method, extra_args, &[])
|
||||
}
|
||||
|
||||
/// Execute the AppImage process with the given method.
|
||||
fn execute_appimage(
|
||||
appimage_path: &Path,
|
||||
method: &LaunchMethod,
|
||||
args: &[String],
|
||||
extra_env: &[(&str, &str)],
|
||||
) -> LaunchResult {
|
||||
let mut cmd = match method {
|
||||
LaunchMethod::Direct => {
|
||||
let mut c = Command::new(appimage_path);
|
||||
c.args(args);
|
||||
c
|
||||
}
|
||||
LaunchMethod::ExtractAndRun => {
|
||||
let mut c = Command::new(appimage_path);
|
||||
c.env("APPIMAGE_EXTRACT_AND_RUN", "1");
|
||||
c.args(args);
|
||||
c
|
||||
}
|
||||
LaunchMethod::Sandboxed => {
|
||||
let mut c = Command::new("firejail");
|
||||
c.arg("--appimage");
|
||||
c.arg(appimage_path);
|
||||
c.args(args);
|
||||
c
|
||||
}
|
||||
};
|
||||
|
||||
// Apply extra environment variables
|
||||
for (key, value) in extra_env {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
// Detach from our process group so the app runs independently
|
||||
cmd.stdin(Stdio::null());
|
||||
|
||||
match cmd.spawn() {
|
||||
Ok(child) => LaunchResult::Started {
|
||||
child,
|
||||
method: method.clone(),
|
||||
},
|
||||
Err(e) => LaunchResult::Failed(format!("Failed to spawn process: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if firejail is available for sandboxed launches.
|
||||
pub fn has_firejail() -> bool {
|
||||
Command::new("firejail")
|
||||
.arg("--version")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get launch statistics for an AppImage from the database.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaunchStats {
|
||||
pub total_launches: u64,
|
||||
pub last_launched: Option<String>,
|
||||
}
|
||||
|
||||
pub fn get_launch_stats(db: &Database, record_id: i64) -> LaunchStats {
|
||||
let total_launches = db.get_launch_count(record_id).unwrap_or(0) as u64;
|
||||
let last_launched = db.get_last_launched(record_id).unwrap_or(None);
|
||||
|
||||
LaunchStats {
|
||||
total_launches,
|
||||
last_launched,
|
||||
}
|
||||
}
|
||||
10
src/core/mod.rs
Normal file
10
src/core/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
pub mod database;
|
||||
pub mod discovery;
|
||||
pub mod duplicates;
|
||||
pub mod fuse;
|
||||
pub mod inspector;
|
||||
pub mod integrator;
|
||||
pub mod launcher;
|
||||
pub mod orphan;
|
||||
pub mod updater;
|
||||
pub mod wayland;
|
||||
199
src/core/orphan.rs
Normal file
199
src/core/orphan.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OrphanedDesktopEntry {
|
||||
pub desktop_file_path: PathBuf,
|
||||
pub original_appimage_path: String,
|
||||
pub app_name: Option<String>,
|
||||
}
|
||||
|
||||
pub struct CleanupSummary {
|
||||
pub entries_removed: usize,
|
||||
pub icons_removed: usize,
|
||||
}
|
||||
|
||||
fn applications_dir() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
.join("applications")
|
||||
}
|
||||
|
||||
fn icons_dir() -> PathBuf {
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
.join("icons/hicolor")
|
||||
}
|
||||
|
||||
/// Parse key-value pairs from a .desktop file's [Desktop Entry] section.
|
||||
fn parse_desktop_key(content: &str, key: &str) -> Option<String> {
|
||||
let mut in_section = false;
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line == "[Desktop Entry]" {
|
||||
in_section = true;
|
||||
continue;
|
||||
}
|
||||
if line.starts_with('[') {
|
||||
in_section = false;
|
||||
continue;
|
||||
}
|
||||
if !in_section {
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = line.strip_prefix(key).and_then(|rest| rest.strip_prefix('=')) {
|
||||
return Some(value.trim().to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Scan for orphaned desktop entries managed by Driftwood.
|
||||
pub fn detect_orphans() -> Vec<OrphanedDesktopEntry> {
|
||||
let mut orphans = Vec::new();
|
||||
let apps_dir = applications_dir();
|
||||
|
||||
let entries = match fs::read_dir(&apps_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return orphans,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
// Only check driftwood-*.desktop files
|
||||
let _filename = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(name) if name.starts_with("driftwood-") && name.ends_with(".desktop") => name,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Read and check if managed by Driftwood
|
||||
let content = match fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let managed = parse_desktop_key(&content, "X-AppImage-Managed-By");
|
||||
if managed.as_deref() != Some("Driftwood") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the referenced AppImage still exists
|
||||
let appimage_path = match parse_desktop_key(&content, "X-AppImage-Path") {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if !Path::new(&appimage_path).exists() {
|
||||
let app_name = parse_desktop_key(&content, "Name");
|
||||
orphans.push(OrphanedDesktopEntry {
|
||||
desktop_file_path: path,
|
||||
original_appimage_path: appimage_path,
|
||||
app_name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
orphans
|
||||
}
|
||||
|
||||
/// Clean up a specific orphaned desktop entry.
|
||||
pub fn clean_orphan(entry: &OrphanedDesktopEntry) -> Result<(bool, usize), std::io::Error> {
|
||||
let mut icons_removed = 0;
|
||||
|
||||
// Remove the .desktop file
|
||||
let entry_removed = if entry.desktop_file_path.exists() {
|
||||
fs::remove_file(&entry.desktop_file_path)?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Try to determine the icon ID and remove associated icon files
|
||||
if let Some(filename) = entry.desktop_file_path.file_stem().and_then(|n| n.to_str()) {
|
||||
// filename is like "driftwood-firefox" - the icon ID is the same
|
||||
let icon_id = filename;
|
||||
let base = icons_dir();
|
||||
let candidates = [
|
||||
base.join(format!("256x256/apps/{}.png", icon_id)),
|
||||
base.join(format!("scalable/apps/{}.svg", icon_id)),
|
||||
base.join(format!("128x128/apps/{}.png", icon_id)),
|
||||
base.join(format!("48x48/apps/{}.png", icon_id)),
|
||||
];
|
||||
for path in &candidates {
|
||||
if path.exists() {
|
||||
fs::remove_file(path)?;
|
||||
icons_removed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((entry_removed, icons_removed))
|
||||
}
|
||||
|
||||
/// Clean all detected orphans.
|
||||
pub fn clean_all_orphans() -> Result<CleanupSummary, std::io::Error> {
|
||||
let orphans = detect_orphans();
|
||||
let mut summary = CleanupSummary {
|
||||
entries_removed: 0,
|
||||
icons_removed: 0,
|
||||
};
|
||||
|
||||
for entry in &orphans {
|
||||
match clean_orphan(entry) {
|
||||
Ok((removed, icons)) => {
|
||||
if removed {
|
||||
summary.entries_removed += 1;
|
||||
}
|
||||
summary.icons_removed += icons;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to clean orphan {}: {}",
|
||||
entry.desktop_file_path.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(summary)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_desktop_key() {
|
||||
let content = "[Desktop Entry]\n\
|
||||
Name=Test App\n\
|
||||
X-AppImage-Path=/home/user/test.AppImage\n\
|
||||
X-AppImage-Managed-By=Driftwood\n";
|
||||
assert_eq!(
|
||||
parse_desktop_key(content, "Name"),
|
||||
Some("Test App".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_desktop_key(content, "X-AppImage-Path"),
|
||||
Some("/home/user/test.AppImage".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_desktop_key(content, "X-AppImage-Managed-By"),
|
||||
Some("Driftwood".to_string())
|
||||
);
|
||||
assert_eq!(parse_desktop_key(content, "Missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_desktop_key_ignores_other_sections() {
|
||||
let content = "[Desktop Entry]\n\
|
||||
Name=App\n\
|
||||
[Desktop Action New]\n\
|
||||
Name=Other\n";
|
||||
assert_eq!(
|
||||
parse_desktop_key(content, "Name"),
|
||||
Some("App".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
1114
src/core/updater.rs
Normal file
1114
src/core/updater.rs
Normal file
File diff suppressed because it is too large
Load Diff
406
src/core/wayland.rs
Normal file
406
src/core/wayland.rs
Normal file
@@ -0,0 +1,406 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum WaylandStatus {
|
||||
/// Native Wayland support detected (GTK4, Qt6+Wayland, Electron 38+)
|
||||
Native,
|
||||
/// Will run under XWayland compatibility layer
|
||||
XWayland,
|
||||
/// Toolkit supports Wayland but plugins may be missing or env vars needed
|
||||
Possible,
|
||||
/// X11-only toolkit with no Wayland path (GTK2, old Electron, Java)
|
||||
X11Only,
|
||||
/// Could not determine (uncommon toolkit, static binary, etc.)
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl WaylandStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Native => "native",
|
||||
Self::XWayland => "xwayland",
|
||||
Self::Possible => "possible",
|
||||
Self::X11Only => "x11_only",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"native" => Self::Native,
|
||||
"xwayland" => Self::XWayland,
|
||||
"possible" => Self::Possible,
|
||||
"x11_only" => Self::X11Only,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Native => "Native Wayland",
|
||||
Self::XWayland => "XWayland",
|
||||
Self::Possible => "Wayland possible",
|
||||
Self::X11Only => "X11 only",
|
||||
Self::Unknown => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn badge_class(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Native => "success",
|
||||
Self::XWayland | Self::Possible => "warning",
|
||||
Self::X11Only => "error",
|
||||
Self::Unknown => "neutral",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum DetectedToolkit {
|
||||
Gtk4,
|
||||
Gtk3Wayland,
|
||||
Gtk3X11Only,
|
||||
Gtk2,
|
||||
Qt6Wayland,
|
||||
Qt6X11Only,
|
||||
Qt5Wayland,
|
||||
Qt5X11Only,
|
||||
ElectronNative(u32), // version >= 38
|
||||
ElectronFlagged(u32), // version 28-37 with ozone flag
|
||||
ElectronLegacy(u32), // version < 28
|
||||
JavaSwing,
|
||||
Flutter,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl DetectedToolkit {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Self::Gtk4 => "GTK4".to_string(),
|
||||
Self::Gtk3Wayland => "GTK3 (Wayland)".to_string(),
|
||||
Self::Gtk3X11Only => "GTK3 (X11)".to_string(),
|
||||
Self::Gtk2 => "GTK2".to_string(),
|
||||
Self::Qt6Wayland => "Qt6 (Wayland)".to_string(),
|
||||
Self::Qt6X11Only => "Qt6 (X11)".to_string(),
|
||||
Self::Qt5Wayland => "Qt5 (Wayland)".to_string(),
|
||||
Self::Qt5X11Only => "Qt5 (X11)".to_string(),
|
||||
Self::ElectronNative(v) => format!("Electron {} (native Wayland)", v),
|
||||
Self::ElectronFlagged(v) => format!("Electron {} (Wayland with flags)", v),
|
||||
Self::ElectronLegacy(v) => format!("Electron {} (X11)", v),
|
||||
Self::JavaSwing => "Java/Swing".to_string(),
|
||||
Self::Flutter => "Flutter".to_string(),
|
||||
Self::Unknown => "Unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wayland_status(&self) -> WaylandStatus {
|
||||
match self {
|
||||
Self::Gtk4 | Self::Gtk3Wayland | Self::Qt6Wayland | Self::Qt5Wayland
|
||||
| Self::ElectronNative(_) | Self::Flutter => WaylandStatus::Native,
|
||||
Self::ElectronFlagged(_) => WaylandStatus::Possible,
|
||||
Self::Gtk3X11Only | Self::Qt6X11Only | Self::Qt5X11Only => WaylandStatus::XWayland,
|
||||
Self::Gtk2 | Self::ElectronLegacy(_) | Self::JavaSwing => WaylandStatus::X11Only,
|
||||
Self::Unknown => WaylandStatus::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WaylandAnalysis {
|
||||
pub status: WaylandStatus,
|
||||
pub toolkit: DetectedToolkit,
|
||||
pub libraries_found: Vec<String>,
|
||||
}
|
||||
|
||||
/// Analyze an AppImage's Wayland compatibility by inspecting its bundled libraries.
|
||||
/// Uses unsquashfs to list files inside the squashfs.
|
||||
pub fn analyze_appimage(appimage_path: &Path) -> WaylandAnalysis {
|
||||
let libs = list_bundled_libraries(appimage_path);
|
||||
|
||||
let toolkit = detect_toolkit(&libs);
|
||||
let status = toolkit.wayland_status();
|
||||
|
||||
WaylandAnalysis {
|
||||
status,
|
||||
toolkit,
|
||||
libraries_found: libs,
|
||||
}
|
||||
}
|
||||
|
||||
/// List shared libraries bundled inside the AppImage squashfs.
|
||||
fn list_bundled_libraries(appimage_path: &Path) -> Vec<String> {
|
||||
// First get the squashfs offset
|
||||
let offset_output = Command::new(appimage_path)
|
||||
.arg("--appimage-offset")
|
||||
.env("APPIMAGE_EXTRACT_AND_RUN", "1")
|
||||
.output();
|
||||
|
||||
let offset = match offset_output {
|
||||
Ok(out) if out.status.success() => {
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
|
||||
// Use unsquashfs to list files (just filenames, no extraction)
|
||||
let output = Command::new("unsquashfs")
|
||||
.args(["-o", &offset, "-l", "-no-progress"])
|
||||
.arg(appimage_path)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(out) if out.status.success() => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
stdout
|
||||
.lines()
|
||||
.filter(|line| line.contains(".so"))
|
||||
.map(|line| {
|
||||
// unsquashfs -l output format: "squashfs-root/usr/lib/libfoo.so.1"
|
||||
// Extract just the filename
|
||||
line.rsplit('/').next().unwrap_or(line).to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the UI toolkit from the bundled library list.
|
||||
fn detect_toolkit(libs: &[String]) -> DetectedToolkit {
|
||||
let has = |pattern: &str| -> bool {
|
||||
libs.iter().any(|l| l.contains(pattern))
|
||||
};
|
||||
|
||||
// Check for GTK4 (always Wayland-native)
|
||||
if has("libgtk-4") || has("libGdk-4") {
|
||||
return DetectedToolkit::Gtk4;
|
||||
}
|
||||
|
||||
// Check for Flutter (GTK backend, Wayland-native)
|
||||
if has("libflutter_linux_gtk") {
|
||||
return DetectedToolkit::Flutter;
|
||||
}
|
||||
|
||||
// Check for Java/Swing (X11 only)
|
||||
if has("libjvm.so") || has("libjava.so") || has("libawt.so") {
|
||||
return DetectedToolkit::JavaSwing;
|
||||
}
|
||||
|
||||
// Check for Electron (version-dependent)
|
||||
if has("libElectron") || has("electron") || has("libnode.so") || has("libchromium") {
|
||||
let version = detect_electron_version(libs);
|
||||
if let Some(v) = version {
|
||||
if v >= 38 {
|
||||
return DetectedToolkit::ElectronNative(v);
|
||||
} else if v >= 28 {
|
||||
return DetectedToolkit::ElectronFlagged(v);
|
||||
} else {
|
||||
return DetectedToolkit::ElectronLegacy(v);
|
||||
}
|
||||
}
|
||||
// Can't determine version - assume modern enough for XWayland at minimum
|
||||
return DetectedToolkit::ElectronFlagged(0);
|
||||
}
|
||||
|
||||
// Check for Qt6
|
||||
if has("libQt6Core") || has("libQt6Gui") {
|
||||
if has("libQt6WaylandClient") || has("libqwayland") {
|
||||
return DetectedToolkit::Qt6Wayland;
|
||||
}
|
||||
return DetectedToolkit::Qt6X11Only;
|
||||
}
|
||||
|
||||
// Check for Qt5
|
||||
if has("libQt5Core") || has("libQt5Gui") {
|
||||
if has("libQt5WaylandClient") || has("libqwayland") {
|
||||
return DetectedToolkit::Qt5Wayland;
|
||||
}
|
||||
return DetectedToolkit::Qt5X11Only;
|
||||
}
|
||||
|
||||
// Check for GTK3
|
||||
if has("libgtk-3") || has("libGdk-3") {
|
||||
if has("libwayland-client") {
|
||||
return DetectedToolkit::Gtk3Wayland;
|
||||
}
|
||||
return DetectedToolkit::Gtk3X11Only;
|
||||
}
|
||||
|
||||
// Check for GTK2 (X11 only, forever)
|
||||
if has("libgtk-x11-2") || has("libgdk-x11-2") {
|
||||
return DetectedToolkit::Gtk2;
|
||||
}
|
||||
|
||||
DetectedToolkit::Unknown
|
||||
}
|
||||
|
||||
/// Try to detect Electron version from bundled files.
|
||||
fn detect_electron_version(libs: &[String]) -> Option<u32> {
|
||||
for lib in libs {
|
||||
// Look for version patterns in Electron-related files
|
||||
if lib.contains("electron") {
|
||||
// Try to extract version number from filenames like "electron-v28.0.0"
|
||||
for part in lib.split(&['-', '_', 'v'][..]) {
|
||||
if let Some(major) = part.split('.').next() {
|
||||
if let Ok(v) = major.parse::<u32>() {
|
||||
if v > 0 && v < 200 {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Detect the current desktop session type.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SessionType {
|
||||
Wayland,
|
||||
X11,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl SessionType {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Wayland => "Wayland",
|
||||
Self::X11 => "X11",
|
||||
Self::Unknown => "Unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detect_session_type() -> SessionType {
|
||||
// Check XDG_SESSION_TYPE first (most reliable)
|
||||
if let Ok(session) = std::env::var("XDG_SESSION_TYPE") {
|
||||
return match session.as_str() {
|
||||
"wayland" => SessionType::Wayland,
|
||||
"x11" => SessionType::X11,
|
||||
_ => SessionType::Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
// Check WAYLAND_DISPLAY
|
||||
if std::env::var("WAYLAND_DISPLAY").is_ok() {
|
||||
return SessionType::Wayland;
|
||||
}
|
||||
|
||||
// Check DISPLAY (X11 fallback)
|
||||
if std::env::var("DISPLAY").is_ok() {
|
||||
return SessionType::X11;
|
||||
}
|
||||
|
||||
SessionType::Unknown
|
||||
}
|
||||
|
||||
/// Get desktop environment info string.
|
||||
pub fn detect_desktop_environment() -> String {
|
||||
let de = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
|
||||
let session = std::env::var("DESKTOP_SESSION").unwrap_or_default();
|
||||
|
||||
if !de.is_empty() {
|
||||
de
|
||||
} else if !session.is_empty() {
|
||||
session
|
||||
} else {
|
||||
"Unknown".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if XWayland is available on the system.
|
||||
pub fn has_xwayland() -> bool {
|
||||
// Check if Xwayland process is running
|
||||
Command::new("pgrep")
|
||||
.arg("Xwayland")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_wayland_status_roundtrip() {
|
||||
let statuses = [
|
||||
WaylandStatus::Native,
|
||||
WaylandStatus::XWayland,
|
||||
WaylandStatus::Possible,
|
||||
WaylandStatus::X11Only,
|
||||
WaylandStatus::Unknown,
|
||||
];
|
||||
for status in &statuses {
|
||||
assert_eq!(&WaylandStatus::from_str(status.as_str()), status);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_toolkit_gtk4() {
|
||||
let libs = vec!["libgtk-4.so.1".to_string(), "libglib-2.0.so.0".to_string()];
|
||||
let toolkit = detect_toolkit(&libs);
|
||||
assert!(matches!(toolkit, DetectedToolkit::Gtk4));
|
||||
assert_eq!(toolkit.wayland_status(), WaylandStatus::Native);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_toolkit_qt5_wayland() {
|
||||
let libs = vec![
|
||||
"libQt5Core.so.5".to_string(),
|
||||
"libQt5Gui.so.5".to_string(),
|
||||
"libQt5WaylandClient.so.5".to_string(),
|
||||
];
|
||||
let toolkit = detect_toolkit(&libs);
|
||||
assert!(matches!(toolkit, DetectedToolkit::Qt5Wayland));
|
||||
assert_eq!(toolkit.wayland_status(), WaylandStatus::Native);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_toolkit_qt5_x11() {
|
||||
let libs = vec![
|
||||
"libQt5Core.so.5".to_string(),
|
||||
"libQt5Gui.so.5".to_string(),
|
||||
];
|
||||
let toolkit = detect_toolkit(&libs);
|
||||
assert!(matches!(toolkit, DetectedToolkit::Qt5X11Only));
|
||||
assert_eq!(toolkit.wayland_status(), WaylandStatus::XWayland);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_toolkit_gtk2() {
|
||||
let libs = vec!["libgtk-x11-2.0.so.0".to_string()];
|
||||
let toolkit = detect_toolkit(&libs);
|
||||
assert!(matches!(toolkit, DetectedToolkit::Gtk2));
|
||||
assert_eq!(toolkit.wayland_status(), WaylandStatus::X11Only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_toolkit_gtk3_with_wayland() {
|
||||
let libs = vec![
|
||||
"libgtk-3.so.0".to_string(),
|
||||
"libwayland-client.so.0".to_string(),
|
||||
];
|
||||
let toolkit = detect_toolkit(&libs);
|
||||
assert!(matches!(toolkit, DetectedToolkit::Gtk3Wayland));
|
||||
assert_eq!(toolkit.wayland_status(), WaylandStatus::Native);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_toolkit_unknown() {
|
||||
let libs = vec!["libfoo.so.1".to_string()];
|
||||
let toolkit = detect_toolkit(&libs);
|
||||
assert!(matches!(toolkit, DetectedToolkit::Unknown));
|
||||
assert_eq!(toolkit.wayland_status(), WaylandStatus::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_badge_classes() {
|
||||
assert_eq!(WaylandStatus::Native.badge_class(), "success");
|
||||
assert_eq!(WaylandStatus::XWayland.badge_class(), "warning");
|
||||
assert_eq!(WaylandStatus::X11Only.badge_class(), "error");
|
||||
assert_eq!(WaylandStatus::Unknown.badge_class(), "neutral");
|
||||
}
|
||||
}
|
||||
35
src/main.rs
Normal file
35
src/main.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
mod application;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod core;
|
||||
mod ui;
|
||||
mod window;
|
||||
|
||||
use clap::Parser;
|
||||
use glib::ExitCode;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use application::DriftwoodApplication;
|
||||
use config::{APP_ID, GSETTINGS_SCHEMA_DIR};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// Point GSettings at our compiled schema directory (dev builds)
|
||||
std::env::set_var("GSETTINGS_SCHEMA_DIR", GSETTINGS_SCHEMA_DIR);
|
||||
|
||||
// Parse CLI arguments
|
||||
let parsed = cli::Cli::parse();
|
||||
|
||||
// If a subcommand was given, run in CLI mode (no GUI)
|
||||
if let Some(command) = parsed.command {
|
||||
// Initialize GTK minimally for GSettings access
|
||||
gtk::init().expect("Failed to initialize GTK");
|
||||
return cli::run_command(command);
|
||||
}
|
||||
|
||||
// Otherwise, launch the full GUI application
|
||||
gio::resources_register_include!("driftwood.gresource")
|
||||
.expect("Failed to register resources");
|
||||
|
||||
let app = DriftwoodApplication::new(APP_ID, &gio::ApplicationFlags::empty());
|
||||
app.run()
|
||||
}
|
||||
119
src/ui/app_card.rs
Normal file
119
src/ui/app_card.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crate::core::database::AppImageRecord;
|
||||
use crate::core::fuse::FuseStatus;
|
||||
use crate::core::wayland::WaylandStatus;
|
||||
use super::widgets;
|
||||
|
||||
/// Build a grid card for an AppImage.
|
||||
pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
|
||||
let card = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(6)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
card.add_css_class("app-card");
|
||||
card.set_size_request(160, -1);
|
||||
|
||||
// Icon (48x48)
|
||||
let icon = if let Some(ref icon_path) = record.icon_path {
|
||||
let path = std::path::Path::new(icon_path);
|
||||
if path.exists() {
|
||||
let paintable = gtk::gdk::Texture::from_filename(path).ok();
|
||||
let image = gtk::Image::builder()
|
||||
.pixel_size(48)
|
||||
.build();
|
||||
if let Some(texture) = paintable {
|
||||
image.set_paintable(Some(&texture));
|
||||
} else {
|
||||
image.set_icon_name(Some("application-x-executable-symbolic"));
|
||||
}
|
||||
image
|
||||
} else {
|
||||
gtk::Image::builder()
|
||||
.icon_name("application-x-executable-symbolic")
|
||||
.pixel_size(48)
|
||||
.build()
|
||||
}
|
||||
} else {
|
||||
gtk::Image::builder()
|
||||
.icon_name("application-x-executable-symbolic")
|
||||
.pixel_size(48)
|
||||
.build()
|
||||
};
|
||||
|
||||
// App name
|
||||
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||
let name_label = gtk::Label::builder()
|
||||
.label(name)
|
||||
.css_classes(["app-card-name"])
|
||||
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||
.max_width_chars(18)
|
||||
.build();
|
||||
|
||||
// Version
|
||||
let version_text = record.app_version.as_deref().unwrap_or("");
|
||||
let version_label = gtk::Label::builder()
|
||||
.label(version_text)
|
||||
.css_classes(["app-card-version"])
|
||||
.ellipsize(gtk::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
|
||||
card.append(&icon);
|
||||
card.append(&name_label);
|
||||
if !version_text.is_empty() {
|
||||
card.append(&version_label);
|
||||
}
|
||||
|
||||
// Status badges row
|
||||
let badges = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(4)
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
badges.add_css_class("badge-row");
|
||||
|
||||
// Wayland status badge
|
||||
if let Some(ref ws) = record.wayland_status {
|
||||
let status = WaylandStatus::from_str(ws);
|
||||
if status != WaylandStatus::Unknown {
|
||||
badges.append(&widgets::status_badge(status.label(), status.badge_class()));
|
||||
}
|
||||
}
|
||||
|
||||
// FUSE status badge
|
||||
if let Some(ref fs) = record.fuse_status {
|
||||
let status = FuseStatus::from_str(fs);
|
||||
if !status.is_functional() {
|
||||
badges.append(&widgets::status_badge(status.label(), status.badge_class()));
|
||||
}
|
||||
}
|
||||
|
||||
// Update available badge
|
||||
if record.latest_version.is_some() {
|
||||
if let (Some(ref latest), Some(ref current)) =
|
||||
(&record.latest_version, &record.app_version)
|
||||
{
|
||||
if crate::core::updater::version_is_newer(latest, current) {
|
||||
badges.append(&widgets::status_badge("Update", "info"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Integration badge (only show if not integrated, to reduce clutter)
|
||||
if !record.integrated {
|
||||
badges.append(&widgets::status_badge("Not integrated", "neutral"));
|
||||
}
|
||||
|
||||
card.append(&badges);
|
||||
|
||||
let child = gtk::FlowBoxChild::builder()
|
||||
.child(&card)
|
||||
.build();
|
||||
|
||||
child
|
||||
}
|
||||
383
src/ui/dashboard.rs
Normal file
383
src/ui/dashboard.rs
Normal file
@@ -0,0 +1,383 @@
|
||||
use adw::prelude::*;
|
||||
use gtk::gio;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::core::database::Database;
|
||||
use crate::core::duplicates;
|
||||
use crate::core::fuse;
|
||||
use crate::core::wayland;
|
||||
use super::widgets;
|
||||
|
||||
/// Build the dashboard page showing system health and statistics.
|
||||
pub fn build_dashboard_page(db: &Rc<Database>) -> adw::NavigationPage {
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(800)
|
||||
.tightening_threshold(600)
|
||||
.build();
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(24)
|
||||
.margin_top(24)
|
||||
.margin_bottom(24)
|
||||
.margin_start(18)
|
||||
.margin_end(18)
|
||||
.build();
|
||||
|
||||
// Section 1: System Status
|
||||
content.append(&build_system_status_section());
|
||||
|
||||
// Section 2: Library Statistics
|
||||
content.append(&build_library_stats_section(db));
|
||||
|
||||
// Section 3: Updates Summary
|
||||
content.append(&build_updates_summary_section(db));
|
||||
|
||||
// Section 4: Duplicates Summary
|
||||
content.append(&build_duplicates_summary_section(db));
|
||||
|
||||
// Section 5: Disk Usage
|
||||
content.append(&build_disk_usage_section(db));
|
||||
|
||||
clamp.set_child(Some(&content));
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
.child(&clamp)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let header = adw::HeaderBar::new();
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.set_content(Some(&scrolled));
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title("Dashboard")
|
||||
.tag("dashboard")
|
||||
.child(&toolbar)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn build_system_status_section() -> gtk::Box {
|
||||
let section = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
let heading = gtk::Label::builder()
|
||||
.label("System Status")
|
||||
.css_classes(["heading"])
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
section.append(&heading);
|
||||
|
||||
let list_box = gtk::ListBox::new();
|
||||
list_box.add_css_class("boxed-list");
|
||||
list_box.set_selection_mode(gtk::SelectionMode::None);
|
||||
|
||||
// Session type
|
||||
let session = wayland::detect_session_type();
|
||||
let session_row = adw::ActionRow::builder()
|
||||
.title("Display server")
|
||||
.subtitle(session.label())
|
||||
.build();
|
||||
let session_badge = widgets::status_badge(
|
||||
session.label(),
|
||||
match session {
|
||||
wayland::SessionType::Wayland => "success",
|
||||
wayland::SessionType::X11 => "warning",
|
||||
wayland::SessionType::Unknown => "neutral",
|
||||
},
|
||||
);
|
||||
session_badge.set_valign(gtk::Align::Center);
|
||||
session_row.add_suffix(&session_badge);
|
||||
list_box.append(&session_row);
|
||||
|
||||
// Desktop environment
|
||||
let de = wayland::detect_desktop_environment();
|
||||
let de_row = adw::ActionRow::builder()
|
||||
.title("Desktop environment")
|
||||
.subtitle(&de)
|
||||
.build();
|
||||
list_box.append(&de_row);
|
||||
|
||||
// FUSE status
|
||||
let fuse_info = fuse::detect_system_fuse();
|
||||
let fuse_row = adw::ActionRow::builder()
|
||||
.title("FUSE")
|
||||
.subtitle(fuse_description(&fuse_info))
|
||||
.build();
|
||||
let fuse_badge = widgets::status_badge(
|
||||
fuse_info.status.label(),
|
||||
fuse_info.status.badge_class(),
|
||||
);
|
||||
fuse_badge.set_valign(gtk::Align::Center);
|
||||
fuse_row.add_suffix(&fuse_badge);
|
||||
list_box.append(&fuse_row);
|
||||
|
||||
// Install hint if FUSE not functional
|
||||
if let Some(ref hint) = fuse_info.install_hint {
|
||||
let hint_row = adw::ActionRow::builder()
|
||||
.title("Fix FUSE")
|
||||
.subtitle(hint)
|
||||
.subtitle_selectable(true)
|
||||
.css_classes(["monospace"])
|
||||
.build();
|
||||
list_box.append(&hint_row);
|
||||
}
|
||||
|
||||
// XWayland
|
||||
let has_xwayland = wayland::has_xwayland();
|
||||
let xwayland_row = adw::ActionRow::builder()
|
||||
.title("XWayland")
|
||||
.subtitle(if has_xwayland { "Running" } else { "Not detected" })
|
||||
.build();
|
||||
let xwayland_badge = widgets::status_badge(
|
||||
if has_xwayland { "Available" } else { "Unavailable" },
|
||||
if has_xwayland { "success" } else { "neutral" },
|
||||
);
|
||||
xwayland_badge.set_valign(gtk::Align::Center);
|
||||
xwayland_row.add_suffix(&xwayland_badge);
|
||||
list_box.append(&xwayland_row);
|
||||
|
||||
// AppImageLauncher conflict check
|
||||
if let Some(version) = fuse::detect_appimagelauncher() {
|
||||
let ail_row = adw::ActionRow::builder()
|
||||
.title("AppImageLauncher detected")
|
||||
.subtitle(&format!(
|
||||
"Version {} - may conflict with some AppImage runtimes",
|
||||
version
|
||||
))
|
||||
.build();
|
||||
let ail_badge = widgets::status_badge("Conflict", "warning");
|
||||
ail_badge.set_valign(gtk::Align::Center);
|
||||
ail_row.add_suffix(&ail_badge);
|
||||
list_box.append(&ail_row);
|
||||
}
|
||||
|
||||
section.append(&list_box);
|
||||
section
|
||||
}
|
||||
|
||||
fn fuse_description(info: &fuse::FuseSystemInfo) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if info.has_libfuse2 {
|
||||
parts.push("libfuse2");
|
||||
}
|
||||
if info.has_libfuse3 {
|
||||
parts.push("libfuse3");
|
||||
}
|
||||
if info.has_fusermount {
|
||||
parts.push("fusermount");
|
||||
}
|
||||
if info.has_dev_fuse {
|
||||
parts.push("/dev/fuse");
|
||||
}
|
||||
if parts.is_empty() {
|
||||
"No FUSE components detected".to_string()
|
||||
} else {
|
||||
format!("Available: {}", parts.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_library_stats_section(db: &Rc<Database>) -> gtk::Box {
|
||||
let section = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
let heading = gtk::Label::builder()
|
||||
.label("Library")
|
||||
.css_classes(["heading"])
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
section.append(&heading);
|
||||
|
||||
let list_box = gtk::ListBox::new();
|
||||
list_box.add_css_class("boxed-list");
|
||||
list_box.set_selection_mode(gtk::SelectionMode::None);
|
||||
|
||||
let records = db.get_all_appimages().unwrap_or_default();
|
||||
|
||||
let total = records.len();
|
||||
let integrated = records.iter().filter(|r| r.integrated).count();
|
||||
let executable = records.iter().filter(|r| r.is_executable).count();
|
||||
|
||||
let total_row = adw::ActionRow::builder()
|
||||
.title("Total AppImages")
|
||||
.subtitle(&total.to_string())
|
||||
.build();
|
||||
list_box.append(&total_row);
|
||||
|
||||
let integrated_row = adw::ActionRow::builder()
|
||||
.title("Integrated")
|
||||
.subtitle(&format!("{} of {}", integrated, total))
|
||||
.build();
|
||||
list_box.append(&integrated_row);
|
||||
|
||||
let exec_row = adw::ActionRow::builder()
|
||||
.title("Executable")
|
||||
.subtitle(&format!("{} of {}", executable, total))
|
||||
.build();
|
||||
if executable < total {
|
||||
let badge = widgets::status_badge(
|
||||
&format!("{} not executable", total - executable),
|
||||
"warning",
|
||||
);
|
||||
badge.set_valign(gtk::Align::Center);
|
||||
exec_row.add_suffix(&badge);
|
||||
}
|
||||
list_box.append(&exec_row);
|
||||
|
||||
section.append(&list_box);
|
||||
section
|
||||
}
|
||||
|
||||
fn build_updates_summary_section(db: &Rc<Database>) -> gtk::Box {
|
||||
let section = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
let heading = gtk::Label::builder()
|
||||
.label("Updates")
|
||||
.css_classes(["heading"])
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
section.append(&heading);
|
||||
|
||||
let list_box = gtk::ListBox::new();
|
||||
list_box.add_css_class("boxed-list");
|
||||
list_box.set_selection_mode(gtk::SelectionMode::None);
|
||||
|
||||
let records = db.get_all_appimages().unwrap_or_default();
|
||||
|
||||
let with_update_info = records
|
||||
.iter()
|
||||
.filter(|r| r.update_info.is_some())
|
||||
.count();
|
||||
let with_updates = records
|
||||
.iter()
|
||||
.filter(|r| {
|
||||
if let (Some(ref latest), Some(ref current)) = (&r.latest_version, &r.app_version) {
|
||||
crate::core::updater::version_is_newer(latest, current)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.count();
|
||||
|
||||
let info_row = adw::ActionRow::builder()
|
||||
.title("With update info")
|
||||
.subtitle(&format!("{} of {}", with_update_info, records.len()))
|
||||
.build();
|
||||
list_box.append(&info_row);
|
||||
|
||||
let updates_row = adw::ActionRow::builder()
|
||||
.title("Updates available")
|
||||
.subtitle(&with_updates.to_string())
|
||||
.build();
|
||||
if with_updates > 0 {
|
||||
let badge = widgets::status_badge(&format!("{} updates", with_updates), "info");
|
||||
badge.set_valign(gtk::Align::Center);
|
||||
updates_row.add_suffix(&badge);
|
||||
}
|
||||
list_box.append(&updates_row);
|
||||
|
||||
section.append(&list_box);
|
||||
section
|
||||
}
|
||||
|
||||
fn build_duplicates_summary_section(db: &Rc<Database>) -> gtk::Box {
|
||||
let section = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
let heading = gtk::Label::builder()
|
||||
.label("Duplicates")
|
||||
.css_classes(["heading"])
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
section.append(&heading);
|
||||
|
||||
let list_box = gtk::ListBox::new();
|
||||
list_box.add_css_class("boxed-list");
|
||||
list_box.set_selection_mode(gtk::SelectionMode::None);
|
||||
|
||||
let groups = duplicates::detect_duplicates(db);
|
||||
let summary = duplicates::summarize_duplicates(&groups);
|
||||
|
||||
if summary.total_groups == 0 {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No duplicates found")
|
||||
.subtitle("All AppImages appear unique")
|
||||
.build();
|
||||
let badge = widgets::status_badge("Clean", "success");
|
||||
badge.set_valign(gtk::Align::Center);
|
||||
row.add_suffix(&badge);
|
||||
list_box.append(&row);
|
||||
} else {
|
||||
let groups_row = adw::ActionRow::builder()
|
||||
.title("Duplicate groups")
|
||||
.subtitle(&summary.total_groups.to_string())
|
||||
.build();
|
||||
list_box.append(&groups_row);
|
||||
|
||||
if summary.total_potential_savings > 0 {
|
||||
let savings_row = adw::ActionRow::builder()
|
||||
.title("Potential savings")
|
||||
.subtitle(&widgets::format_size(summary.total_potential_savings as i64))
|
||||
.build();
|
||||
let badge = widgets::status_badge("Reclaimable", "warning");
|
||||
badge.set_valign(gtk::Align::Center);
|
||||
savings_row.add_suffix(&badge);
|
||||
list_box.append(&savings_row);
|
||||
}
|
||||
}
|
||||
|
||||
section.append(&list_box);
|
||||
section
|
||||
}
|
||||
|
||||
fn build_disk_usage_section(db: &Rc<Database>) -> gtk::Box {
|
||||
let section = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
let heading = gtk::Label::builder()
|
||||
.label("Disk Usage")
|
||||
.css_classes(["heading"])
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
section.append(&heading);
|
||||
|
||||
let list_box = gtk::ListBox::new();
|
||||
list_box.add_css_class("boxed-list");
|
||||
list_box.set_selection_mode(gtk::SelectionMode::None);
|
||||
|
||||
let records = db.get_all_appimages().unwrap_or_default();
|
||||
let total_bytes: i64 = records.iter().map(|r| r.size_bytes).sum();
|
||||
|
||||
let total_row = adw::ActionRow::builder()
|
||||
.title("Total disk usage")
|
||||
.subtitle(&widgets::format_size(total_bytes))
|
||||
.build();
|
||||
list_box.append(&total_row);
|
||||
|
||||
// Largest AppImages
|
||||
let mut sorted = records.clone();
|
||||
sorted.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes));
|
||||
|
||||
for record in sorted.iter().take(3) {
|
||||
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(name)
|
||||
.subtitle(&widgets::format_size(record.size_bytes))
|
||||
.build();
|
||||
list_box.append(&row);
|
||||
}
|
||||
|
||||
section.append(&list_box);
|
||||
section
|
||||
}
|
||||
523
src/ui/detail_view.rs
Normal file
523
src/ui/detail_view.rs
Normal file
@@ -0,0 +1,523 @@
|
||||
use adw::prelude::*;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::core::database::{AppImageRecord, Database};
|
||||
use crate::core::fuse::FuseStatus;
|
||||
use crate::core::integrator;
|
||||
use crate::core::launcher;
|
||||
use crate::core::wayland::WaylandStatus;
|
||||
use super::widgets;
|
||||
|
||||
pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::NavigationPage {
|
||||
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||
|
||||
// Scrollable content with clamp
|
||||
let clamp = adw::Clamp::builder()
|
||||
.maximum_size(800)
|
||||
.tightening_threshold(600)
|
||||
.build();
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(24)
|
||||
.margin_top(24)
|
||||
.margin_bottom(24)
|
||||
.margin_start(18)
|
||||
.margin_end(18)
|
||||
.build();
|
||||
|
||||
// Section 1: App Identity
|
||||
content.append(&build_identity_section(record));
|
||||
|
||||
// Section 2: Desktop Integration
|
||||
content.append(&build_integration_section(record, db));
|
||||
|
||||
// Section 3: Runtime Compatibility (Wayland + FUSE)
|
||||
content.append(&build_runtime_section(record));
|
||||
|
||||
// Section 4: Updates
|
||||
content.append(&build_updates_section(record));
|
||||
|
||||
// Section 5: Usage Statistics
|
||||
content.append(&build_usage_section(record, db));
|
||||
|
||||
// Section 6: File Details
|
||||
content.append(&build_file_details_section(record));
|
||||
|
||||
clamp.set_child(Some(&content));
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
.child(&clamp)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
// Header bar with per-app actions
|
||||
let header = adw::HeaderBar::new();
|
||||
|
||||
let launch_button = gtk::Button::builder()
|
||||
.label("Launch")
|
||||
.build();
|
||||
launch_button.add_css_class("suggested-action");
|
||||
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 { .. } => {
|
||||
log::info!("Launched AppImage: {}", path);
|
||||
}
|
||||
launcher::LaunchResult::Failed(msg) => {
|
||||
log::error!("Failed to launch: {}", msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
header.pack_end(&launch_button);
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.set_content(Some(&scrolled));
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title(name)
|
||||
.tag("detail")
|
||||
.child(&toolbar)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn build_identity_section(record: &AppImageRecord) -> gtk::Box {
|
||||
let section = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
let heading = gtk::Label::builder()
|
||||
.label("App Info")
|
||||
.css_classes(["heading"])
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
section.append(&heading);
|
||||
|
||||
let list_box = gtk::ListBox::new();
|
||||
list_box.add_css_class("boxed-list");
|
||||
list_box.set_selection_mode(gtk::SelectionMode::None);
|
||||
|
||||
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||
|
||||
// Icon + name row
|
||||
let name_row = adw::ActionRow::builder()
|
||||
.title(name)
|
||||
.build();
|
||||
|
||||
if let Some(ref icon_path) = record.icon_path {
|
||||
let path = std::path::Path::new(icon_path);
|
||||
if path.exists() {
|
||||
if let Ok(texture) = gtk::gdk::Texture::from_filename(path) {
|
||||
let image = gtk::Image::builder()
|
||||
.pixel_size(48)
|
||||
.build();
|
||||
image.set_paintable(Some(&texture));
|
||||
name_row.add_prefix(&image);
|
||||
}
|
||||
}
|
||||
}
|
||||
list_box.append(&name_row);
|
||||
|
||||
// Version
|
||||
if let Some(ref version) = record.app_version {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Version")
|
||||
.subtitle(version)
|
||||
.build();
|
||||
list_box.append(&row);
|
||||
}
|
||||
|
||||
// Description
|
||||
if let Some(ref desc) = record.description {
|
||||
if !desc.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Description")
|
||||
.subtitle(desc)
|
||||
.build();
|
||||
list_box.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
// Architecture
|
||||
if let Some(ref arch) = record.architecture {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Architecture")
|
||||
.subtitle(arch)
|
||||
.build();
|
||||
list_box.append(&row);
|
||||
}
|
||||
|
||||
// Categories
|
||||
if let Some(ref cats) = record.categories {
|
||||
if !cats.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Categories")
|
||||
.subtitle(cats)
|
||||
.build();
|
||||
list_box.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
section.append(&list_box);
|
||||
section
|
||||
}
|
||||
|
||||
fn build_integration_section(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
let section = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
let heading = gtk::Label::builder()
|
||||
.label("Desktop Integration")
|
||||
.css_classes(["heading"])
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
section.append(&heading);
|
||||
|
||||
let list_box = gtk::ListBox::new();
|
||||
list_box.add_css_class("boxed-list");
|
||||
list_box.set_selection_mode(gtk::SelectionMode::None);
|
||||
|
||||
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();
|
||||
switch_row.connect_active_notify(move |row| {
|
||||
if row.is_active() {
|
||||
match integrator::integrate(&record_clone) {
|
||||
Ok(result) => {
|
||||
db_ref
|
||||
.set_integrated(
|
||||
record_id,
|
||||
true,
|
||||
Some(&result.desktop_file_path.to_string_lossy()),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Integration failed: {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
integrator::remove_integration(&record_clone).ok();
|
||||
db_ref.set_integrated(record_id, false, None).ok();
|
||||
}
|
||||
});
|
||||
|
||||
list_box.append(&switch_row);
|
||||
|
||||
// Show desktop file path if integrated
|
||||
if record.integrated {
|
||||
if let Some(ref desktop_file) = record.desktop_file {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Desktop file")
|
||||
.subtitle(desktop_file)
|
||||
.css_classes(["monospace"])
|
||||
.build();
|
||||
list_box.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
section.append(&list_box);
|
||||
section
|
||||
}
|
||||
|
||||
fn build_runtime_section(record: &AppImageRecord) -> gtk::Box {
|
||||
let section = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
let heading = gtk::Label::builder()
|
||||
.label("Runtime Compatibility")
|
||||
.css_classes(["heading"])
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
section.append(&heading);
|
||||
|
||||
let list_box = gtk::ListBox::new();
|
||||
list_box.add_css_class("boxed-list");
|
||||
list_box.set_selection_mode(gtk::SelectionMode::None);
|
||||
|
||||
// Wayland status
|
||||
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))
|
||||
.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);
|
||||
list_box.append(&wayland_row);
|
||||
|
||||
// FUSE status
|
||||
let fuse_status = record
|
||||
.fuse_status
|
||||
.as_deref()
|
||||
.map(FuseStatus::from_str)
|
||||
.unwrap_or(FuseStatus::MissingLibfuse2);
|
||||
|
||||
let fuse_row = adw::ActionRow::builder()
|
||||
.title("FUSE")
|
||||
.subtitle(fuse_description(&fuse_status))
|
||||
.build();
|
||||
let fuse_badge = widgets::status_badge(fuse_status.label(), fuse_status.badge_class());
|
||||
fuse_badge.set_valign(gtk::Align::Center);
|
||||
fuse_row.add_suffix(&fuse_badge);
|
||||
list_box.append(&fuse_row);
|
||||
|
||||
section.append(&list_box);
|
||||
section
|
||||
}
|
||||
|
||||
fn wayland_description(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",
|
||||
}
|
||||
}
|
||||
|
||||
fn fuse_description(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",
|
||||
}
|
||||
}
|
||||
|
||||
fn build_updates_section(record: &AppImageRecord) -> gtk::Box {
|
||||
let section = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
let heading = gtk::Label::builder()
|
||||
.label("Updates")
|
||||
.css_classes(["heading"])
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
section.append(&heading);
|
||||
|
||||
let list_box = gtk::ListBox::new();
|
||||
list_box.add_css_class("boxed-list");
|
||||
list_box.set_selection_mode(gtk::SelectionMode::None);
|
||||
|
||||
// Update info type
|
||||
if let Some(ref update_type) = record.update_type {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Update method")
|
||||
.subtitle(update_type)
|
||||
.build();
|
||||
list_box.append(&row);
|
||||
} else {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Update method")
|
||||
.subtitle("No update information embedded")
|
||||
.build();
|
||||
let badge = widgets::status_badge("None", "neutral");
|
||||
badge.set_valign(gtk::Align::Center);
|
||||
row.add_suffix(&badge);
|
||||
list_box.append(&row);
|
||||
}
|
||||
|
||||
// Latest version / update status
|
||||
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);
|
||||
list_box.append(&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);
|
||||
list_box.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
// Last checked
|
||||
if let Some(ref checked) = record.update_checked {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Last checked")
|
||||
.subtitle(checked)
|
||||
.build();
|
||||
list_box.append(&row);
|
||||
}
|
||||
|
||||
section.append(&list_box);
|
||||
section
|
||||
}
|
||||
|
||||
fn build_usage_section(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
|
||||
let section = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
let heading = gtk::Label::builder()
|
||||
.label("Usage")
|
||||
.css_classes(["heading"])
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
section.append(&heading);
|
||||
|
||||
let list_box = gtk::ListBox::new();
|
||||
list_box.add_css_class("boxed-list");
|
||||
list_box.set_selection_mode(gtk::SelectionMode::None);
|
||||
|
||||
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();
|
||||
list_box.append(&launches_row);
|
||||
|
||||
if let Some(ref last) = stats.last_launched {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Last launched")
|
||||
.subtitle(last)
|
||||
.build();
|
||||
list_box.append(&row);
|
||||
}
|
||||
|
||||
section.append(&list_box);
|
||||
section
|
||||
}
|
||||
|
||||
fn build_file_details_section(record: &AppImageRecord) -> gtk::Box {
|
||||
let section = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
let heading = gtk::Label::builder()
|
||||
.label("File Details")
|
||||
.css_classes(["heading"])
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
section.append(&heading);
|
||||
|
||||
let list_box = gtk::ListBox::new();
|
||||
list_box.add_css_class("boxed-list");
|
||||
list_box.set_selection_mode(gtk::SelectionMode::None);
|
||||
|
||||
// Path
|
||||
let path_row = adw::ActionRow::builder()
|
||||
.title("Path")
|
||||
.subtitle(&record.path)
|
||||
.subtitle_selectable(true)
|
||||
.build();
|
||||
list_box.append(&path_row);
|
||||
|
||||
// Size
|
||||
let size_row = adw::ActionRow::builder()
|
||||
.title("Size")
|
||||
.subtitle(&widgets::format_size(record.size_bytes))
|
||||
.build();
|
||||
list_box.append(&size_row);
|
||||
|
||||
// Type
|
||||
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)
|
||||
.build();
|
||||
list_box.append(&type_row);
|
||||
|
||||
// Executable
|
||||
let exec_row = adw::ActionRow::builder()
|
||||
.title("Executable")
|
||||
.subtitle(if record.is_executable { "Yes" } else { "No" })
|
||||
.build();
|
||||
list_box.append(&exec_row);
|
||||
|
||||
// SHA256
|
||||
if let Some(ref hash) = record.sha256 {
|
||||
let hash_row = adw::ActionRow::builder()
|
||||
.title("SHA256")
|
||||
.subtitle(hash)
|
||||
.subtitle_selectable(true)
|
||||
.build();
|
||||
list_box.append(&hash_row);
|
||||
}
|
||||
|
||||
// First seen
|
||||
let seen_row = adw::ActionRow::builder()
|
||||
.title("First seen")
|
||||
.subtitle(&record.first_seen)
|
||||
.build();
|
||||
list_box.append(&seen_row);
|
||||
|
||||
// Last scanned
|
||||
let scanned_row = adw::ActionRow::builder()
|
||||
.title("Last scanned")
|
||||
.subtitle(&record.last_scanned)
|
||||
.build();
|
||||
list_box.append(&scanned_row);
|
||||
|
||||
// Notes
|
||||
if let Some(ref notes) = record.notes {
|
||||
if !notes.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Notes")
|
||||
.subtitle(notes)
|
||||
.build();
|
||||
list_box.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
section.append(&list_box);
|
||||
section
|
||||
}
|
||||
156
src/ui/duplicate_dialog.rs
Normal file
156
src/ui/duplicate_dialog.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
use adw::prelude::*;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::core::database::Database;
|
||||
use crate::core::duplicates::{self, DuplicateGroup, MatchReason, MemberRecommendation};
|
||||
use super::widgets;
|
||||
|
||||
/// Show a dialog listing duplicate/multi-version AppImages with resolution options.
|
||||
pub fn show_duplicate_dialog(
|
||||
parent: &impl IsA<gtk::Widget>,
|
||||
db: &Rc<Database>,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
let groups = duplicates::detect_duplicates(db);
|
||||
|
||||
if groups.is_empty() {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading("No Duplicates Found")
|
||||
.body("No duplicate or multi-version AppImages were detected.")
|
||||
.build();
|
||||
dialog.add_response("ok", "OK");
|
||||
dialog.set_default_response(Some("ok"));
|
||||
dialog.present(Some(parent));
|
||||
return;
|
||||
}
|
||||
|
||||
let summary = duplicates::summarize_duplicates(&groups);
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Duplicates & Old Versions")
|
||||
.content_width(600)
|
||||
.content_height(500)
|
||||
.build();
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
let header = adw::HeaderBar::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
|
||||
let scrolled = gtk::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(16)
|
||||
.margin_top(16)
|
||||
.margin_bottom(16)
|
||||
.margin_start(16)
|
||||
.margin_end(16)
|
||||
.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_label = gtk::Label::builder()
|
||||
.label(&summary_text)
|
||||
.wrap(true)
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
summary_label.add_css_class("dim-label");
|
||||
content.append(&summary_label);
|
||||
|
||||
// Build a list for each duplicate group
|
||||
for group in &groups {
|
||||
content.append(&build_group_widget(group));
|
||||
}
|
||||
|
||||
scrolled.set_child(Some(&content));
|
||||
toolbar.set_content(Some(&scrolled));
|
||||
dialog.set_child(Some(&toolbar));
|
||||
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
fn build_group_widget(group: &DuplicateGroup) -> gtk::Box {
|
||||
let container = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
// Group header
|
||||
let header_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
let name_label = gtk::Label::builder()
|
||||
.label(&group.app_name)
|
||||
.css_classes(["heading"])
|
||||
.halign(gtk::Align::Start)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
header_box.append(&name_label);
|
||||
|
||||
let reason_badge = widgets::status_badge(
|
||||
group.match_reason.label(),
|
||||
match group.match_reason {
|
||||
MatchReason::ExactDuplicate => "error",
|
||||
MatchReason::MultiVersion => "warning",
|
||||
MatchReason::SameVersionDifferentPath => "warning",
|
||||
},
|
||||
);
|
||||
header_box.append(&reason_badge);
|
||||
|
||||
container.append(&header_box);
|
||||
|
||||
// Savings info
|
||||
if group.potential_savings > 0 {
|
||||
let savings_label = gtk::Label::builder()
|
||||
.label(&format!(
|
||||
"Potential savings: {}",
|
||||
widgets::format_size(group.potential_savings as i64)
|
||||
))
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
savings_label.add_css_class("dim-label");
|
||||
container.append(&savings_label);
|
||||
}
|
||||
|
||||
// Members list
|
||||
let list_box = gtk::ListBox::new();
|
||||
list_box.add_css_class("boxed-list");
|
||||
list_box.set_selection_mode(gtk::SelectionMode::None);
|
||||
|
||||
for member in &group.members {
|
||||
let record = &member.record;
|
||||
let version = record.app_version.as_deref().unwrap_or("unknown");
|
||||
let size = widgets::format_size(record.size_bytes);
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&format!("{} ({})", version, size))
|
||||
.subtitle(&record.path)
|
||||
.build();
|
||||
|
||||
// Recommendation badge
|
||||
let badge_class = match member.recommendation {
|
||||
MemberRecommendation::KeepNewest | MemberRecommendation::KeepIntegrated => "success",
|
||||
MemberRecommendation::RemoveOlder | MemberRecommendation::RemoveDuplicate => "error",
|
||||
MemberRecommendation::UserChoice => "neutral",
|
||||
};
|
||||
let badge = widgets::status_badge(member.recommendation.label(), badge_class);
|
||||
badge.set_valign(gtk::Align::Center);
|
||||
row.add_suffix(&badge);
|
||||
|
||||
list_box.append(&row);
|
||||
}
|
||||
|
||||
container.append(&list_box);
|
||||
container
|
||||
}
|
||||
480
src/ui/library_view.rs
Normal file
480
src/ui/library_view.rs
Normal file
@@ -0,0 +1,480 @@
|
||||
use adw::prelude::*;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::core::database::AppImageRecord;
|
||||
use super::app_card;
|
||||
use super::widgets;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum ViewMode {
|
||||
Grid,
|
||||
List,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum LibraryState {
|
||||
Loading,
|
||||
Empty,
|
||||
Populated,
|
||||
SearchEmpty,
|
||||
}
|
||||
|
||||
pub struct LibraryView {
|
||||
pub page: adw::NavigationPage,
|
||||
pub header_bar: adw::HeaderBar,
|
||||
stack: gtk::Stack,
|
||||
flow_box: gtk::FlowBox,
|
||||
list_box: gtk::ListBox,
|
||||
search_bar: gtk::SearchBar,
|
||||
search_entry: gtk::SearchEntry,
|
||||
subtitle_label: gtk::Label,
|
||||
view_mode: Rc<Cell<ViewMode>>,
|
||||
view_toggle: gtk::ToggleButton,
|
||||
records: Rc<RefCell<Vec<AppImageRecord>>>,
|
||||
search_empty_page: adw::StatusPage,
|
||||
}
|
||||
|
||||
impl LibraryView {
|
||||
pub fn new(menu: >k::gio::Menu) -> Self {
|
||||
let records: Rc<RefCell<Vec<AppImageRecord>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
let view_mode = Rc::new(Cell::new(ViewMode::Grid));
|
||||
|
||||
// --- Header bar ---
|
||||
let menu_button = gtk::MenuButton::builder()
|
||||
.icon_name("open-menu-symbolic")
|
||||
.menu_model(menu)
|
||||
.tooltip_text("Menu")
|
||||
.primary(true)
|
||||
.build();
|
||||
menu_button.add_css_class("flat");
|
||||
|
||||
let search_button = gtk::ToggleButton::builder()
|
||||
.icon_name("system-search-symbolic")
|
||||
.tooltip_text("Search")
|
||||
.build();
|
||||
search_button.add_css_class("flat");
|
||||
|
||||
let view_toggle = gtk::ToggleButton::builder()
|
||||
.icon_name("view-list-symbolic")
|
||||
.tooltip_text("Toggle list view")
|
||||
.build();
|
||||
view_toggle.add_css_class("flat");
|
||||
|
||||
let subtitle_label = gtk::Label::builder()
|
||||
.css_classes(["dim-label"])
|
||||
.build();
|
||||
|
||||
let title_widget = adw::WindowTitle::builder()
|
||||
.title("Driftwood")
|
||||
.build();
|
||||
|
||||
let header_bar = adw::HeaderBar::builder()
|
||||
.title_widget(&title_widget)
|
||||
.build();
|
||||
header_bar.pack_end(&menu_button);
|
||||
header_bar.pack_end(&search_button);
|
||||
header_bar.pack_end(&view_toggle);
|
||||
|
||||
// --- Search bar ---
|
||||
let search_entry = gtk::SearchEntry::builder()
|
||||
.placeholder_text("Search AppImages...")
|
||||
.hexpand(true)
|
||||
.build();
|
||||
|
||||
let search_clamp = adw::Clamp::builder()
|
||||
.maximum_size(500)
|
||||
.child(&search_entry)
|
||||
.build();
|
||||
|
||||
let search_bar = gtk::SearchBar::builder()
|
||||
.child(&search_clamp)
|
||||
.search_mode_enabled(false)
|
||||
.build();
|
||||
search_bar.connect_entry(&search_entry);
|
||||
|
||||
// Bind search button to search bar
|
||||
search_button
|
||||
.bind_property("active", &search_bar, "search-mode-enabled")
|
||||
.bidirectional()
|
||||
.build();
|
||||
|
||||
// --- Content stack ---
|
||||
let stack = gtk::Stack::builder()
|
||||
.transition_type(gtk::StackTransitionType::Crossfade)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
// Loading state
|
||||
let loading_page = adw::StatusPage::builder()
|
||||
.title("Scanning for AppImages...")
|
||||
.build();
|
||||
let spinner = gtk::Spinner::builder()
|
||||
.spinning(true)
|
||||
.width_request(32)
|
||||
.height_request(32)
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
loading_page.set_child(Some(&spinner));
|
||||
stack.add_named(&loading_page, Some("loading"));
|
||||
|
||||
// Empty state
|
||||
let empty_button_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.halign(gtk::Align::Center)
|
||||
.spacing(12)
|
||||
.build();
|
||||
|
||||
let scan_now_btn = gtk::Button::builder()
|
||||
.label("Scan Now")
|
||||
.build();
|
||||
scan_now_btn.add_css_class("suggested-action");
|
||||
scan_now_btn.add_css_class("pill");
|
||||
|
||||
let prefs_btn = gtk::Button::builder()
|
||||
.label("Preferences")
|
||||
.build();
|
||||
prefs_btn.add_css_class("flat");
|
||||
prefs_btn.add_css_class("pill");
|
||||
|
||||
empty_button_box.append(&scan_now_btn);
|
||||
empty_button_box.append(&prefs_btn);
|
||||
|
||||
let empty_page = adw::StatusPage::builder()
|
||||
.icon_name("folder-saved-search-symbolic")
|
||||
.title("No AppImages Found")
|
||||
.description(
|
||||
"Driftwood looks for AppImages in ~/Applications and ~/Downloads.\n\
|
||||
Drop an AppImage file here, or add more scan locations in Preferences.",
|
||||
)
|
||||
.child(&empty_button_box)
|
||||
.build();
|
||||
stack.add_named(&empty_page, Some("empty"));
|
||||
|
||||
// Search empty state
|
||||
let search_empty_page = adw::StatusPage::builder()
|
||||
.icon_name("system-search-symbolic")
|
||||
.title("No Results")
|
||||
.description("No AppImages match your search. Try a different search term.")
|
||||
.build();
|
||||
stack.add_named(&search_empty_page, Some("search-empty"));
|
||||
|
||||
// Grid view
|
||||
let flow_box = gtk::FlowBox::builder()
|
||||
.valign(gtk::Align::Start)
|
||||
.selection_mode(gtk::SelectionMode::Single)
|
||||
.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();
|
||||
|
||||
let grid_scroll = gtk::ScrolledWindow::builder()
|
||||
.child(&flow_box)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
stack.add_named(&grid_scroll, Some("grid"));
|
||||
|
||||
// List view
|
||||
let list_box = gtk::ListBox::builder()
|
||||
.selection_mode(gtk::SelectionMode::Single)
|
||||
.build();
|
||||
list_box.add_css_class("boxed-list");
|
||||
|
||||
let list_clamp = adw::Clamp::builder()
|
||||
.maximum_size(900)
|
||||
.child(&list_box)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.build();
|
||||
|
||||
let list_scroll = gtk::ScrolledWindow::builder()
|
||||
.child(&list_clamp)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
stack.add_named(&list_scroll, Some("list"));
|
||||
|
||||
// --- Assemble toolbar view ---
|
||||
let content_box = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.build();
|
||||
content_box.append(&search_bar);
|
||||
content_box.append(&stack);
|
||||
|
||||
let toolbar_view = adw::ToolbarView::new();
|
||||
toolbar_view.add_top_bar(&header_bar);
|
||||
toolbar_view.set_content(Some(&content_box));
|
||||
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title("Driftwood")
|
||||
.tag("library")
|
||||
.child(&toolbar_view)
|
||||
.build();
|
||||
|
||||
// --- Wire up view toggle ---
|
||||
{
|
||||
let stack_ref = stack.clone();
|
||||
let view_mode_ref = view_mode.clone();
|
||||
let toggle_ref = view_toggle.clone();
|
||||
view_toggle.connect_toggled(move |btn| {
|
||||
if btn.is_active() {
|
||||
view_mode_ref.set(ViewMode::List);
|
||||
toggle_ref.set_icon_name("view-grid-symbolic");
|
||||
toggle_ref.set_tooltip_text(Some("Toggle grid view"));
|
||||
stack_ref.set_visible_child_name("list");
|
||||
} else {
|
||||
view_mode_ref.set(ViewMode::Grid);
|
||||
toggle_ref.set_icon_name("view-list-symbolic");
|
||||
toggle_ref.set_tooltip_text(Some("Toggle list view"));
|
||||
stack_ref.set_visible_child_name("grid");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Wire up search filtering ---
|
||||
{
|
||||
let flow_box_ref = flow_box.clone();
|
||||
let list_box_ref = list_box.clone();
|
||||
let records_ref = records.clone();
|
||||
let stack_ref = stack.clone();
|
||||
let view_mode_ref = view_mode.clone();
|
||||
let search_empty_ref = search_empty_page.clone();
|
||||
search_entry.connect_search_changed(move |entry| {
|
||||
let query = entry.text().to_string().to_lowercase();
|
||||
|
||||
if query.is_empty() {
|
||||
flow_box_ref.set_filter_func(|_| true);
|
||||
let mut i = 0;
|
||||
while let Some(row) = list_box_ref.row_at_index(i) {
|
||||
row.set_visible(true);
|
||||
i += 1;
|
||||
}
|
||||
if !records_ref.borrow().is_empty() {
|
||||
let view_name = if view_mode_ref.get() == ViewMode::Grid {
|
||||
"grid"
|
||||
} else {
|
||||
"list"
|
||||
};
|
||||
stack_ref.set_visible_child_name(view_name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a snapshot of match results for the filter closure
|
||||
let recs = records_ref.borrow();
|
||||
let match_flags: Vec<bool> = recs
|
||||
.iter()
|
||||
.map(|rec| {
|
||||
let name = rec.app_name.as_deref().unwrap_or(&rec.filename).to_lowercase();
|
||||
let path = rec.path.to_lowercase();
|
||||
name.contains(&query) || path.contains(&query)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let flags_clone = match_flags.clone();
|
||||
flow_box_ref.set_filter_func(move |child| {
|
||||
let idx = child.index() as usize;
|
||||
flags_clone.get(idx).copied().unwrap_or(false)
|
||||
});
|
||||
|
||||
let mut visible_count = 0;
|
||||
for (i, matches) in match_flags.iter().enumerate() {
|
||||
if let Some(row) = list_box_ref.row_at_index(i as i32) {
|
||||
row.set_visible(*matches);
|
||||
}
|
||||
if *matches {
|
||||
visible_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if visible_count == 0 && !recs.is_empty() {
|
||||
search_empty_ref.set_description(Some(
|
||||
&format!("No AppImages match '{}'. Try a different search term.", query)
|
||||
));
|
||||
stack_ref.set_visible_child_name("search-empty");
|
||||
} else {
|
||||
let view_name = if view_mode_ref.get() == ViewMode::Grid {
|
||||
"grid"
|
||||
} else {
|
||||
"list"
|
||||
};
|
||||
stack_ref.set_visible_child_name(view_name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Wire up empty state buttons ---
|
||||
// These will be connected to actions externally via the public methods
|
||||
scan_now_btn.set_action_name(Some("win.scan"));
|
||||
prefs_btn.set_action_name(Some("win.preferences"));
|
||||
|
||||
Self {
|
||||
page,
|
||||
header_bar,
|
||||
stack,
|
||||
flow_box,
|
||||
list_box,
|
||||
search_bar,
|
||||
search_entry,
|
||||
subtitle_label,
|
||||
view_mode,
|
||||
view_toggle,
|
||||
records,
|
||||
search_empty_page,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_state(&self, state: LibraryState) {
|
||||
match state {
|
||||
LibraryState::Loading => {
|
||||
self.stack.set_visible_child_name("loading");
|
||||
}
|
||||
LibraryState::Empty => {
|
||||
self.stack.set_visible_child_name("empty");
|
||||
}
|
||||
LibraryState::Populated => {
|
||||
let view_name = if self.view_mode.get() == ViewMode::Grid {
|
||||
"grid"
|
||||
} else {
|
||||
"list"
|
||||
};
|
||||
self.stack.set_visible_child_name(view_name);
|
||||
}
|
||||
LibraryState::SearchEmpty => {
|
||||
self.stack.set_visible_child_name("search-empty");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn populate(&self, new_records: Vec<AppImageRecord>) {
|
||||
// Clear existing
|
||||
while let Some(child) = self.flow_box.first_child() {
|
||||
self.flow_box.remove(&child);
|
||||
}
|
||||
while let Some(row) = self.list_box.row_at_index(0) {
|
||||
self.list_box.remove(&row);
|
||||
}
|
||||
|
||||
// Build cards and list rows
|
||||
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);
|
||||
}
|
||||
|
||||
*self.records.borrow_mut() = new_records;
|
||||
let count = self.records.borrow().len();
|
||||
|
||||
if count == 0 {
|
||||
self.set_state(LibraryState::Empty);
|
||||
} else {
|
||||
self.set_state(LibraryState::Populated);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_list_row(&self, record: &AppImageRecord) -> adw::ActionRow {
|
||||
let name = record.app_name.as_deref().unwrap_or(&record.filename);
|
||||
let subtitle = if let Some(ref ver) = record.app_version {
|
||||
format!("{} - {}", ver, widgets::format_size(record.size_bytes))
|
||||
} else {
|
||||
widgets::format_size(record.size_bytes)
|
||||
};
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(name)
|
||||
.subtitle(&subtitle)
|
||||
.activatable(true)
|
||||
.build();
|
||||
|
||||
// Icon prefix
|
||||
if let Some(ref icon_path) = record.icon_path {
|
||||
let path = std::path::Path::new(icon_path);
|
||||
if path.exists() {
|
||||
if let Ok(texture) = gtk::gdk::Texture::from_filename(path) {
|
||||
let image = gtk::Image::builder()
|
||||
.pixel_size(32)
|
||||
.build();
|
||||
image.set_paintable(Some(&texture));
|
||||
row.add_prefix(&image);
|
||||
}
|
||||
} else {
|
||||
let image = gtk::Image::builder()
|
||||
.icon_name("application-x-executable-symbolic")
|
||||
.pixel_size(32)
|
||||
.build();
|
||||
row.add_prefix(&image);
|
||||
}
|
||||
} else {
|
||||
let image = gtk::Image::builder()
|
||||
.icon_name("application-x-executable-symbolic")
|
||||
.pixel_size(32)
|
||||
.build();
|
||||
row.add_prefix(&image);
|
||||
}
|
||||
|
||||
// Integration badge suffix
|
||||
let badge = widgets::integration_badge(record.integrated);
|
||||
row.add_suffix(&badge);
|
||||
|
||||
// Navigate arrow
|
||||
let arrow = gtk::Image::from_icon_name("go-next-symbolic");
|
||||
row.add_suffix(&arrow);
|
||||
|
||||
row
|
||||
}
|
||||
|
||||
/// Get the record ID at a given flow box index.
|
||||
pub fn record_at_grid_index(&self, index: usize) -> Option<i64> {
|
||||
self.records.borrow().get(index).map(|r| r.id)
|
||||
}
|
||||
|
||||
/// Get the record ID at a given list box index.
|
||||
pub fn record_at_list_index(&self, index: i32) -> Option<i64> {
|
||||
self.records.borrow().get(index as usize).map(|r| r.id)
|
||||
}
|
||||
|
||||
/// Connect a callback for when a grid card is activated.
|
||||
pub fn connect_grid_activated<F: Fn(i64) + 'static>(&self, f: F) {
|
||||
let records = self.records.clone();
|
||||
self.flow_box.connect_child_activated(move |_, child| {
|
||||
let idx = child.index() as usize;
|
||||
if let Some(record) = records.borrow().get(idx) {
|
||||
f(record.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Connect a callback for when a list row is activated.
|
||||
pub fn connect_list_activated<F: Fn(i64) + 'static>(&self, f: F) {
|
||||
let records = self.records.clone();
|
||||
self.list_box.connect_row_activated(move |_, row| {
|
||||
let idx = row.index() as usize;
|
||||
if let Some(record) = records.borrow().get(idx) {
|
||||
f(record.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn current_view_mode(&self) -> ViewMode {
|
||||
self.view_mode.get()
|
||||
}
|
||||
|
||||
pub fn toggle_search(&self) {
|
||||
let active = self.search_bar.is_search_mode();
|
||||
self.search_bar.set_search_mode(!active);
|
||||
if !active {
|
||||
self.search_entry.grab_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
8
src/ui/mod.rs
Normal file
8
src/ui/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub mod app_card;
|
||||
pub mod dashboard;
|
||||
pub mod detail_view;
|
||||
pub mod duplicate_dialog;
|
||||
pub mod library_view;
|
||||
pub mod preferences;
|
||||
pub mod update_dialog;
|
||||
pub mod widgets;
|
||||
157
src/ui/preferences.rs
Normal file
157
src/ui/preferences.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
use adw::prelude::*;
|
||||
use gtk::gio;
|
||||
|
||||
use crate::config::APP_ID;
|
||||
|
||||
pub fn show_preferences_dialog(parent: &impl IsA<gtk::Widget>) {
|
||||
let dialog = adw::PreferencesDialog::new();
|
||||
dialog.set_title("Preferences");
|
||||
|
||||
let settings = gio::Settings::new(APP_ID);
|
||||
|
||||
// --- General page ---
|
||||
let general_page = adw::PreferencesPage::builder()
|
||||
.title("General")
|
||||
.icon_name("emblem-system-symbolic")
|
||||
.build();
|
||||
|
||||
// Appearance group
|
||||
let appearance_group = adw::PreferencesGroup::builder()
|
||||
.title("Appearance")
|
||||
.build();
|
||||
|
||||
let theme_row = adw::ComboRow::builder()
|
||||
.title("Color Scheme")
|
||||
.subtitle("Choose light, dark, or follow system preference")
|
||||
.build();
|
||||
|
||||
let model = gtk::StringList::new(&["Follow System", "Light", "Dark"]);
|
||||
theme_row.set_model(Some(&model));
|
||||
|
||||
let current = settings.string("color-scheme");
|
||||
theme_row.set_selected(match current.as_str() {
|
||||
"force-light" => 1,
|
||||
"force-dark" => 2,
|
||||
_ => 0,
|
||||
});
|
||||
|
||||
let settings_clone = settings.clone();
|
||||
theme_row.connect_selected_notify(move |row| {
|
||||
let value = match row.selected() {
|
||||
1 => "force-light",
|
||||
2 => "force-dark",
|
||||
_ => "default",
|
||||
};
|
||||
settings_clone.set_string("color-scheme", value).ok();
|
||||
});
|
||||
|
||||
appearance_group.add(&theme_row);
|
||||
general_page.add(&appearance_group);
|
||||
|
||||
// Scan Locations group
|
||||
let scan_group = adw::PreferencesGroup::builder()
|
||||
.title("Scan Locations")
|
||||
.description("Directories to scan for AppImage files")
|
||||
.build();
|
||||
|
||||
let dirs = settings.strv("scan-directories");
|
||||
let dir_list_box = gtk::ListBox::new();
|
||||
dir_list_box.add_css_class("boxed-list");
|
||||
dir_list_box.set_selection_mode(gtk::SelectionMode::None);
|
||||
|
||||
for dir in &dirs {
|
||||
add_directory_row(&dir_list_box, &dir, &settings);
|
||||
}
|
||||
|
||||
scan_group.add(&dir_list_box);
|
||||
|
||||
// Add location button
|
||||
let add_button = gtk::Button::builder()
|
||||
.label("Add Location")
|
||||
.build();
|
||||
add_button.add_css_class("flat");
|
||||
|
||||
let settings_add = settings.clone();
|
||||
let list_box_ref = dir_list_box.clone();
|
||||
let dialog_weak = dialog.downgrade();
|
||||
add_button.connect_clicked(move |_| {
|
||||
let file_dialog = gtk::FileDialog::builder()
|
||||
.title("Choose a directory")
|
||||
.modal(true)
|
||||
.build();
|
||||
|
||||
let settings_ref = settings_add.clone();
|
||||
let list_ref = list_box_ref.clone();
|
||||
let dlg = dialog_weak.upgrade();
|
||||
// Get the root window as the transient parent for the file dialog
|
||||
let parent_window: Option<gtk::Window> = dlg
|
||||
.as_ref()
|
||||
.and_then(|d| d.root())
|
||||
.and_then(|r| r.downcast::<gtk::Window>().ok());
|
||||
file_dialog.select_folder(
|
||||
parent_window.as_ref(),
|
||||
None::<&gio::Cancellable>,
|
||||
move |result| {
|
||||
if let Ok(file) = result {
|
||||
if let Some(path) = file.path() {
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
|
||||
let mut current_dirs: Vec<String> = settings_ref
|
||||
.strv("scan-directories")
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
if !current_dirs.contains(&path_str) {
|
||||
current_dirs.push(path_str.clone());
|
||||
let refs: Vec<&str> =
|
||||
current_dirs.iter().map(|s| s.as_str()).collect();
|
||||
settings_ref.set_strv("scan-directories", refs).ok();
|
||||
|
||||
add_directory_row(&list_ref, &path_str, &settings_ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
scan_group.add(&add_button);
|
||||
general_page.add(&scan_group);
|
||||
|
||||
dialog.add(&general_page);
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
fn add_directory_row(list_box: >k::ListBox, dir: &str, settings: &gio::Settings) {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(dir)
|
||||
.build();
|
||||
|
||||
let remove_btn = gtk::Button::builder()
|
||||
.icon_name("edit-delete-symbolic")
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text("Remove")
|
||||
.build();
|
||||
remove_btn.add_css_class("flat");
|
||||
|
||||
let list_ref = list_box.clone();
|
||||
let settings_ref = settings.clone();
|
||||
let dir_str = dir.to_string();
|
||||
let row_ref = row.clone();
|
||||
remove_btn.connect_clicked(move |_| {
|
||||
let current_dirs: Vec<String> = settings_ref
|
||||
.strv("scan-directories")
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.filter(|s| s != &dir_str)
|
||||
.collect();
|
||||
let refs: Vec<&str> = current_dirs.iter().map(|s| s.as_str()).collect();
|
||||
settings_ref.set_strv("scan-directories", refs).ok();
|
||||
|
||||
list_ref.remove(&row_ref);
|
||||
});
|
||||
|
||||
row.add_suffix(&remove_btn);
|
||||
list_box.append(&row);
|
||||
}
|
||||
147
src/ui/update_dialog.rs
Normal file
147
src/ui/update_dialog.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use adw::prelude::*;
|
||||
use gtk::gio;
|
||||
use std::rc::Rc;
|
||||
use crate::core::database::{AppImageRecord, Database};
|
||||
use crate::core::updater;
|
||||
|
||||
/// Show an update check + apply dialog for a single AppImage.
|
||||
pub fn show_update_dialog(
|
||||
parent: &impl IsA<gtk::Widget>,
|
||||
record: &AppImageRecord,
|
||||
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)
|
||||
))
|
||||
.build();
|
||||
dialog.add_response("close", "Close");
|
||||
dialog.set_default_response(Some("close"));
|
||||
dialog.set_close_response("close");
|
||||
|
||||
let record_clone = record.clone();
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
|
||||
// Start the update check in the background
|
||||
let record_id = record.id;
|
||||
let path = record.path.clone();
|
||||
let current_version = record.app_version.clone();
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let result = gio::spawn_blocking(move || {
|
||||
let appimage_path = std::path::Path::new(&path);
|
||||
updater::check_appimage_for_update(
|
||||
appimage_path,
|
||||
current_version.as_deref(),
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok((type_label, raw_info, Some(check_result))) => {
|
||||
// Store update info in DB
|
||||
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
db_ref
|
||||
.update_update_info(
|
||||
record_id,
|
||||
raw_info.as_deref(),
|
||||
type_label.as_deref(),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if check_result.update_available {
|
||||
if let Some(ref version) = check_result.latest_version {
|
||||
db_ref.set_update_available(record_id, Some(version), check_result.download_url.as_deref()).ok();
|
||||
}
|
||||
|
||||
let body = format!(
|
||||
"{} -> {}\n\nA new version is available.",
|
||||
record_clone.app_version.as_deref().unwrap_or("unknown"),
|
||||
check_result.latest_version.as_deref().unwrap_or("unknown"),
|
||||
);
|
||||
dialog_ref.set_heading(Some("Update Available"));
|
||||
dialog_ref.set_body(&body);
|
||||
// Future: add "Update" response to trigger download
|
||||
} 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"),
|
||||
));
|
||||
db_ref.clear_update_available(record_id).ok();
|
||||
}
|
||||
}
|
||||
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.");
|
||||
} else {
|
||||
dialog_ref.set_heading(Some("No Update Info"));
|
||||
dialog_ref.set_body(
|
||||
"This AppImage does not contain update information. \
|
||||
Updates must be downloaded manually.",
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
dialog_ref.set_heading(Some("Error"));
|
||||
dialog_ref.set_body("An error occurred while checking for updates.");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
/// Batch check all AppImages for updates. Returns count of updates found.
|
||||
pub fn batch_check_updates(db: &Database) -> u32 {
|
||||
let records = match db.get_all_appimages() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::error!("Failed to get appimages for update check: {}", e);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
let mut updates_found = 0u32;
|
||||
|
||||
for record in &records {
|
||||
let appimage_path = std::path::Path::new(&record.path);
|
||||
if !appimage_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (type_label, raw_info, check_result) = updater::check_appimage_for_update(
|
||||
appimage_path,
|
||||
record.app_version.as_deref(),
|
||||
);
|
||||
|
||||
// Store update info
|
||||
if raw_info.is_some() || type_label.is_some() {
|
||||
db.update_update_info(
|
||||
record.id,
|
||||
raw_info.as_deref(),
|
||||
type_label.as_deref(),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
if let Some(result) = check_result {
|
||||
if result.update_available {
|
||||
if let Some(ref version) = result.latest_version {
|
||||
db.set_update_available(record.id, Some(version), result.download_url.as_deref()).ok();
|
||||
updates_found += 1;
|
||||
}
|
||||
} else {
|
||||
db.clear_update_available(record.id).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updates_found
|
||||
}
|
||||
24
src/ui/widgets.rs
Normal file
24
src/ui/widgets.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use gtk::prelude::*;
|
||||
|
||||
/// Create a status badge pill label with the given text and style class.
|
||||
/// Style classes: "success", "warning", "error", "info", "neutral"
|
||||
pub fn status_badge(text: &str, style_class: &str) -> gtk::Label {
|
||||
let label = gtk::Label::new(Some(text));
|
||||
label.add_css_class("status-badge");
|
||||
label.add_css_class(style_class);
|
||||
label
|
||||
}
|
||||
|
||||
/// Create a badge showing integration status.
|
||||
pub fn integration_badge(integrated: bool) -> gtk::Label {
|
||||
if integrated {
|
||||
status_badge("Integrated", "success")
|
||||
} else {
|
||||
status_badge("Not integrated", "neutral")
|
||||
}
|
||||
}
|
||||
|
||||
/// Format bytes into a human-readable string.
|
||||
pub fn format_size(bytes: i64) -> String {
|
||||
humansize::format_size(bytes as u64, humansize::BINARY)
|
||||
}
|
||||
476
src/window.rs
Normal file
476
src/window.rs
Normal file
@@ -0,0 +1,476 @@
|
||||
use adw::prelude::*;
|
||||
use adw::subclass::prelude::*;
|
||||
use gtk::gio;
|
||||
use std::cell::OnceCell;
|
||||
use std::rc::Rc;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::config::APP_ID;
|
||||
use crate::core::database::Database;
|
||||
use crate::core::discovery;
|
||||
use crate::core::inspector;
|
||||
use crate::core::orphan;
|
||||
use crate::ui::dashboard;
|
||||
use crate::ui::detail_view;
|
||||
use crate::ui::duplicate_dialog;
|
||||
use crate::ui::library_view::{LibraryState, LibraryView};
|
||||
use crate::ui::preferences;
|
||||
use crate::ui::update_dialog;
|
||||
|
||||
mod imp {
|
||||
use super::*;
|
||||
|
||||
pub struct DriftwoodWindow {
|
||||
pub settings: OnceCell<gio::Settings>,
|
||||
pub toast_overlay: OnceCell<adw::ToastOverlay>,
|
||||
pub navigation_view: OnceCell<adw::NavigationView>,
|
||||
pub library_view: OnceCell<LibraryView>,
|
||||
pub database: OnceCell<Rc<Database>>,
|
||||
}
|
||||
|
||||
impl Default for DriftwoodWindow {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
settings: OnceCell::new(),
|
||||
toast_overlay: OnceCell::new(),
|
||||
navigation_view: OnceCell::new(),
|
||||
library_view: OnceCell::new(),
|
||||
database: OnceCell::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for DriftwoodWindow {
|
||||
const NAME: &'static str = "DriftwoodWindow";
|
||||
type Type = super::DriftwoodWindow;
|
||||
type ParentType = adw::ApplicationWindow;
|
||||
}
|
||||
|
||||
impl ObjectImpl for DriftwoodWindow {
|
||||
fn constructed(&self) {
|
||||
self.parent_constructed();
|
||||
let window = self.obj();
|
||||
window.setup_settings();
|
||||
window.setup_database();
|
||||
window.setup_ui();
|
||||
window.restore_window_state();
|
||||
window.load_initial_data();
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetImpl for DriftwoodWindow {}
|
||||
impl WindowImpl for DriftwoodWindow {
|
||||
fn close_request(&self) -> glib::Propagation {
|
||||
self.obj().save_window_state();
|
||||
self.parent_close_request()
|
||||
}
|
||||
}
|
||||
impl ApplicationWindowImpl for DriftwoodWindow {}
|
||||
impl AdwApplicationWindowImpl for DriftwoodWindow {}
|
||||
}
|
||||
|
||||
glib::wrapper! {
|
||||
pub struct DriftwoodWindow(ObjectSubclass<imp::DriftwoodWindow>)
|
||||
@extends adw::ApplicationWindow, gtk::ApplicationWindow, gtk::Window, gtk::Widget,
|
||||
@implements gio::ActionGroup, gio::ActionMap,
|
||||
gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget,
|
||||
gtk::Native, gtk::Root, gtk::ShortcutManager;
|
||||
}
|
||||
|
||||
impl DriftwoodWindow {
|
||||
pub fn new(app: &crate::application::DriftwoodApplication) -> Self {
|
||||
glib::Object::builder()
|
||||
.property("application", app)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn setup_settings(&self) {
|
||||
let settings = gio::Settings::new(APP_ID);
|
||||
self.imp()
|
||||
.settings
|
||||
.set(settings)
|
||||
.expect("Settings already initialized");
|
||||
}
|
||||
|
||||
fn settings(&self) -> &gio::Settings {
|
||||
self.imp().settings.get().expect("Settings not initialized")
|
||||
}
|
||||
|
||||
fn setup_database(&self) {
|
||||
let db = Database::open().expect("Failed to open database");
|
||||
if self.imp().database.set(Rc::new(db)).is_err() {
|
||||
panic!("Database already initialized");
|
||||
}
|
||||
}
|
||||
|
||||
fn database(&self) -> &Rc<Database> {
|
||||
self.imp().database.get().expect("Database not initialized")
|
||||
}
|
||||
|
||||
fn setup_ui(&self) {
|
||||
// Build the hamburger menu model
|
||||
let menu = gio::Menu::new();
|
||||
menu.append(Some("Dashboard"), Some("win.dashboard"));
|
||||
menu.append(Some("Preferences"), Some("win.preferences"));
|
||||
|
||||
let section2 = gio::Menu::new();
|
||||
section2.append(Some("Scan for AppImages"), Some("win.scan"));
|
||||
section2.append(Some("Check for Updates"), Some("win.check-updates"));
|
||||
section2.append(Some("Find Duplicates"), Some("win.find-duplicates"));
|
||||
menu.append_section(None, §ion2);
|
||||
|
||||
let section3 = gio::Menu::new();
|
||||
section3.append(Some("About Driftwood"), Some("app.about"));
|
||||
menu.append_section(None, §ion3);
|
||||
|
||||
// Library view (contains header bar, search, grid/list, empty state)
|
||||
let library_view = LibraryView::new(&menu);
|
||||
|
||||
// Navigation view
|
||||
let navigation_view = adw::NavigationView::new();
|
||||
navigation_view.push(&library_view.page);
|
||||
|
||||
// Toast overlay wraps everything
|
||||
let toast_overlay = adw::ToastOverlay::new();
|
||||
toast_overlay.set_child(Some(&navigation_view));
|
||||
|
||||
self.set_content(Some(&toast_overlay));
|
||||
|
||||
// Wire up card/row activation to push detail view
|
||||
{
|
||||
let nav = navigation_view.clone();
|
||||
let db = self.database().clone();
|
||||
library_view.connect_grid_activated(move |record_id| {
|
||||
if let Ok(Some(record)) = db.get_appimage_by_id(record_id) {
|
||||
let page = detail_view::build_detail_page(&record, &db);
|
||||
nav.push(&page);
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let nav = navigation_view.clone();
|
||||
let db = self.database().clone();
|
||||
library_view.connect_list_activated(move |record_id| {
|
||||
if let Ok(Some(record)) = db.get_appimage_by_id(record_id) {
|
||||
let page = detail_view::build_detail_page(&record, &db);
|
||||
nav.push(&page);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Store references
|
||||
self.imp()
|
||||
.toast_overlay
|
||||
.set(toast_overlay)
|
||||
.expect("ToastOverlay already set");
|
||||
self.imp()
|
||||
.navigation_view
|
||||
.set(navigation_view)
|
||||
.expect("NavigationView already set");
|
||||
if self.imp().library_view.set(library_view).is_err() {
|
||||
panic!("LibraryView already set");
|
||||
}
|
||||
|
||||
// Set up window actions
|
||||
self.setup_window_actions();
|
||||
}
|
||||
|
||||
fn setup_window_actions(&self) {
|
||||
let dashboard_action = gio::ActionEntry::builder("dashboard")
|
||||
.activate(|window: &Self, _, _| {
|
||||
let db = window.database().clone();
|
||||
let nav = window.imp().navigation_view.get().unwrap();
|
||||
let page = dashboard::build_dashboard_page(&db);
|
||||
nav.push(&page);
|
||||
})
|
||||
.build();
|
||||
|
||||
// Preferences action
|
||||
let preferences_action = gio::ActionEntry::builder("preferences")
|
||||
.activate(|window: &Self, _, _| {
|
||||
preferences::show_preferences_dialog(window);
|
||||
})
|
||||
.build();
|
||||
|
||||
// Scan action - runs real scan
|
||||
let scan_action = gio::ActionEntry::builder("scan")
|
||||
.activate(|window: &Self, _, _| {
|
||||
window.trigger_scan();
|
||||
})
|
||||
.build();
|
||||
|
||||
// Clean orphans action
|
||||
let clean_toast = self.imp().toast_overlay.get().unwrap().clone();
|
||||
let clean_action = gio::ActionEntry::builder("clean-orphans")
|
||||
.activate(move |_window: &Self, _, _| {
|
||||
let toast_ref = clean_toast.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = gio::spawn_blocking(|| {
|
||||
orphan::clean_all_orphans()
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(summary)) => {
|
||||
let msg = format!(
|
||||
"Cleaned {} desktop entries, {} icons",
|
||||
summary.entries_removed,
|
||||
summary.icons_removed,
|
||||
);
|
||||
toast_ref.add_toast(adw::Toast::new(&msg));
|
||||
}
|
||||
_ => {
|
||||
toast_ref.add_toast(adw::Toast::new("Failed to clean orphaned entries"));
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.build();
|
||||
|
||||
// Search action - toggles search bar
|
||||
let search_action = gio::ActionEntry::builder("search")
|
||||
.activate(|window: &Self, _, _| {
|
||||
let lib_view = window.imp().library_view.get().unwrap();
|
||||
lib_view.toggle_search();
|
||||
})
|
||||
.build();
|
||||
|
||||
// Check for updates action
|
||||
let updates_toast = self.imp().toast_overlay.get().unwrap().clone();
|
||||
let check_updates_action = gio::ActionEntry::builder("check-updates")
|
||||
.activate(move |window: &Self, _, _| {
|
||||
let toast_ref = updates_toast.clone();
|
||||
let db = window.database().clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = gio::spawn_blocking(move || {
|
||||
let bg_db = Database::open().expect("Failed to open database");
|
||||
update_dialog::batch_check_updates(&bg_db)
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(0) => {
|
||||
toast_ref.add_toast(adw::Toast::new("All AppImages are up to date"));
|
||||
}
|
||||
Ok(n) => {
|
||||
let msg = format!("{} update{} available", n, if n == 1 { "" } else { "s" });
|
||||
toast_ref.add_toast(adw::Toast::new(&msg));
|
||||
}
|
||||
Err(_) => {
|
||||
toast_ref.add_toast(adw::Toast::new("Failed to check for updates"));
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.build();
|
||||
|
||||
// Find duplicates action
|
||||
let find_duplicates_action = gio::ActionEntry::builder("find-duplicates")
|
||||
.activate(|window: &Self, _, _| {
|
||||
let db = window.database().clone();
|
||||
let toast_overlay = window.imp().toast_overlay.get().unwrap();
|
||||
duplicate_dialog::show_duplicate_dialog(window, &db, toast_overlay);
|
||||
})
|
||||
.build();
|
||||
|
||||
self.add_action_entries([
|
||||
dashboard_action,
|
||||
preferences_action,
|
||||
scan_action,
|
||||
clean_action,
|
||||
search_action,
|
||||
check_updates_action,
|
||||
find_duplicates_action,
|
||||
]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
if let Some(app) = self.application() {
|
||||
let gtk_app = app.downcast_ref::<gtk::Application>().unwrap();
|
||||
gtk_app.set_accels_for_action("win.scan", &["<Control>r", "F5"]);
|
||||
gtk_app.set_accels_for_action("win.search", &["<Control>f"]);
|
||||
gtk_app.set_accels_for_action("win.preferences", &["<Control>comma"]);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_initial_data(&self) {
|
||||
let db = self.database();
|
||||
let library_view = self.imp().library_view.get().unwrap();
|
||||
|
||||
match db.get_all_appimages() {
|
||||
Ok(records) if !records.is_empty() => {
|
||||
library_view.populate(records);
|
||||
}
|
||||
_ => {
|
||||
// Empty database - show empty state
|
||||
library_view.set_state(LibraryState::Empty);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for orphaned desktop entries in the background
|
||||
let toast_overlay = self.imp().toast_overlay.get().unwrap().clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = gio::spawn_blocking(|| {
|
||||
orphan::detect_orphans().len()
|
||||
})
|
||||
.await;
|
||||
|
||||
if let Ok(count) = result {
|
||||
if count > 0 {
|
||||
let msg = if count == 1 {
|
||||
"1 orphaned desktop entry found. Use 'Clean' to remove it.".to_string()
|
||||
} else {
|
||||
format!("{} orphaned desktop entries found. Use 'Clean' to remove them.", count)
|
||||
};
|
||||
let toast = adw::Toast::builder()
|
||||
.title(&msg)
|
||||
.timeout(5)
|
||||
.button_label("Clean")
|
||||
.action_name("win.clean-orphans")
|
||||
.build();
|
||||
toast_overlay.add_toast(toast);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn trigger_scan(&self) {
|
||||
let library_view = self.imp().library_view.get().unwrap();
|
||||
library_view.set_state(LibraryState::Loading);
|
||||
|
||||
let settings = self.settings();
|
||||
let dirs: Vec<String> = settings
|
||||
.strv("scan-directories")
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
let toast_overlay = self.imp().toast_overlay.get().unwrap().clone();
|
||||
let window_weak = self.downgrade();
|
||||
|
||||
// Run scan in a background thread (opens its own DB connection),
|
||||
// then update UI on main thread using the window's DB.
|
||||
glib::spawn_future_local(async move {
|
||||
let result = gio::spawn_blocking(move || {
|
||||
let bg_db = Database::open().expect("Failed to open database for scan");
|
||||
let start = Instant::now();
|
||||
let discovered = discovery::scan_directories(&dirs);
|
||||
|
||||
let mut new_count = 0i32;
|
||||
let total = discovered.len() as i32;
|
||||
|
||||
for d in &discovered {
|
||||
let existing = bg_db
|
||||
.get_appimage_by_path(&d.path.to_string_lossy())
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let modified = d.modified_time
|
||||
.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 id = bg_db.upsert_appimage(
|
||||
&d.path.to_string_lossy(),
|
||||
&d.filename,
|
||||
Some(d.appimage_type.as_i32()),
|
||||
d.size_bytes as i64,
|
||||
d.is_executable,
|
||||
modified.as_deref(),
|
||||
).unwrap_or(0);
|
||||
|
||||
if existing.is_none() {
|
||||
new_count += 1;
|
||||
}
|
||||
|
||||
let needs_metadata = existing
|
||||
.as_ref()
|
||||
.map(|r| r.app_name.is_none())
|
||||
.unwrap_or(true);
|
||||
|
||||
if needs_metadata {
|
||||
if let Ok(metadata) = inspector::inspect_appimage(&d.path, &d.appimage_type) {
|
||||
let categories = if metadata.categories.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(metadata.categories.join(";"))
|
||||
};
|
||||
bg_db.update_metadata(
|
||||
id,
|
||||
metadata.app_name.as_deref(),
|
||||
metadata.app_version.as_deref(),
|
||||
metadata.description.as_deref(),
|
||||
metadata.developer.as_deref(),
|
||||
categories.as_deref(),
|
||||
metadata.architecture.as_deref(),
|
||||
metadata.cached_icon_path.as_ref().map(|p| p.to_string_lossy()).as_deref(),
|
||||
Some(&metadata.desktop_entry_content),
|
||||
).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let duration = start.elapsed().as_millis() as i64;
|
||||
bg_db.log_scan(
|
||||
"manual",
|
||||
&dirs.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
|
||||
total,
|
||||
new_count,
|
||||
0,
|
||||
duration,
|
||||
).ok();
|
||||
|
||||
(total, new_count)
|
||||
})
|
||||
.await;
|
||||
|
||||
if let Ok((total, new_count)) = result {
|
||||
// Refresh the library view from the window's main-thread DB
|
||||
if let Some(window) = window_weak.upgrade() {
|
||||
let db = window.database();
|
||||
let lib_view = window.imp().library_view.get().unwrap();
|
||||
match db.get_all_appimages() {
|
||||
Ok(records) => lib_view.populate(records),
|
||||
Err(_) => lib_view.set_state(LibraryState::Empty),
|
||||
}
|
||||
}
|
||||
|
||||
let msg = match new_count {
|
||||
0 if total == 0 => "No AppImages found".to_string(),
|
||||
0 => format!("{} AppImages up to date", total),
|
||||
1 => "Found 1 new AppImage".to_string(),
|
||||
n => format!("Found {} new AppImages", n),
|
||||
};
|
||||
toast_overlay.add_toast(adw::Toast::new(&msg));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn save_window_state(&self) {
|
||||
let settings = self.settings();
|
||||
let (width, height) = self.default_size();
|
||||
settings
|
||||
.set_int("window-width", width)
|
||||
.expect("Failed to save window width");
|
||||
settings
|
||||
.set_int("window-height", height)
|
||||
.expect("Failed to save window height");
|
||||
settings
|
||||
.set_boolean("window-maximized", self.is_maximized())
|
||||
.expect("Failed to save maximized state");
|
||||
}
|
||||
|
||||
fn restore_window_state(&self) {
|
||||
let settings = self.settings();
|
||||
let width = settings.int("window-width");
|
||||
let height = settings.int("window-height");
|
||||
let maximized = settings.boolean("window-maximized");
|
||||
|
||||
self.set_default_size(width, height);
|
||||
if maximized {
|
||||
self.maximize();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user