Files
driftwood/src/core/launcher.rs

309 lines
9.0 KiB
Rust

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};
/// Sandbox mode for running AppImages.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SandboxMode {
None,
Firejail,
}
impl SandboxMode {
pub fn from_str(s: &str) -> Self {
match s {
"firejail" => Self::Firejail,
_ => Self::None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::None => "none",
Self::Firejail => "firejail",
}
}
pub fn display_label(&self) -> &'static str {
match self {
Self::None => "None",
Self::Firejail => "Firejail",
}
}
}
/// 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 {
/// Process spawned and survived the startup crash-check window.
Started {
pid: u32,
method: LaunchMethod,
},
/// Process spawned but exited during the startup crash-check window.
Crashed {
exit_code: Option<i32>,
stderr: String,
method: LaunchMethod,
},
/// Failed to launch (binary not found, permission denied, etc.).
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(),
);
}
};
// Override with sandboxed launch if the user enabled firejail for this app
let method = if has_firejail() {
let sandbox = db
.get_appimage_by_id(record_id)
.ok()
.flatten()
.and_then(|r| r.sandbox_mode);
if sandbox.as_deref() == Some("firejail") {
LaunchMethod::Sandboxed
} else {
method
}
} else {
method
};
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 stdin, pipe stderr so we can capture crash messages
cmd.stdin(Stdio::null());
cmd.stderr(Stdio::piped());
match cmd.spawn() {
Ok(mut child) => {
let pid = child.id();
// Monitor for early crash (2s window). This blocks the current
// thread, so callers should run this inside gio::spawn_blocking.
match check_early_crash(&mut child, std::time::Duration::from_secs(2)) {
Some((exit_code, stderr)) => {
LaunchResult::Crashed {
exit_code,
stderr,
method: method.clone(),
}
}
None => {
LaunchResult::Started {
pid,
method: method.clone(),
}
}
}
}
Err(e) => LaunchResult::Failed(format!("Failed to start: {}", e)),
}
}
/// Check if a recently-launched child process crashed during startup.
/// Waits up to `timeout` for the process to exit. If it exits within that window,
/// reads stderr and returns a Crashed result. If still running, drops the stderr
/// pipe (to prevent pipe buffer deadlock) and returns None.
///
/// Call this from a background thread after spawning the process.
pub fn check_early_crash(
child: &mut Child,
timeout: std::time::Duration,
) -> Option<(Option<i32>, String)> {
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(status)) => {
// Process exited - read stderr for crash details
let stderr_text = child.stderr.take().map(|mut pipe| {
let mut buf = String::new();
use std::io::Read;
let mut limited = (&mut pipe).take(64 * 1024);
let _ = limited.read_to_string(&mut buf);
buf
}).unwrap_or_default();
return Some((status.code(), stderr_text));
}
Ok(None) => {
if start.elapsed() >= timeout {
// Still running - drop stderr pipe to avoid deadlock
drop(child.stderr.take());
return None;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(_) => return None,
}
}
}
/// Parse launch arguments with basic quote support.
/// Splits on whitespace, respecting double-quoted strings.
/// Returns an empty Vec if the input is None or empty.
pub fn parse_launch_args(args: Option<&str>) -> Vec<String> {
let Some(s) = args else {
return Vec::new();
};
let s = s.trim();
if s.is_empty() {
return Vec::new();
}
let mut result = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
for c in s.chars() {
match c {
'"' => in_quotes = !in_quotes,
' ' | '\t' if !in_quotes => {
if !current.is_empty() {
result.push(std::mem::take(&mut current));
}
}
_ => current.push(c),
}
}
if !current.is_empty() {
result.push(current);
}
result
}
/// 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,
}
}