Linux: fix detection of system package installs (#99)

- Include InstallSource::Rpm in ours package filter
- Add bundle_id to candidate names for Deb and Rpm packages
- Strip architecture suffix from package name in parse_dpkg
- Clear AppImage environment variables in process::command to prevent polluting system tools
- Fall back to detecting system desktop entries and binaries with CLI version extraction
- Add unit tests for candidate names, RPM filtering, and binary CLI version

Fixes #97
This commit is contained in:
Dylan M. Taylor 2026-10-11 02:10:53 -04:00 committed by GitHub
parent bd1182e75c
commit 797071e591
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 171 additions and 28 deletions

View File

@ -184,7 +184,7 @@ pub mod windows {
#[cfg(target_os = "linux")]
pub mod linux {
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::time::Duration;
use artcraft_launcher_core::platform::parse_asset_name;
@ -198,7 +198,7 @@ pub mod linux {
pub fn packages() -> Vec<Package> {
let ours = |name: &str| {
artcraft_launcher_core::SUITE.iter().any(|a| {
[InstallSource::Deb, InstallSource::Pacman, InstallSource::Flatpak]
[InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak]
.iter()
.any(|m| crate::packages::candidate_names(a, *m).iter().any(|n| n == name))
})
@ -269,9 +269,78 @@ pub mod linux {
return Some(Installed { version, source: InstallSource::AppImage, location: path.display().to_string(), package: None });
}
let order = [InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak];
let p = packages::find(app, &snapshot.packages, &order)?;
let location = if p.manager == InstallSource::Flatpak { app.bundle_id() } else { binary_on_path(app.id) };
Some(Installed { version: packages::version_of(p), source: p.manager, location, package: Some(p.name.clone()) })
if let Some(p) = packages::find(app, &snapshot.packages, &order) {
let location = if p.manager == InstallSource::Flatpak { app.bundle_id() } else { binary_on_path(app.id) };
return Some(Installed { version: packages::version_of(p), source: p.manager, location, package: Some(p.name.clone()) });
}
detect_system_install(app)
}
/// When package manager queries don't report the package (e.g. timeout, unlisted name,
/// running inside a container sandbox, or lock conflicts), detect the app from its desktop
/// entry or system binary.
fn detect_system_install(app: &CraftApp) -> Option<Installed> {
let bin = find_binary(app.id);
let desktop = find_desktop_file(app);
if bin.is_none() && desktop.is_none() {
return None;
}
let bin_path = bin.unwrap_or_else(|| PathBuf::from("/usr/bin").join(app.id));
let version = binary_version(&bin_path, app.id);
let source = if Path::new("/usr/bin/dpkg").is_file() || which("dpkg").is_some() {
InstallSource::Deb
} else if Path::new("/usr/bin/rpm").is_file() || which("rpm").is_some() {
InstallSource::Rpm
} else {
InstallSource::Binary
};
Some(Installed {
version,
source,
location: bin_path.display().to_string(),
package: Some(app.id.to_string()),
})
}
fn find_binary(id: &str) -> Option<PathBuf> {
if let Some(path) = std::env::var_os("PATH") {
if let Some(p) = std::env::split_paths(&path).map(|d| d.join(id)).find(|p| p.is_file()) {
return Some(p);
}
}
for dir in ["/usr/bin", "/usr/local/bin"] {
let p = PathBuf::from(dir).join(id);
if p.is_file() {
return Some(p);
}
}
if let Some(home) = dirs::home_dir() {
let p = home.join(".local/bin").join(id);
if p.is_file() {
return Some(p);
}
}
None
}
fn find_desktop_file(app: &CraftApp) -> Option<PathBuf> {
let mut dirs = vec![PathBuf::from("/usr/share/applications"), PathBuf::from("/usr/local/share/applications")];
if let Some(data) = dirs::data_dir() {
dirs.push(data.join("applications"));
}
let names = [format!("{}.desktop", app.bundle_id()), format!("{}.desktop", app.id)];
dirs.into_iter().flat_map(|d| names.iter().map(move |n| d.join(n))).find(|p| p.is_file())
}
pub(crate) fn binary_version(bin: &Path, id: &str) -> Option<version::Version> {
let out = crate::process::capture(bin.to_str()?, &["--version"], Duration::from_secs(5))?;
let rest = out.trim().strip_prefix(id)?.trim_start();
version::parse(rest.split_whitespace().next()?)
}
fn which(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path).map(|d| d.join(name)).find(|p| p.is_file())
}
}
@ -297,3 +366,40 @@ pub mod freebsd {
version::parse(rest.split_whitespace().next()?)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(target_os = "linux")]
fn ours_filter_matches_rpm_packages() {
let ours = |name: &str| {
artcraft_launcher_core::SUITE.iter().any(|a| {
[InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak]
.iter()
.any(|m| crate::packages::candidate_names(a, *m).iter().any(|n| n == name))
})
};
assert!(ours("photocraft"));
assert!(ours("ai.storyteller.photocraft"));
assert!(!ours("unrelated-package"));
}
#[test]
#[cfg(target_os = "linux")]
fn reads_binary_version_from_cli() {
let photo = CraftApp::by_id("photocraft").unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let bin = temp_dir.path().join("photocraft");
std::fs::write(&bin, "#!/bin/sh\necho 'photocraft 0.6.0 (0c72d9542, 2026-10-10)'\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let ver = linux::binary_version(&bin, photo.id);
assert_eq!(ver.unwrap().to_string(), "0.6.0");
}
}

View File

@ -21,7 +21,10 @@ pub fn parse_dpkg(text: &str) -> Vec<(String, String)> {
.filter_map(|line| {
let mut f = line.split('|');
match (f.next(), f.next(), f.next()) {
(Some(name), Some("installed"), Some(ver)) => Some((name.to_string(), ver.to_string())),
(Some(name), Some("installed"), Some(ver)) => {
let name = name.split_once(':').map(|(n, _)| n).unwrap_or(name);
Some((name.to_string(), ver.to_string()))
}
_ => None,
}
})
@ -62,11 +65,17 @@ pub fn parse_flatpak_info(text: &str) -> Option<String> {
/// The package names a Craft App may be installed under with `manager`. On Arch the AUR carries
/// each app three ways: built from source (`photocraft`), the release binary (`photocraft-bin`)
/// and the latest commit (`photocraft-git`).
/// and the latest commit (`photocraft-git`). Debian and RPM packages may use either the short stem
/// (`photocraft`) or the reverse-DNS bundle id (`ai.storyteller.photocraft`).
pub fn candidate_names(app: &CraftApp, manager: InstallSource) -> Vec<String> {
match manager {
InstallSource::Flatpak => vec![app.bundle_id()],
InstallSource::Pacman => app.stems().flat_map(|s| [s.to_string(), format!("{s}-bin"), format!("{s}-git")]).collect(),
InstallSource::Pacman => {
app.stems().flat_map(|s| [s.to_string(), format!("{s}-bin"), format!("{s}-git")]).chain(std::iter::once(app.bundle_id())).collect()
}
InstallSource::Deb | InstallSource::Rpm => {
app.stems().map(str::to_string).chain(std::iter::once(app.bundle_id())).collect()
}
_ => app.stems().map(str::to_string).collect(),
}
}
@ -94,7 +103,10 @@ mod tests {
#[test]
fn reads_each_package_managers_listing() {
assert_eq!(parse_dpkg("photocraft|installed|0.5.0-1\nwordcraft|config-files|0.3.0-1\n"), [("photocraft".into(), "0.5.0-1".into())]);
assert_eq!(
parse_dpkg("photocraft|installed|0.5.0-1\nphotocraft:amd64|installed|0.6.0-1\nwordcraft|config-files|0.3.0-1\n"),
[("photocraft".into(), "0.5.0-1".into()), ("photocraft".into(), "0.6.0-1".into())]
);
assert_eq!(parse_rpm("gridcraft|0.3.0\n"), [("gridcraft".into(), "0.3.0".into())]);
assert_eq!(parse_pacman("photocraft-bin 0.5.0-1\nlinux 6.11.1.arch1-1\n").len(), 2);
assert_eq!(
@ -106,6 +118,23 @@ mod tests {
assert_eq!(parse_flatpak_info("Version: \n"), None);
}
#[test]
fn candidate_names_cover_bundle_ids_for_deb_rpm_and_pacman() {
let photo = CraftApp::by_id("photocraft").unwrap();
let deb_names = candidate_names(photo, InstallSource::Deb);
assert!(deb_names.contains(&"photocraft".to_string()));
assert!(deb_names.contains(&"ai.storyteller.photocraft".to_string()));
let rpm_names = candidate_names(photo, InstallSource::Rpm);
assert!(rpm_names.contains(&"photocraft".to_string()));
assert!(rpm_names.contains(&"ai.storyteller.photocraft".to_string()));
let pacman_names = candidate_names(photo, InstallSource::Pacman);
assert!(pacman_names.contains(&"photocraft".to_string()));
assert!(pacman_names.contains(&"photocraft-bin".to_string()));
assert!(pacman_names.contains(&"ai.storyteller.photocraft".to_string()));
}
#[test]
fn finds_aur_packages_in_every_flavour() {
let photo = CraftApp::by_id("photocraft").unwrap();

View File

@ -4,7 +4,8 @@ use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// A `Command` that won't flash a console window on Windows.
/// A `Command` that won't flash a console window on Windows, and clears AppImage-injected
/// environment variables that corrupt system helper binaries and external applications.
pub fn command(program: impl AsRef<std::ffi::OsStr>) -> Command {
#[allow(unused_mut)]
let mut cmd = Command::new(program);
@ -14,9 +15,34 @@ pub fn command(program: impl AsRef<std::ffi::OsStr>) -> Command {
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(unix)]
clean_appimage_env(&mut cmd);
cmd
}
/// Cleans AppImage runtime environment variables so child processes don't load incompatible
/// libraries from the launcher's bundled AppImage.
#[cfg(unix)]
pub fn clean_appimage_env(cmd: &mut Command) {
if std::env::var_os("APPIMAGE").is_some() {
for var in [
"LD_LIBRARY_PATH",
"LD_PRELOAD",
"APPDIR",
"APPIMAGE",
"ARGV0",
"OWD",
"GDK_PIXBUF_MODULE_FILE",
"GIO_MODULE_DIR",
"GTK_PATH",
"PYTHONHOME",
"QT_PLUGIN_PATH",
] {
cmd.env_remove(var);
}
}
}
/// Runs a helper and returns its standard output when it succeeds within `timeout`.
pub fn capture(program: &str, args: &[&str], timeout: Duration) -> Option<String> {
let mut child = command(program).args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?;
@ -58,24 +84,6 @@ pub fn launch_binary(exe: &Path, args: &[&str]) -> Result<(), String> {
if let Some(dir) = exe.parent() {
cmd.current_dir(dir);
}
// An AppImage'd launcher sets these for itself; they'd break the app we start.
for var in [
"LD_LIBRARY_PATH",
"LD_PRELOAD",
"APPDIR",
"APPIMAGE",
"ARGV0",
"OWD",
"GDK_PIXBUF_MODULE_FILE",
"GIO_MODULE_DIR",
"GTK_PATH",
"PYTHONHOME",
"QT_PLUGIN_PATH",
] {
if std::env::var_os("APPIMAGE").is_some() {
cmd.env_remove(var);
}
}
spawn_detached(cmd)
}