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:
355
src/core/fuse.rs
Normal file
355
src/core/fuse.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum FuseStatus {
|
||||
/// libfuse2 available, fusermount present, /dev/fuse exists - fully working
|
||||
FullyFunctional,
|
||||
/// Only libfuse3 installed - most AppImages won't mount natively
|
||||
Fuse3Only,
|
||||
/// fusermount binary not found
|
||||
NoFusermount,
|
||||
/// /dev/fuse device not present (container or WSL)
|
||||
NoDevFuse,
|
||||
/// libfuse2 not installed
|
||||
MissingLibfuse2,
|
||||
}
|
||||
|
||||
impl FuseStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::FullyFunctional => "fully_functional",
|
||||
Self::Fuse3Only => "fuse3_only",
|
||||
Self::NoFusermount => "no_fusermount",
|
||||
Self::NoDevFuse => "no_dev_fuse",
|
||||
Self::MissingLibfuse2 => "missing_libfuse2",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"fully_functional" => Self::FullyFunctional,
|
||||
"fuse3_only" => Self::Fuse3Only,
|
||||
"no_fusermount" => Self::NoFusermount,
|
||||
"no_dev_fuse" => Self::NoDevFuse,
|
||||
_ => Self::MissingLibfuse2,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::FullyFunctional => "OK",
|
||||
Self::Fuse3Only => "FUSE3 only",
|
||||
Self::NoFusermount => "No fusermount",
|
||||
Self::NoDevFuse => "No /dev/fuse",
|
||||
Self::MissingLibfuse2 => "No libfuse2",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn badge_class(&self) -> &'static str {
|
||||
match self {
|
||||
Self::FullyFunctional => "success",
|
||||
Self::Fuse3Only => "warning",
|
||||
Self::NoFusermount | Self::NoDevFuse | Self::MissingLibfuse2 => "error",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_functional(&self) -> bool {
|
||||
matches!(self, Self::FullyFunctional)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FuseSystemInfo {
|
||||
pub status: FuseStatus,
|
||||
pub has_libfuse2: bool,
|
||||
pub has_libfuse3: bool,
|
||||
pub has_fusermount: bool,
|
||||
pub fusermount_path: Option<String>,
|
||||
pub has_dev_fuse: bool,
|
||||
pub install_hint: Option<String>,
|
||||
}
|
||||
|
||||
/// Detect the system FUSE status by checking for libraries, binaries, and device nodes.
|
||||
pub fn detect_system_fuse() -> FuseSystemInfo {
|
||||
let has_libfuse2 = check_library("libfuse.so.2");
|
||||
let has_libfuse3 = check_library("libfuse3.so.3");
|
||||
let fusermount_path = find_fusermount();
|
||||
let has_fusermount = fusermount_path.is_some();
|
||||
let has_dev_fuse = Path::new("/dev/fuse").exists();
|
||||
|
||||
let status = if has_libfuse2 && has_fusermount && has_dev_fuse {
|
||||
FuseStatus::FullyFunctional
|
||||
} else if !has_dev_fuse {
|
||||
FuseStatus::NoDevFuse
|
||||
} else if !has_fusermount {
|
||||
FuseStatus::NoFusermount
|
||||
} else if has_libfuse3 && !has_libfuse2 {
|
||||
FuseStatus::Fuse3Only
|
||||
} else {
|
||||
FuseStatus::MissingLibfuse2
|
||||
};
|
||||
|
||||
let install_hint = if status.is_functional() {
|
||||
None
|
||||
} else {
|
||||
Some(get_install_hint())
|
||||
};
|
||||
|
||||
FuseSystemInfo {
|
||||
status,
|
||||
has_libfuse2,
|
||||
has_libfuse3,
|
||||
has_fusermount,
|
||||
fusermount_path,
|
||||
has_dev_fuse,
|
||||
install_hint,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-AppImage FUSE launch status
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AppImageFuseStatus {
|
||||
/// Will mount natively via FUSE
|
||||
NativeFuse,
|
||||
/// Uses new type2-runtime with static FUSE
|
||||
StaticRuntime,
|
||||
/// Will use extract-and-run fallback (slower startup)
|
||||
ExtractAndRun,
|
||||
/// Cannot launch at all
|
||||
CannotLaunch,
|
||||
}
|
||||
|
||||
impl AppImageFuseStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NativeFuse => "native_fuse",
|
||||
Self::StaticRuntime => "static_runtime",
|
||||
Self::ExtractAndRun => "extract_and_run",
|
||||
Self::CannotLaunch => "cannot_launch",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NativeFuse => "Native FUSE",
|
||||
Self::StaticRuntime => "Static runtime",
|
||||
Self::ExtractAndRun => "Extract & Run",
|
||||
Self::CannotLaunch => "Cannot launch",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn badge_class(&self) -> &'static str {
|
||||
match self {
|
||||
Self::NativeFuse | Self::StaticRuntime => "success",
|
||||
Self::ExtractAndRun => "warning",
|
||||
Self::CannotLaunch => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine launch status for a specific AppImage given system FUSE state.
|
||||
pub fn determine_app_fuse_status(
|
||||
system: &FuseSystemInfo,
|
||||
appimage_path: &Path,
|
||||
) -> AppImageFuseStatus {
|
||||
// Check if the AppImage uses the new static runtime
|
||||
if has_static_runtime(appimage_path) {
|
||||
return AppImageFuseStatus::StaticRuntime;
|
||||
}
|
||||
|
||||
if system.status.is_functional() {
|
||||
return AppImageFuseStatus::NativeFuse;
|
||||
}
|
||||
|
||||
// FUSE not fully functional - check if extract-and-run works
|
||||
if supports_extract_and_run(appimage_path) {
|
||||
AppImageFuseStatus::ExtractAndRun
|
||||
} else {
|
||||
AppImageFuseStatus::CannotLaunch
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if the AppImage uses the new type2-runtime with statically linked FUSE.
|
||||
/// The new runtime embeds FUSE support and doesn't need system libfuse.
|
||||
fn has_static_runtime(appimage_path: &Path) -> bool {
|
||||
// The new type2-runtime responds to --appimage-version with a version string
|
||||
// containing "type2-runtime" or a recent date
|
||||
let output = Command::new(appimage_path)
|
||||
.arg("--appimage-version")
|
||||
.env("APPIMAGE_EXTRACT_AND_RUN", "1")
|
||||
.output();
|
||||
|
||||
if let Ok(output) = output {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_lowercase();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
// New runtime identifies itself
|
||||
return combined.contains("type2-runtime")
|
||||
|| combined.contains("static")
|
||||
|| combined.contains("libfuse3");
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if --appimage-extract-and-run is supported.
|
||||
fn supports_extract_and_run(appimage_path: &Path) -> bool {
|
||||
// Virtually all Type 2 AppImages support this flag
|
||||
// We check by looking at the appimage type (offset 8 in the file)
|
||||
if let Ok(data) = std::fs::read(appimage_path) {
|
||||
if data.len() > 11 {
|
||||
// Check for AppImage Type 2 magic at offset 8
|
||||
return data[8] == 0x41 && data[9] == 0x49 && data[10] == 0x02;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if a shared library is available on the system via ldconfig.
|
||||
fn check_library(soname: &str) -> bool {
|
||||
let output = Command::new("ldconfig")
|
||||
.arg("-p")
|
||||
.output();
|
||||
|
||||
if let Ok(output) = output {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
return stdout.contains(soname);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Find fusermount or fusermount3 binary.
|
||||
fn find_fusermount() -> Option<String> {
|
||||
for name in &["fusermount", "fusermount3"] {
|
||||
let output = Command::new("which")
|
||||
.arg(name)
|
||||
.output();
|
||||
if let Ok(output) = output {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Detect distro and return the appropriate libfuse2 install command.
|
||||
fn get_install_hint() -> String {
|
||||
if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
|
||||
let id = extract_os_field(&content, "ID");
|
||||
let version_id = extract_os_field(&content, "VERSION_ID");
|
||||
let id_like = extract_os_field(&content, "ID_LIKE");
|
||||
|
||||
return match id.as_deref() {
|
||||
Some("ubuntu") => {
|
||||
let ver: f64 = version_id
|
||||
.as_deref()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0.0);
|
||||
if ver >= 24.04 {
|
||||
"sudo apt install libfuse2t64".to_string()
|
||||
} else {
|
||||
"sudo apt install libfuse2".to_string()
|
||||
}
|
||||
}
|
||||
Some("debian") => "sudo apt install libfuse2".to_string(),
|
||||
Some("fedora") => "sudo dnf install fuse-libs".to_string(),
|
||||
Some("arch") | Some("manjaro") | Some("endeavouros") => {
|
||||
"sudo pacman -S fuse2".to_string()
|
||||
}
|
||||
Some("opensuse-tumbleweed") | Some("opensuse-leap") => {
|
||||
"sudo zypper install libfuse2".to_string()
|
||||
}
|
||||
_ => {
|
||||
// Check ID_LIKE for derivatives
|
||||
if let Some(like) = id_like.as_deref() {
|
||||
if like.contains("ubuntu") || like.contains("debian") {
|
||||
return "sudo apt install libfuse2".to_string();
|
||||
}
|
||||
if like.contains("fedora") {
|
||||
return "sudo dnf install fuse-libs".to_string();
|
||||
}
|
||||
if like.contains("arch") {
|
||||
return "sudo pacman -S fuse2".to_string();
|
||||
}
|
||||
if like.contains("suse") {
|
||||
return "sudo zypper install libfuse2".to_string();
|
||||
}
|
||||
}
|
||||
"Install libfuse2 using your distribution's package manager".to_string()
|
||||
}
|
||||
};
|
||||
}
|
||||
"Install libfuse2 using your distribution's package manager".to_string()
|
||||
}
|
||||
|
||||
fn extract_os_field(content: &str, key: &str) -> Option<String> {
|
||||
for line in content.lines() {
|
||||
if let Some(rest) = line.strip_prefix(&format!("{}=", key)) {
|
||||
return Some(rest.trim_matches('"').to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Check if AppImageLauncher is installed (known conflicts with new runtime).
|
||||
pub fn detect_appimagelauncher() -> Option<String> {
|
||||
let output = Command::new("dpkg")
|
||||
.args(["-s", "appimagelauncher"])
|
||||
.output();
|
||||
|
||||
if let Ok(output) = output {
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
if let Some(ver) = line.strip_prefix("Version: ") {
|
||||
return Some(ver.trim().to_string());
|
||||
}
|
||||
}
|
||||
return Some("unknown".to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_fuse_status_roundtrip() {
|
||||
let statuses = [
|
||||
FuseStatus::FullyFunctional,
|
||||
FuseStatus::Fuse3Only,
|
||||
FuseStatus::NoFusermount,
|
||||
FuseStatus::NoDevFuse,
|
||||
FuseStatus::MissingLibfuse2,
|
||||
];
|
||||
for status in &statuses {
|
||||
assert_eq!(&FuseStatus::from_str(status.as_str()), status);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_os_field() {
|
||||
let content = r#"NAME="Ubuntu"
|
||||
VERSION_ID="24.04"
|
||||
ID=ubuntu
|
||||
ID_LIKE=debian
|
||||
"#;
|
||||
assert_eq!(extract_os_field(content, "ID"), Some("ubuntu".to_string()));
|
||||
assert_eq!(extract_os_field(content, "VERSION_ID"), Some("24.04".to_string()));
|
||||
assert_eq!(extract_os_field(content, "ID_LIKE"), Some("debian".to_string()));
|
||||
assert_eq!(extract_os_field(content, "MISSING"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuse_status_badges() {
|
||||
assert_eq!(FuseStatus::FullyFunctional.badge_class(), "success");
|
||||
assert_eq!(FuseStatus::Fuse3Only.badge_class(), "warning");
|
||||
assert_eq!(FuseStatus::MissingLibfuse2.badge_class(), "error");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user