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:
lashman
2026-02-26 23:04:27 +02:00
parent 588b1b1525
commit fa28955919
33 changed files with 10401 additions and 0 deletions

777
src/core/database.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

406
src/core/wayland.rs Normal file
View 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");
}
}