From 28ef3ad924fc38bc707d3e2592552a216d203e8b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 11 Oct 2026 02:21:10 -0400 Subject: [PATCH] Linux: prefer the launcher's own format (Flatpak, AppImage) and run host apps from the Flatpak sandbox (#89) * linux: prefer Flatpak if available and support Flatpak sandbox execution * linux: match default package format to launcher format (Flatpak for Flatpak, AppImage for AppImage) * ui: default package preference selection to launcher format (Flatpak for Flatpak, AppImage for AppImage) --- crates/native/src/detect.rs | 4 +- crates/native/src/install.rs | 86 ++++++++++++++++++++++++++++---- crates/native/src/process.rs | 59 +++++++++++++++++++++- crates/native/src/self_update.rs | 2 +- crates/ui-egui/src/dialogs.rs | 21 ++++++-- crates/ui-egui/src/settings.rs | 33 +++++++++++- 6 files changed, 186 insertions(+), 19 deletions(-) diff --git a/crates/native/src/detect.rs b/crates/native/src/detect.rs index 2ef6089..753d77b 100644 --- a/crates/native/src/detect.rs +++ b/crates/native/src/detect.rs @@ -217,11 +217,11 @@ pub mod linux { if let Some(list) = capture("pacman", &["-Q"], t) { add(InstallSource::Pacman, packages::parse_pacman(&list)); } - if let Some(list) = capture("flatpak", &["list", "--app", "--columns=application,version"], t) { + if let Some(list) = crate::process::capture_flatpak(&["list", "--app", "--columns=application,version"], t) { let mut flatpaks = packages::parse_flatpak_list(&list); // Bundles built from a release tarball often list no version: ask Flatpak directly. for (id, version) in flatpaks.iter_mut().filter(|(id, v)| v.is_empty() && id.starts_with("ai.storyteller.")) { - if let Some(v) = capture("flatpak", &["info", id], t).as_deref().and_then(packages::parse_flatpak_info) { + if let Some(v) = crate::process::capture_flatpak(&["info", id], t).as_deref().and_then(packages::parse_flatpak_info) { *version = v; } } diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs index 8af0b8f..362c93a 100644 --- a/crates/native/src/install.rs +++ b/crates/native/src/install.rs @@ -245,7 +245,7 @@ pub fn launch(app: &CraftApp, installed: &Installed) -> Result<(), String> { let path = Path::new(&installed.location); match installed.source { InstallSource::Flatpak => { - let mut cmd = process::command("flatpak"); + let mut cmd = process::flatpak_command(); cmd.args(["run", &app.bundle_id()]); process::spawn_detached(cmd) } @@ -413,21 +413,50 @@ pub(crate) mod linux { } } - /// What this system can install: its own package format first (when polkit can ask for - /// permission), then Flatpak, then AppImage, which works everywhere. + /// What this system can install, ordered with the best default first. + /// + /// The default package format aligns with how the user installed the launcher itself: + /// - Running as a Flatpak: prefers Flatpak (per-user, desktop integration). + /// - Running as an AppImage: prefers AppImage. + /// - Otherwise (system install): prefers native distro packages (`.deb` / `.rpm`), + /// then Flatpak if available, then AppImage. pub fn available_kinds() -> Vec { - let mut kinds = Vec::new(); + let flatpak = process::is_flatpak_available(); + let is_flatpak = process::in_sandbox(); + let is_appimage = std::env::var_os("APPIMAGE").is_some(); let root = which("pkexec").is_some(); - if root && which("apt-get").is_some() && which("dpkg").is_some() { + let can_deb = root && which("apt-get").is_some() && which("dpkg").is_some(); + let can_rpm = root && which("rpm").is_some() && which("dpkg").is_none(); + order_available_kinds(is_flatpak, is_appimage, flatpak, can_deb, can_rpm) + } + + pub(crate) fn order_available_kinds( + is_flatpak: bool, + is_appimage: bool, + flatpak_available: bool, + can_deb: bool, + can_rpm: bool, + ) -> Vec { + let mut kinds = Vec::new(); + if is_flatpak && flatpak_available { + kinds.push(PackageKind::Flatpak); + } else if is_appimage { + kinds.push(PackageKind::AppImage); + } + + if can_deb { kinds.push(PackageKind::Deb); } - if root && which("rpm").is_some() && which("dpkg").is_none() { + if can_rpm { kinds.push(PackageKind::Rpm); } - if which("flatpak").is_some() { + + if flatpak_available && !kinds.contains(&PackageKind::Flatpak) { kinds.push(PackageKind::Flatpak); } - kinds.push(PackageKind::AppImage); + if !kinds.contains(&PackageKind::AppImage) { + kinds.push(PackageKind::AppImage); + } kinds } @@ -443,7 +472,7 @@ pub(crate) mod linux { } match kind { PackageKind::Flatpak => { - let status = process::command("flatpak") + let status = process::flatpak_command() .args(["install", "--user", "-y", "--noninteractive", "--bundle"]) .arg(f) .status() @@ -473,10 +502,17 @@ pub(crate) mod linux { InstallSource::Pacman => remove(&["pacman", "-R", "--noconfirm"], installed.package.as_deref().unwrap_or(app.id)), InstallSource::Flatpak => { let id = app.bundle_id(); - let user = process::command("flatpak").args(["uninstall", "--user", "-y", "--noninteractive", &id]).status(); + let user = process::flatpak_command().args(["uninstall", "--user", "-y", "--noninteractive", &id]).status(); if user.is_ok_and(|s| s.success()) { return Ok(()); } + if process::in_sandbox() { + let system = process::flatpak_command().args(["uninstall", "--system", "-y", "--noninteractive", &id]).status(); + if system.is_ok_and(|s| s.success()) { + return Ok(()); + } + return Err(format!("couldn't uninstall {id}")); + } remove(&["flatpak", "uninstall", "--system", "-y", "--noninteractive"], &id) } other => Err(format!("{} isn't a system package", other.label())), @@ -994,4 +1030,34 @@ mod tests { assert_eq!(choose_kinds(Some(&inst(InstallSource::AppImage)), None, &[]), [PackageKind::AppImage]); let _ = inst; } + + #[cfg(target_os = "linux")] + #[test] + fn package_kinds_order_matches_launcher_format() { + use super::linux::order_available_kinds; + + // Flatpak launcher defaults to Flatpak if available + let kinds = order_available_kinds(true, false, true, true, false); + assert_eq!(kinds.first(), Some(&PackageKind::Flatpak)); + assert!(kinds.contains(&PackageKind::Deb)); + assert!(kinds.contains(&PackageKind::AppImage)); + + // AppImage launcher defaults to AppImage + let kinds = order_available_kinds(false, true, true, true, false); + assert_eq!(kinds.first(), Some(&PackageKind::AppImage)); + assert!(kinds.contains(&PackageKind::Deb)); + assert!(kinds.contains(&PackageKind::Flatpak)); + + // Base Linux launcher defaults to native package format (deb or rpm) if available + let kinds = order_available_kinds(false, false, true, true, false); + assert_eq!(kinds.first(), Some(&PackageKind::Deb)); + + let kinds = order_available_kinds(false, false, true, false, true); + assert_eq!(kinds.first(), Some(&PackageKind::Rpm)); + + // Base Linux launcher without root/distro packaging falls back to Flatpak then AppImage + let kinds = order_available_kinds(false, false, true, false, false); + assert_eq!(kinds.first(), Some(&PackageKind::Flatpak)); + assert_eq!(kinds.get(1), Some(&PackageKind::AppImage)); + } } diff --git a/crates/native/src/process.rs b/crates/native/src/process.rs index 9a31c7e..4db6955 100644 --- a/crates/native/src/process.rs +++ b/crates/native/src/process.rs @@ -77,9 +77,56 @@ pub fn spawn_detached(mut cmd: Command) -> Result<(), String> { Ok(()) } +/// Whether this process is running inside a Flatpak container sandbox. +pub fn in_sandbox() -> bool { + std::env::var_os("FLATPAK_ID").is_some() || Path::new("/.flatpak-info").exists() +} + +/// A command to run `flatpak`, delegating to the host via `flatpak-spawn --host` if inside a sandbox. +pub fn flatpak_command() -> Command { + if in_sandbox() { + let mut cmd = command("flatpak-spawn"); + cmd.args(["--host", "flatpak"]); + cmd + } else { + command("flatpak") + } +} + +/// Runs a flatpak command and captures its standard output. +pub fn capture_flatpak(args: &[&str], timeout: Duration) -> Option { + if in_sandbox() { + let mut spawn_args = vec!["--host", "flatpak"]; + spawn_args.extend_from_slice(args); + capture("flatpak-spawn", &spawn_args, timeout) + } else { + capture("flatpak", args, timeout) + } +} + +/// Whether Flatpak is available on the machine (or on the host when sandboxed). +pub fn is_flatpak_available() -> bool { + if in_sandbox() { + capture("flatpak-spawn", &["--host", "flatpak", "--version"], Duration::from_secs(3)).is_some() + } else { + which_on_path("flatpak").is_some() + } +} + +fn which_on_path(name: &str) -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path).map(|d| d.join(name)).find(|p| p.is_file()) +} + /// Starts an app binary from its own folder. pub fn launch_binary(exe: &Path, args: &[&str]) -> Result<(), String> { - let mut cmd = command(exe); + let mut cmd = if in_sandbox() { + let mut c = command("flatpak-spawn"); + c.arg("--host").arg(exe); + c + } else { + command(exe) + }; cmd.args(args); if let Some(dir) = exe.parent() { cmd.current_dir(dir); @@ -95,6 +142,10 @@ pub fn open(target: &std::ffi::OsStr) -> Result<(), String> { let mut c = command("explorer"); c.arg(target); return spawn_detached(c); + } else if in_sandbox() { + let mut c = command("flatpak-spawn"); + c.arg("--host").arg("xdg-open"); + c } else { command("xdg-open") }; @@ -130,7 +181,11 @@ pub fn is_running(path: &Path) -> bool { let Some(p) = path.to_str() else { return false }; let pattern = pgrep_pattern(p); // pgrep exits 0 when something matches; it never matches itself. - capture("pgrep", &["-f", "--", &pattern], Duration::from_secs(5)).is_some() + if in_sandbox() { + capture("flatpak-spawn", &["--host", "pgrep", "-f", "--", &pattern], Duration::from_secs(5)).is_some() + } else { + capture("pgrep", &["-f", "--", &pattern], Duration::from_secs(5)).is_some() + } } } diff --git a/crates/native/src/self_update.rs b/crates/native/src/self_update.rs index 08827e0..65a599b 100644 --- a/crates/native/src/self_update.rs +++ b/crates/native/src/self_update.rs @@ -127,7 +127,7 @@ fn msi_install_dir() -> Option { #[cfg(target_os = "linux")] fn detect(exe: &Path) -> Option { - if std::env::var_os("FLATPAK_ID").is_some() { + if process::in_sandbox() { return None; } if let Some(appimage) = std::env::var_os("APPIMAGE") { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f82ad4b..d8a94fe 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -227,11 +227,26 @@ impl LauncherUi { if self.caps.manage_apps && self.platform.is_some_and(|p| p.os == Os::Linux) { section(ui, tl!("Installing on Linux")); - let mut choice = self.settings.preferred_kinds.first().copied(); + let is_flatpak = crate::settings::is_flatpak_sandbox(); + let is_appimage = crate::settings::is_appimage(); + let default_kind = if is_flatpak { + Some(PackageKind::Flatpak) + } else if is_appimage { + Some(PackageKind::AppImage) + } else { + None + }; + let mut choice = self.settings.preferred_kinds.first().copied().or(default_kind); row(ui, tl!("Install new apps as"), &mut |ui| { - let label = choice.map_or(tl!("System package (recommended)"), |k| crate::i18n::t(k.label())); + let default_label = match default_kind { + Some(k) => crate::i18n::t(k.label()), + None => tl!("System package (recommended)"), + }; + let label = choice.map_or(default_label, |k| crate::i18n::t(k.label())); egui::ComboBox::from_id_salt("kind").selected_text(label).width(210.0).show_ui(ui, |ui| { - ui.selectable_value(&mut choice, None, tl!("System package (recommended)")); + if !is_flatpak && !is_appimage { + ui.selectable_value(&mut choice, None, tl!("System package (recommended)")); + } for k in [PackageKind::Deb, PackageKind::Rpm, PackageKind::Flatpak, PackageKind::AppImage] { ui.selectable_value(&mut choice, Some(k), crate::i18n::t(k.label())); } diff --git a/crates/ui-egui/src/settings.rs b/crates/ui-egui/src/settings.rs index c715cf7..66ebe77 100644 --- a/crates/ui-egui/src/settings.rs +++ b/crates/ui-egui/src/settings.rs @@ -132,6 +132,37 @@ impl Settings { } } +/// Whether this process is running inside a Flatpak container sandbox. +pub fn is_flatpak_sandbox() -> bool { + #[cfg(target_os = "linux")] + { + std::env::var_os("FLATPAK_ID").is_some() || std::path::Path::new("/.flatpak-info").exists() + } + #[cfg(not(target_os = "linux"))] + false +} + +/// Whether this process was launched from an AppImage. +pub fn is_appimage() -> bool { + #[cfg(target_os = "linux")] + { + std::env::var_os("APPIMAGE").is_some() + } + #[cfg(not(target_os = "linux"))] + false +} + +/// Default package kinds preferred on Linux based on how the launcher itself was installed. +pub fn default_preferred_kinds() -> Vec { + if is_flatpak_sandbox() { + vec![PackageKind::Flatpak] + } else if is_appimage() { + vec![PackageKind::AppImage] + } else { + Vec::new() + } +} + impl Default for Settings { fn default() -> Self { Self { @@ -147,7 +178,7 @@ impl Default for Settings { auto_update: false, notify: true, prereleases: false, - preferred_kinds: Vec::new(), + preferred_kinds: default_preferred_kinds(), dismissed_notices: std::collections::BTreeSet::new(), notified: BTreeMap::new(), last_check: None,