Browse / github.com/storytold/craft-launcher / patch
linux: keep Flatpak bundle updates off remote installs, report the re…
linux: keep Flatpak bundle updates off remote installs, report the re… …al error, and ask Flatpak for its arch - Only pass --reinstall when the app is already installed from a bundle. A copy from a remote such as Flathub is no longer silently removed and taken over by the bundle; the user is told to update it with flatpak update instead. A fresh install no longer uses --reinstall at all. - When a reinstall fails, say the app may need installing again (Flatpak removes the old copy first; its data in ~/.var/app stays). - Report Flatpak's own error: line even when warnings or a transaction table come before it, instead of passing all of stderr (or stdout) through.
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- CTMJUAN (imported from pull request #700, open)
- Base
ba3b0345f0b14f5cb892dcb8b917ca212c124cf9- Size
- +113 −28 in 3 files
- Review
- pending
- Shared
- 2026-10-28
- Id
42ab825fcf7c5b3713bf8cb4
How it went
No other operator has reported using it yet.
Diff
raw · crates/native/src/detect.rs crates/native/src/install.rs crates/native/src/self_update.rs
@@ -312,12 +312,23 @@ pub mod linux { { add(InstallSource::Pacman, packages::parse_pacman(&list)); }- let arch = format!("--arch={}", std::env::consts::ARCH);- if let Some(list) = crate::process::capture_flatpak(&["list", "--app", &arch, "--columns=application,version"], t) {+ // Flatpak's names for architectures aren't Rust's (`i386`, not `x86`), and the launcher+ // may run emulated on another one: ask Flatpak which it installs for. Without an answer,+ // list every architecture.+ let arch = crate::process::capture_flatpak(&["--default-arch"], t)+ .map(|a| a.trim().to_string())+ .filter(|a| !a.is_empty())+ .map(|a| format!("--arch={a}"));+ let mut list_args = vec!["list", "--app", "--columns=application,version"];+ list_args.extend(arch.as_deref());+ if let Some(list) = crate::process::capture_flatpak(&list_args, 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) = crate::process::capture_flatpak(&["info", &arch, id], t).as_deref().and_then(packages::parse_flatpak_info) {+ let mut info = vec!["info"];+ info.extend(arch.as_deref());+ info.push(id);+ if let Some(v) = crate::process::capture_flatpak(&info, t).as_deref().and_then(packages::parse_flatpak_info) { *version = v; } }@@ -118,7 +118,7 @@ pub fn install_at( } #[cfg(target_os = "linux")] PackageKind::Deb | PackageKind::Rpm | PackageKind::ArchPkg | PackageKind::Flatpak => {- linux::install_package(kind, &file, job.stop, &mut *job.phase).map(|_| None)+ linux::install_package(app, kind, &file, job.stop, &mut *job.phase).map(|_| None) } #[cfg(target_os = "linux")] PackageKind::AppImage => {@@ -694,6 +694,7 @@ fn cancelled(stop: &Stop) -> impl Fn() -> bool + '_ { pub(crate) mod linux { use std::ffi::{OsStr, OsString}; use std::os::unix::fs::PermissionsExt;+ use std::time::Duration; use artcraft_launcher_core::distro; use artcraft_launcher_core::protocol::CANCELLED;@@ -825,44 +826,58 @@ pub(crate) mod linux { } /// Installs (or upgrades) a downloaded package with the system's own tools.- pub fn install_package(kind: PackageKind, file: &Path, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {+ pub fn install_package(app: &CraftApp, kind: PackageKind, file: &Path, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> { if kind == PackageKind::Deb && which("apt-get").is_none() { return Err("this system has no apt-get to install a Debian package with".into()); } if kind == PackageKind::ArchPkg && which("pacman").is_none() { return Err("this system has no pacman to install an Arch package with".into()); }- install_package_via(&SYSTEM, kind, file, stop, phase)+ install_package_via(&SYSTEM, app, kind, file, stop, phase) } - pub fn install_package_via(root: &Root<'_>, kind: PackageKind, file: &Path, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {+ pub fn install_package_via(+ root: &Root<'_>,+ app: &CraftApp,+ kind: PackageKind,+ file: &Path,+ stop: &Stop,+ phase: &mut dyn FnMut(JobPhase),+ ) -> Result<(), String> { let f = file.as_os_str(); if let Some(command) = install_command(kind) { return as_root(root, command, f, stop, phase, JobPhase::Installing); } match kind { PackageKind::Flatpak => {+ let id = app.bundle_id(); let install_one = || {- log::info!("executing flatpak install --reinstall --bundle for {}", file.display());- let output = process::flatpak_command()- .args(["install", "--user", "-y", "--noninteractive", "--reinstall", "--bundle"])- .arg(f)- .output()- .map_err(|e| format!("couldn't start Flatpak ({e})"))?;+ // A bundle can't update an installed copy in place: `--reinstall` removes it+ // first. Only do that to a copy that came from a bundle, never to one a remote+ // such as Flathub keeps up to date, which the bundle would quietly take over.+ let origin = process::capture_flatpak(&["info", "--user", "--show-origin", &id], Duration::from_secs(10))+ .map(|o| o.trim().to_string())+ .filter(|o| !o.is_empty());+ if let Some(origin) = origin.as_deref().filter(|o| !is_bundle_origin(o)) {+ return Err(format!("{} is installed from the Flatpak remote {origin}. Update it with flatpak update {id}", app.name));+ }+ let mut cmd = process::flatpak_command();+ cmd.args(["install", "--user", "-y", "--noninteractive"]);+ if origin.is_some() {+ cmd.arg("--reinstall");+ }+ log::info!("executing flatpak install{} --bundle for {}", if origin.is_some() { " --reinstall" } else { "" }, file.display());+ let output = cmd.arg("--bundle").arg(f).output().map_err(|e| format!("couldn't start Flatpak ({e})"))?; if output.status.success() { log::info!("flatpak install succeeded for {}", file.display());- Ok(())- } else {- let stderr = String::from_utf8_lossy(&output.stderr);- let err = stderr.trim();- let err = if err.is_empty() { String::from_utf8_lossy(&output.stdout).trim().to_string() } else { err.to_string() };- if err.is_empty() {- Err("Flatpak couldn't install the bundle (it needs the Flathub remote for its runtime)".into())- } else {- let clean_err = err.strip_prefix("error: ").or_else(|| err.strip_prefix("Error: ")).unwrap_or(&err);- Err(format!("Flatpak couldn't install the bundle: {clean_err}"))- }+ return Ok(()); }+ let err = flatpak_error(&output.stderr, &output.stdout);+ if origin.is_some() {+ // Flatpak may already have removed the old copy; its data in ~/.var/app stays.+ return Err(format!("{err}. {} may need installing again; its settings are kept.", app.name));+ }+ Err(err) }; root.queue.alone(&cancelled(stop), &mut turn(phase, JobPhase::Installing), install_one).unwrap_or(Err(CANCELLED.into())) }@@ -870,6 +885,27 @@ pub(crate) mod linux { } } + /// Whether `origin`, the remote an installed Flatpak came from, is one Flatpak made up when it+ /// installed a bundle (`ai.storyteller.photocraft-origin`), not a real remote like `flathub`.+ pub(crate) fn is_bundle_origin(origin: &str) -> bool {+ origin.contains("-origin")+ }++ /// What to tell the user when `flatpak install --bundle` fails: its `error:` line, wherever+ /// it printed one among warnings and progress, else the last thing it said on stderr.+ pub(crate) fn flatpak_error(stderr: &[u8], stdout: &[u8]) -> String {+ let (stderr, stdout) = (String::from_utf8_lossy(stderr), String::from_utf8_lossy(stdout));+ let error = stderr+ .lines()+ .chain(stdout.lines())+ .map(str::trim)+ .find_map(|l| l.strip_prefix("error:").or_else(|| l.strip_prefix("Error:")).map(str::trim).filter(|e| !e.is_empty()));+ match error.or_else(|| stderr.lines().map(str::trim).rev().find(|l| !l.is_empty())) {+ Some(e) => format!("Flatpak couldn't install the bundle: {e}"),+ None => "Flatpak couldn't install the bundle (it needs the Flathub remote for its runtime)".into(),+ }+ }+ pub fn remove_package(app: &CraftApp, installed: &Installed, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> { let mut remove = |command: &[&str], package: &str| as_root(&SYSTEM, command, OsStr::new(package), stop, phase, JobPhase::Removing); match installed.source {@@ -2026,7 +2062,14 @@ mod tests { waiting.send(()).unwrap(); } };- linux::install_package_via(root, PackageKind::Deb, Path::new(file), &Stop::default(), &mut phase)+ linux::install_package_via(+ root,+ CraftApp::by_id("photocraft").unwrap(),+ PackageKind::Deb,+ Path::new(file),+ &Stop::default(),+ &mut phase,+ ) }) }); waits.recv().unwrap();@@ -2069,12 +2112,43 @@ mod tests { } let queue = crate::queue::Queue::new(); let root = linux::Root { queue: &queue, run: &|_| Ok(()) };- let result = linux::install_package_via(&root, PackageKind::Flatpak, Path::new("/nonexistent/file.flatpak"), &Stop::default(), &mut |_| ());+ let app = CraftApp::by_id("photocraft").unwrap();+ let result =+ linux::install_package_via(&root, app, PackageKind::Flatpak, Path::new("/nonexistent/file.flatpak"), &Stop::default(), &mut |_| ()); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.starts_with("Flatpak couldn't install the bundle: "), "unexpected error: {err}"); } + #[cfg(target_os = "linux")]+ #[test]+ fn flatpak_error_picks_the_error_line() {+ let warned = b"warning: Unable to load summary from remote flathub\nerror: The application ai.storyteller.photocraft requires the runtime org.kde.Platform/x86_64/6.8 which was not found\n";+ assert_eq!(+ linux::flatpak_error(warned, b""),+ "Flatpak couldn't install the bundle: The application ai.storyteller.photocraft requires the runtime org.kde.Platform/x86_64/6.8 which was not found"+ );+ let table = b"Looking for matches...\n\n ID Branch\n 1. [\xe2\x9c\x97] ai.storyteller.photocraft stable\n\nError: Failed to install: No space left on device\n";+ assert_eq!(linux::flatpak_error(b"", table), "Flatpak couldn't install the bundle: Failed to install: No space left on device");+ assert_eq!(+ linux::flatpak_error(b"some note\nBundle is not signed\n\n", b"progress"),+ "Flatpak couldn't install the bundle: Bundle is not signed"+ );+ assert_eq!(+ linux::flatpak_error(b" \n", b"1. ai.storyteller.photocraft"),+ "Flatpak couldn't install the bundle (it needs the Flathub remote for its runtime)"+ );+ }++ #[cfg(target_os = "linux")]+ #[test]+ fn only_bundle_origins_are_reinstalled_over() {+ assert!(linux::is_bundle_origin("ai.storyteller.photocraft-origin"));+ assert!(linux::is_bundle_origin("photocraft-1-origin"));+ assert!(!linux::is_bundle_origin("flathub"));+ assert!(!linux::is_bundle_origin("flathub-beta"));+ }+ #[cfg(target_os = "linux")] #[test] fn adds_and_removes_only_its_own_menu_entry_and_icon() {@@ -252,14 +252,14 @@ pub fn update( } #[cfg(target_os = "linux")] Install::Package(kind, exe) => {- let result = install::linux::install_package(*kind, &file, stop, phase);+ let result = install::linux::install_package(&LAUNCHER, *kind, &file, stop, phase); let _ = std::fs::remove_file(&file); result?; relaunch_after_exit(pid, &[exe.as_os_str()]) } #[cfg(target_os = "linux")] Install::Flatpak => {- let result = install::linux::install_package(PackageKind::Flatpak, &file, stop, phase);+ let result = install::linux::install_package(&LAUNCHER, PackageKind::Flatpak, &file, stop, phase); let _ = std::fs::remove_file(&file); result?; relaunch_flatpak_after_exit()