diff --git a/src/cli.rs b/src/cli.rs index 1ff6da3..4fa695a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -621,6 +621,14 @@ fn cmd_launch(db: &Database, path: &str) -> ExitCode { ); ExitCode::SUCCESS } + launcher::LaunchResult::Crashed { stderr, exit_code, .. } => { + eprintln!( + "App crashed immediately (exit code: {})\n{}", + exit_code.map(|c| c.to_string()).unwrap_or_else(|| "unknown".into()), + stderr, + ); + ExitCode::FAILURE + } launcher::LaunchResult::Failed(msg) => { eprintln!("Error: {}", msg); ExitCode::FAILURE @@ -633,6 +641,14 @@ fn cmd_launch(db: &Database, path: &str) -> ExitCode { println!("Launched {} ({})", path, method.as_str()); ExitCode::SUCCESS } + launcher::LaunchResult::Crashed { stderr, exit_code, .. } => { + eprintln!( + "App crashed immediately (exit code: {})\n{}", + exit_code.map(|c| c.to_string()).unwrap_or_else(|| "unknown".into()), + stderr, + ); + ExitCode::FAILURE + } launcher::LaunchResult::Failed(msg) => { eprintln!("Error: {}", msg); ExitCode::FAILURE diff --git a/src/core/analysis.rs b/src/core/analysis.rs index ed3bb1d..861a0c8 100644 --- a/src/core/analysis.rs +++ b/src/core/analysis.rs @@ -15,6 +15,7 @@ const MAX_CONCURRENT_ANALYSES: usize = 2; static RUNNING_ANALYSES: AtomicUsize = AtomicUsize::new(0); /// Returns the number of currently running background analyses. +#[allow(dead_code)] pub fn running_count() -> usize { RUNNING_ANALYSES.load(Ordering::Relaxed) } diff --git a/src/core/appstream.rs b/src/core/appstream.rs index d15fdc4..e466d76 100644 --- a/src/core/appstream.rs +++ b/src/core/appstream.rs @@ -405,6 +405,7 @@ fn summarize_content_rating(attrs: &[(String, String)]) -> String { // AppStream catalog generation - writes catalog XML for GNOME Software/Discover // --------------------------------------------------------------------------- +#[allow(dead_code)] /// Generate an AppStream catalog XML from the Driftwood database. /// This allows GNOME Software / KDE Discover to see locally managed AppImages. pub fn generate_catalog(db: &Database) -> Result { @@ -462,6 +463,7 @@ pub fn generate_catalog(db: &Database) -> Result { Ok(xml) } +#[allow(dead_code)] /// Install the AppStream catalog to the local swcatalog directory. /// GNOME Software reads from `~/.local/share/swcatalog/xml/`. pub fn install_catalog(db: &Database) -> Result { @@ -482,6 +484,7 @@ pub fn install_catalog(db: &Database) -> Result { Ok(catalog_path) } +#[allow(dead_code)] /// Remove the AppStream catalog from the local swcatalog directory. pub fn uninstall_catalog() -> Result<(), AppStreamError> { let catalog_path = dirs::data_dir() @@ -498,6 +501,7 @@ pub fn uninstall_catalog() -> Result<(), AppStreamError> { Ok(()) } +#[allow(dead_code)] /// Check if the AppStream catalog is currently installed. pub fn is_catalog_installed() -> bool { let catalog_path = dirs::data_dir() @@ -511,6 +515,7 @@ pub fn is_catalog_installed() -> bool { // --- Utility functions --- +#[allow(dead_code)] fn make_component_id(name: &str) -> String { name.chars() .map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c.to_ascii_lowercase() } else { '_' }) @@ -519,6 +524,7 @@ fn make_component_id(name: &str) -> String { .to_string() } +#[allow(dead_code)] fn xml_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") @@ -530,6 +536,7 @@ fn xml_escape(s: &str) -> String { // --- Error types --- #[derive(Debug)] +#[allow(dead_code)] pub enum AppStreamError { Database(String), Io(String), diff --git a/src/core/backup.rs b/src/core/backup.rs index 3f8a787..7176c5d 100644 --- a/src/core/backup.rs +++ b/src/core/backup.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; diff --git a/src/core/database.rs b/src/core/database.rs index 30eb169..9eb2327 100644 --- a/src/core/database.rs +++ b/src/core/database.rs @@ -185,6 +185,7 @@ pub struct ConfigBackupRecord { } #[derive(Debug, Clone)] +#[allow(dead_code)] pub struct CatalogSourceRecord { pub id: i64, pub name: String, @@ -196,6 +197,7 @@ pub struct CatalogSourceRecord { } #[derive(Debug, Clone)] +#[allow(dead_code)] pub struct CatalogAppRecord { pub id: i64, pub source_id: i64, diff --git a/src/core/inspector.rs b/src/core/inspector.rs index 4df84f9..ed2244a 100644 --- a/src/core/inspector.rs +++ b/src/core/inspector.rs @@ -38,6 +38,7 @@ pub struct AppImageMetadata { pub app_version: Option, pub description: Option, pub developer: Option, + #[allow(dead_code)] pub icon_name: Option, pub categories: Vec, pub desktop_entry_content: String, diff --git a/src/core/launcher.rs b/src/core/launcher.rs index c6ef9b9..5d93735 100644 --- a/src/core/launcher.rs +++ b/src/core/launcher.rs @@ -42,6 +42,7 @@ pub enum LaunchMethod { /// Extract-and-run fallback (APPIMAGE_EXTRACT_AND_RUN=1) ExtractAndRun, /// Via firejail sandbox + #[allow(dead_code)] Sandboxed, } @@ -58,11 +59,18 @@ impl LaunchMethod { /// Result of a launch attempt. #[derive(Debug)] pub enum LaunchResult { - /// Successfully spawned the process. + /// Successfully spawned the process and it's still running. Started { child: Child, method: LaunchMethod, }, + /// Process spawned but crashed immediately (within ~1 second). + Crashed { + exit_code: Option, + stderr: String, + #[allow(dead_code)] + method: LaunchMethod, + }, /// Failed to launch. Failed(String), } @@ -155,20 +163,56 @@ fn execute_appimage( cmd.env(key, value); } - // Detach from our process group so the app runs independently + // Capture stderr to detect crash messages, stdin detached cmd.stdin(Stdio::null()); + cmd.stderr(Stdio::piped()); match cmd.spawn() { - Ok(child) => LaunchResult::Started { - child, - method: method.clone(), - }, - Err(e) => LaunchResult::Failed(format!("Failed to spawn process: {}", e)), + Ok(mut child) => { + // Brief wait to detect immediate crashes (e.g. missing Qt plugins) + std::thread::sleep(std::time::Duration::from_millis(1500)); + match child.try_wait() { + Ok(Some(status)) => { + // Process already exited - it crashed + let stderr = child + .stderr + .take() + .and_then(|mut err| { + let mut buf = String::new(); + use std::io::Read; + err.read_to_string(&mut buf).ok()?; + Some(buf) + }) + .unwrap_or_default(); + LaunchResult::Crashed { + exit_code: status.code(), + stderr, + method: method.clone(), + } + } + Ok(None) => { + // Still running - success + LaunchResult::Started { + child, + method: method.clone(), + } + } + Err(_) => { + // Can't check status, assume it's running + LaunchResult::Started { + child, + method: method.clone(), + } + } + } + } + Err(e) => LaunchResult::Failed(format!("Failed to start: {}", e)), } } /// Parse a launch_args string from the database into a Vec of individual arguments. /// Splits on whitespace; returns an empty Vec if the input is None or empty. +#[allow(dead_code)] pub fn parse_launch_args(args: Option<&str>) -> Vec { args.map(|s| s.split_whitespace().map(String::from).collect()) .unwrap_or_default() diff --git a/src/core/notification.rs b/src/core/notification.rs index 66407f8..5c5a3c7 100644 --- a/src/core/notification.rs +++ b/src/core/notification.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use super::database::Database; use super::security; diff --git a/src/core/report.rs b/src/core/report.rs index 49157c6..c48171b 100644 --- a/src/core/report.rs +++ b/src/core/report.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use super::database::{CveSummary, Database}; use crate::config::VERSION; diff --git a/src/core/security.rs b/src/core/security.rs index 876d758..b01a5aa 100644 --- a/src/core/security.rs +++ b/src/core/security.rs @@ -28,6 +28,7 @@ pub struct CveMatch { /// Result of a security scan for a single AppImage. #[derive(Debug, Clone)] pub struct SecurityScanResult { + #[allow(dead_code)] pub appimage_id: i64, pub libraries: Vec, pub cve_matches: Vec<(BundledLibrary, Vec)>, diff --git a/src/core/updater.rs b/src/core/updater.rs index b86b30d..dae82e4 100644 --- a/src/core/updater.rs +++ b/src/core/updater.rs @@ -390,6 +390,7 @@ fn extract_update_info_runtime(path: &Path) -> Option { // -- GitHub/GitLab API types for JSON deserialization -- #[derive(Deserialize)] +#[allow(dead_code)] struct GhRelease { tag_name: String, name: Option, @@ -405,6 +406,7 @@ struct GhAsset { } #[derive(Deserialize)] +#[allow(dead_code)] struct GlRelease { tag_name: String, name: Option, diff --git a/src/core/watcher.rs b/src/core/watcher.rs index 0243e6d..366b7bd 100644 --- a/src/core/watcher.rs +++ b/src/core/watcher.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use std::path::PathBuf; use std::sync::mpsc; use std::time::Duration; diff --git a/src/core/wayland.rs b/src/core/wayland.rs index 35acb3b..b2a4f3d 100644 --- a/src/core/wayland.rs +++ b/src/core/wayland.rs @@ -307,9 +307,11 @@ pub fn detect_desktop_environment() -> String { /// Result of analyzing a running process for Wayland usage. #[derive(Debug, Clone)] pub struct RuntimeAnalysis { + #[allow(dead_code)] pub pid: u32, pub has_wayland_socket: bool, pub has_x11_connection: bool, + #[allow(dead_code)] pub env_vars: Vec<(String, String)>, } diff --git a/src/ui/detail_view.rs b/src/ui/detail_view.rs index 2ffcd58..2000caf 100644 --- a/src/ui/detail_view.rs +++ b/src/ui/detail_view.rs @@ -79,59 +79,67 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc) -> adw::Nav ]); let record_id = record.id; let path = record.path.clone(); + let app_name_launch = record.app_name.clone().unwrap_or_else(|| record.filename.clone()); let db_launch = db.clone(); - launch_button.connect_clicked(move |_| { - let appimage_path = std::path::Path::new(&path); - let result = launcher::launch_appimage( - &db_launch, - record_id, - appimage_path, - "gui_detail", - &[], - &[], - ); - match result { - launcher::LaunchResult::Started { child, method } => { - let pid = child.id(); - log::info!("Launched AppImage: {} (PID: {}, method: {})", path, pid, method.as_str()); + let toast_launch = toast_overlay.clone(); + launch_button.connect_clicked(move |btn| { + btn.set_sensitive(false); + let btn_ref = btn.clone(); + let path = path.clone(); + let app_name = app_name_launch.clone(); + let db_launch = db_launch.clone(); + let toast_ref = toast_launch.clone(); + glib::spawn_future_local(async move { + let path_bg = path.clone(); + let result = gio::spawn_blocking(move || { + let appimage_path = std::path::Path::new(&path_bg); + launcher::launch_appimage( + &Database::open().expect("DB open"), + record_id, + appimage_path, + "gui_detail", + &[], + &[], + ) + }).await; - let db_wayland = db_launch.clone(); - let path_clone = path.clone(); - glib::spawn_future_local(async move { - glib::timeout_future(std::time::Duration::from_secs(3)).await; + btn_ref.set_sensitive(true); + match result { + Ok(launcher::LaunchResult::Started { child, method }) => { + let pid = child.id(); + log::info!("Launched: {} (PID: {}, method: {})", path, pid, method.as_str()); - let analysis_result = gio::spawn_blocking(move || { - wayland::analyze_running_process(pid) - }).await; - - match analysis_result { - Ok(Ok(analysis)) => { - let status_label = analysis.status_label(); + let db_wayland = db_launch.clone(); + let path_clone = path.clone(); + glib::spawn_future_local(async move { + glib::timeout_future(std::time::Duration::from_secs(3)).await; + let analysis_result = gio::spawn_blocking(move || { + wayland::analyze_running_process(pid) + }).await; + if let Ok(Ok(analysis)) = analysis_result { let status_str = analysis.as_status_str(); - log::info!( - "Runtime Wayland analysis for {} (PID {}): {} (wayland_socket={}, x11={}, env_vars={})", - path_clone, analysis.pid, status_label, - analysis.has_wayland_socket, - analysis.has_x11_connection, - analysis.env_vars.len(), - ); - db_wayland.update_runtime_wayland_status( - record_id, status_str, - ).ok(); + log::info!("Runtime Wayland: {} -> {}", path_clone, analysis.status_label()); + db_wayland.update_runtime_wayland_status(record_id, status_str).ok(); } - Ok(Err(e)) => { - log::debug!("Runtime analysis failed for PID {}: {}", pid, e); - } - Err(_) => { - log::debug!("Runtime analysis task failed for PID {}", pid); - } - } - }); + }); + } + Ok(launcher::LaunchResult::Crashed { exit_code, stderr, .. }) => { + log::error!("App crashed on launch (exit {}): {}", exit_code.unwrap_or(-1), stderr); + widgets::show_crash_dialog(&btn_ref, &app_name, exit_code, &stderr); + } + Ok(launcher::LaunchResult::Failed(msg)) => { + log::error!("Failed to launch: {}", msg); + let toast = adw::Toast::builder() + .title(&format!("Could not launch: {}", msg)) + .timeout(5) + .build(); + toast_ref.add_toast(toast); + } + Err(_) => { + log::error!("Launch task panicked"); + } } - launcher::LaunchResult::Failed(msg) => { - log::error!("Failed to launch: {}", msg); - } - } + }); }); header.pack_end(&launch_button); @@ -1025,7 +1033,7 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc, toast_overlay: & match result { Ok(analysis) => { let toolkit_label = analysis.toolkit.label(); - let lib_count = analysis.libraries_found.len(); + let _lib_count = analysis.libraries_found.len(); row_clone.set_subtitle(&format!( "Built with: {}", toolkit_label, @@ -1062,13 +1070,10 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc, toast_overlay: & compat_group.add(&runtime_row); } - // FUSE status + // FUSE status - always use live system detection (the stored fuse_status + // is per-app AppImageFuseStatus, not the system-level FuseStatus) let fuse_system = fuse::detect_system_fuse(); - let fuse_status = record - .fuse_status - .as_deref() - .map(FuseStatus::from_str) - .unwrap_or(fuse_system.status.clone()); + let fuse_status = fuse_system.status.clone(); let fuse_row = adw::ActionRow::builder() .title("App mounting") @@ -1824,3 +1829,4 @@ fn fetch_favicon_async(url: &str, image: >k::Image) { } }); } + diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index 8a84cef..f89ef61 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -1,4 +1,4 @@ -use gtk::prelude::*; +use adw::prelude::*; use std::sync::OnceLock; /// Ensures the shared letter-icon CSS provider is registered on the default @@ -194,6 +194,141 @@ pub fn copy_button(text_to_copy: &str, toast_overlay: Option<&adw::ToastOverlay> btn } +/// Show a detailed crash dialog when an AppImage fails to start. +/// Includes a plain-text explanation, the full error output in a copyable text view, +/// and a button to copy the full error to clipboard. +pub fn show_crash_dialog( + parent: &impl gtk::prelude::IsA, + app_name: &str, + exit_code: Option, + stderr: &str, +) { + let explanation = crash_explanation(stderr); + + let exit_str = exit_code + .map(|c| c.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + + let body = format!("{}\n\nExit code: {}", explanation, exit_str); + + let dialog = adw::AlertDialog::builder() + .heading(&format!("{} failed to start", app_name)) + .body(&body) + .close_response("close") + .default_response("close") + .build(); + + // Build the full text that gets copied + let full_error = format!( + "App: {}\nExit code: {}\n\n{}\n\nError output:\n{}", + app_name, + exit_str, + explanation, + stderr.trim(), + ); + + // Extra content: scrollable text view with full stderr + copy button + if !stderr.trim().is_empty() { + let vbox = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .build(); + + let heading = gtk::Label::builder() + .label("Error output:") + .xalign(0.0) + .build(); + heading.add_css_class("heading"); + vbox.append(&heading); + + let text_view = gtk::TextView::builder() + .editable(false) + .cursor_visible(false) + .monospace(true) + .wrap_mode(gtk::WrapMode::WordChar) + .top_margin(8) + .bottom_margin(8) + .left_margin(8) + .right_margin(8) + .build(); + text_view.buffer().set_text(stderr.trim()); + text_view.add_css_class("card"); + + let scrolled = gtk::ScrolledWindow::builder() + .child(&text_view) + .min_content_height(120) + .max_content_height(300) + .build(); + vbox.append(&scrolled); + + let copy_btn = gtk::Button::builder() + .label("Copy to clipboard") + .halign(gtk::Align::Start) + .build(); + copy_btn.add_css_class("pill"); + let full_error_copy = full_error.clone(); + copy_btn.connect_clicked(move |btn| { + let clipboard = btn.display().clipboard(); + clipboard.set_text(&full_error_copy); + btn.set_label("Copied!"); + btn.set_sensitive(false); + }); + vbox.append(©_btn); + + dialog.set_extra_child(Some(&vbox)); + } + + dialog.add_response("close", "Close"); + dialog.present(Some(parent)); +} + +/// Generate a plain-text explanation of why an app crashed based on stderr patterns. +fn crash_explanation(stderr: &str) -> String { + if stderr.contains("Could not find the Qt platform plugin") || stderr.contains("qt.qpa.plugin") { + return "The app couldn't find a required display plugin. This usually means \ + it needs a Qt library that isn't bundled inside the AppImage or \ + available on your system.".to_string(); + } + if stderr.contains("cannot open shared object file") { + if let Some(pos) = stderr.find("cannot open shared object file") { + let before = &stderr[..pos]; + if let Some(start) = before.rfind(": ") { + let lib = before[start + 2..].trim(); + if !lib.is_empty() { + return format!( + "The app needs a system library ({}) that isn't installed. \ + You may be able to fix this by installing the missing package.", + lib, + ); + } + } + } + return "The app needs a system library that isn't installed on your system.".to_string(); + } + if stderr.contains("Segmentation fault") || stderr.contains("SIGSEGV") { + return "The app crashed due to a memory error. This is usually a bug \ + in the app itself, not something you can fix.".to_string(); + } + if stderr.contains("Permission denied") { + return "The app was blocked from accessing something it needs. \ + Check that the AppImage file has the right permissions.".to_string(); + } + if stderr.contains("fatal IO error") || stderr.contains("display connection") { + return "The app lost its connection to the display server. This can happen \ + with apps that don't fully support your display system.".to_string(); + } + if stderr.contains("FATAL:") || stderr.contains("Aborted") { + return "The app hit a fatal error and had to stop. The error details \ + below may help identify the cause.".to_string(); + } + if stderr.contains("Failed to initialize") { + return "The app couldn't set itself up properly. It may need additional \ + system components to run.".to_string(); + } + "The app exited immediately after starting. The error details below \ + may help identify the cause.".to_string() +} + /// Create a screen-reader live region announcement. /// Inserts a hidden label with AccessibleRole::Alert into the given container, /// which causes AT-SPI to announce the text to screen readers. diff --git a/src/window.rs b/src/window.rs index 0707147..987d4ed 100644 --- a/src/window.rs +++ b/src/window.rs @@ -24,6 +24,7 @@ use crate::ui::library_view::{LibraryState, LibraryView}; use crate::ui::preferences; use crate::ui::security_report; use crate::ui::update_dialog; +use crate::ui::widgets; mod imp { use super::*; @@ -588,18 +589,46 @@ impl DriftwoodWindow { launch_action.connect_activate(move |_, param| { let Some(window) = window_weak.upgrade() else { return }; let Some(record_id) = param.and_then(|p| p.get::()) else { return }; - let db = window.database().clone(); - if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { - let appimage_path = std::path::Path::new(&record.path); - match launcher::launch_appimage(&db, record_id, appimage_path, "gui_context", &[], &[]) { - launcher::LaunchResult::Started { child, method } => { - log::info!("Context menu launched: {} (PID: {}, method: {})", record.path, child.id(), method.as_str()); + let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); + let window_ref = window.clone(); + let (path_str, app_name) = { + let db = window.database(); + match db.get_appimage_by_id(record_id) { + Ok(Some(r)) => { + let name = r.app_name.clone().unwrap_or_else(|| r.filename.clone()); + (r.path.clone(), name) } - launcher::LaunchResult::Failed(msg) => { + _ => return, + } + }; + glib::spawn_future_local(async move { + let path_bg = path_str.clone(); + let result = gio::spawn_blocking(move || { + let bg_db = crate::core::database::Database::open().expect("DB open"); + let appimage_path = std::path::Path::new(&path_bg); + launcher::launch_appimage(&bg_db, record_id, appimage_path, "gui_context", &[], &[]) + }).await; + match result { + Ok(launcher::LaunchResult::Started { child, method }) => { + log::info!("Launched: {} (PID: {}, method: {})", path_str, child.id(), method.as_str()); + } + Ok(launcher::LaunchResult::Crashed { exit_code, stderr, .. }) => { + log::error!("App crashed (exit {}): {}", exit_code.unwrap_or(-1), stderr); + widgets::show_crash_dialog(&window_ref, &app_name, exit_code, &stderr); + } + Ok(launcher::LaunchResult::Failed(msg)) => { log::error!("Failed to launch: {}", msg); + let toast = adw::Toast::builder() + .title(&format!("Could not launch: {}", msg)) + .timeout(5) + .build(); + toast_overlay.add_toast(toast); + } + Err(_) => { + log::error!("Launch task panicked"); } } - } + }); }); } self.add_action(&launch_action);