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

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");
}
}