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:
parent
bd1182e75c
commit
797071e591
|
|
@ -184,7 +184,7 @@ pub mod windows {
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub mod linux {
|
pub mod linux {
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use artcraft_launcher_core::platform::parse_asset_name;
|
use artcraft_launcher_core::platform::parse_asset_name;
|
||||||
|
|
@ -198,7 +198,7 @@ pub mod linux {
|
||||||
pub fn packages() -> Vec<Package> {
|
pub fn packages() -> Vec<Package> {
|
||||||
let ours = |name: &str| {
|
let ours = |name: &str| {
|
||||||
artcraft_launcher_core::SUITE.iter().any(|a| {
|
artcraft_launcher_core::SUITE.iter().any(|a| {
|
||||||
[InstallSource::Deb, InstallSource::Pacman, InstallSource::Flatpak]
|
[InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak]
|
||||||
.iter()
|
.iter()
|
||||||
.any(|m| crate::packages::candidate_names(a, *m).iter().any(|n| n == name))
|
.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 });
|
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 order = [InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak];
|
||||||
let p = packages::find(app, &snapshot.packages, &order)?;
|
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) };
|
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()) })
|
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()?)
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,10 @@ pub fn parse_dpkg(text: &str) -> Vec<(String, String)> {
|
||||||
.filter_map(|line| {
|
.filter_map(|line| {
|
||||||
let mut f = line.split('|');
|
let mut f = line.split('|');
|
||||||
match (f.next(), f.next(), f.next()) {
|
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,
|
_ => 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
|
/// 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`)
|
/// 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> {
|
pub fn candidate_names(app: &CraftApp, manager: InstallSource) -> Vec<String> {
|
||||||
match manager {
|
match manager {
|
||||||
InstallSource::Flatpak => vec![app.bundle_id()],
|
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(),
|
_ => app.stems().map(str::to_string).collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +103,10 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reads_each_package_managers_listing() {
|
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_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!(parse_pacman("photocraft-bin 0.5.0-1\nlinux 6.11.1.arch1-1\n").len(), 2);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -106,6 +118,23 @@ mod tests {
|
||||||
assert_eq!(parse_flatpak_info("Version: \n"), None);
|
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]
|
#[test]
|
||||||
fn finds_aur_packages_in_every_flavour() {
|
fn finds_aur_packages_in_every_flavour() {
|
||||||
let photo = CraftApp::by_id("photocraft").unwrap();
|
let photo = CraftApp::by_id("photocraft").unwrap();
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ use std::path::Path;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::time::{Duration, Instant};
|
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 {
|
pub fn command(program: impl AsRef<std::ffi::OsStr>) -> Command {
|
||||||
#[allow(unused_mut)]
|
#[allow(unused_mut)]
|
||||||
let mut cmd = Command::new(program);
|
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;
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||||
}
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
clean_appimage_env(&mut cmd);
|
||||||
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`.
|
/// Runs a helper and returns its standard output when it succeeds within `timeout`.
|
||||||
pub fn capture(program: &str, args: &[&str], timeout: Duration) -> Option<String> {
|
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()?;
|
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() {
|
if let Some(dir) = exe.parent() {
|
||||||
cmd.current_dir(dir);
|
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)
|
spawn_detached(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue