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)
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-10-11 02:21:10 -04:00
1 parent 9eb2c60f18
commit 28ef3ad924
6 files changed
+186 -19

No files matched your search

+2 -2
View File
@@ -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;
}
}
+76 -10
View File
@@ -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<PackageKind> {
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<PackageKind> {
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));
}
}
+57 -2
View File
@@ -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<String> {
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<std::path::PathBuf> {
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()
}
}
}
+1 -1
View File
@@ -127,7 +127,7 @@ fn msi_install_dir() -> Option<PathBuf> {
#[cfg(target_os = "linux")]
fn detect(exe: &Path) -> Option<Install> {
if std::env::var_os("FLATPAK_ID").is_some() {
if process::in_sandbox() {
return None;
}
if let Some(appimage) = std::env::var_os("APPIMAGE") {
+18 -3
View File
@@ -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()));
}
+32 -1
View File
@@ -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<PackageKind> {
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,