feat(linux): handle installing and updating Arch packages (.pkg.tar.zst) via settings

This commit is contained in:
Dylan M. Taylor
2026-10-17 17:22:24 -04:00
parent ac88b2b6c4
commit 0c8f5a7603
16 changed files with 85 additions and 33 deletions
+4 -2
View File
@@ -44,7 +44,8 @@ fn unquote(v: &str) -> &str {
}
/// The distro's own package format: `.deb` for the Debian family, `.rpm` for the Fedora, Red Hat
/// and SUSE families, `None` for anything else (Arch, unknown distros), which gets AppImages.
/// and SUSE families, `.pkg.tar.zst` for the Arch Linux family, `None` for anything else
/// (unknown distros), which gets AppImages.
pub fn native_package(os: &OsRelease) -> Option<PackageKind> {
std::iter::once(&os.id).chain(&os.id_like).find_map(|id| family(id))
}
@@ -54,6 +55,7 @@ fn family(id: &str) -> Option<PackageKind> {
"debian" | "ubuntu" => Some(PackageKind::Deb),
"fedora" | "rhel" | "centos" | "suse" | "sles" => Some(PackageKind::Rpm),
_ if id.starts_with("opensuse") => Some(PackageKind::Rpm),
"arch" | "manjaro" | "endeavouros" | "garuda" | "cachyos" | "artix" => Some(PackageKind::ArchPkg),
_ => None,
}
}
@@ -99,7 +101,7 @@ ID_LIKE=debian
assert_eq!(native(TUMBLEWEED), Some(PackageKind::Rpm));
assert_eq!(native(UBUNTU), Some(PackageKind::Deb));
assert_eq!(native(MINT), Some(PackageKind::Deb));
assert_eq!(native(ARCH), None);
assert_eq!(native(ARCH), Some(PackageKind::ArchPkg));
assert_eq!(native(UNKNOWN), None);
assert_eq!(native(""), None);
}
+12 -4
View File
@@ -60,6 +60,8 @@ pub enum PackageKind {
AppImage,
Deb,
Rpm,
/// Arch Linux package (`.pkg.tar.zst`).
ArchPkg,
Flatpak,
/// Linux or FreeBSD tarball (`bin/<app>`, `share/…`).
TarGz,
@@ -74,6 +76,7 @@ impl PackageKind {
Self::AppImage => "AppImage",
Self::Deb => "Debian package (.deb)",
Self::Rpm => "RPM package (.rpm)",
Self::ArchPkg => "Arch package (.pkg.tar.zst)",
Self::Flatpak => "Flatpak bundle",
Self::TarGz => "Tarball (.tar.gz)",
}
@@ -87,6 +90,7 @@ impl PackageKind {
Self::AppImage => ".AppImage",
Self::Deb => ".deb",
Self::Rpm => ".rpm",
Self::ArchPkg => ".pkg.tar.zst",
Self::Flatpak => ".flatpak",
Self::TarGz => ".tar.gz",
}
@@ -171,7 +175,7 @@ impl Platform {
match self.os {
Os::Windows => &[PackageKind::Msi, PackageKind::PortableZip],
Os::MacOs => &[PackageKind::Dmg],
Os::Linux => &[PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::Flatpak, PackageKind::TarGz],
Os::Linux => &[PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::ArchPkg, PackageKind::Flatpak, PackageKind::TarGz],
Os::FreeBsd => &[PackageKind::TarGz],
}
}
@@ -189,7 +193,7 @@ pub struct AssetName {
/// Reads `<stem>-<version>-<os>-<arch>[-portable].<ext>`; `None` for checksums, zsync files, CLI
/// and web bundles, or anything else.
pub fn parse_asset_name(name: &str, stems: &[&str]) -> Option<AssetName> {
const KINDS: [PackageKind; 8] = [
const KINDS: [PackageKind; 9] = [
PackageKind::PortableZip,
PackageKind::TarGz,
PackageKind::Msi,
@@ -197,6 +201,7 @@ pub fn parse_asset_name(name: &str, stems: &[&str]) -> Option<AssetName> {
PackageKind::AppImage,
PackageKind::Deb,
PackageKind::Rpm,
PackageKind::ArchPkg,
PackageKind::Flatpak,
];
let (kind, base) = KINDS.iter().find_map(|k| name.strip_suffix(k.suffix()).map(|b| (*k, b)))?;
@@ -362,6 +367,8 @@ mod tests {
"photocraft-0.5.0-linux-x86_64.deb",
"photocraft-0.5.0-linux-aarch64.rpm",
"photocraft-0.5.0-linux-x86_64.rpm",
"photocraft-0.5.0-linux-aarch64.pkg.tar.zst",
"photocraft-0.5.0-linux-x86_64.pkg.tar.zst",
"photocraft-0.5.0-linux-aarch64.flatpak",
"photocraft-0.5.0-linux-x86_64.flatpak",
"photocraft-0.5.0-linux-aarch64.tar.gz",
@@ -371,7 +378,7 @@ mod tests {
let p_x64 = Platform { os: Os::Linux, arch: Arch::X64 };
let p_arm64 = Platform { os: Os::Linux, arch: Arch::Arm64 };
for kind in [PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::Flatpak, PackageKind::TarGz] {
for kind in [PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::ArchPkg, PackageKind::Flatpak, PackageKind::TarGz] {
let pick_x64 = pick_asset(&multi_arch_release, &["photocraft"], p_x64, &[kind]).map(|(a, _)| a.name.clone());
assert!(pick_x64.as_deref().unwrap().contains("linux-x86_64"), "Expected x86_64 asset for {kind:?}, got {pick_x64:?}");
@@ -384,10 +391,11 @@ mod tests {
"photocraft-0.5.0-linux-aarch64.AppImage",
"photocraft-0.5.0-linux-aarch64.deb",
"photocraft-0.5.0-linux-aarch64.rpm",
"photocraft-0.5.0-linux-aarch64.pkg.tar.zst",
"photocraft-0.5.0-linux-aarch64.flatpak",
"photocraft-0.5.0-linux-aarch64.tar.gz",
]);
for kind in [PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::Flatpak, PackageKind::TarGz] {
for kind in [PackageKind::AppImage, PackageKind::Deb, PackageKind::Rpm, PackageKind::ArchPkg, PackageKind::Flatpak, PackageKind::TarGz] {
assert_eq!(pick_asset(&arm_only_release, &["photocraft"], p_x64, &[kind]), None, "x86_64 must never match an aarch64 asset for {kind:?}");
}
}
+43 -24
View File
@@ -117,7 +117,9 @@ pub fn install_at(
.map(Some)
}
#[cfg(target_os = "linux")]
PackageKind::Deb | PackageKind::Rpm | PackageKind::Flatpak => linux::install_package(kind, &file, job.stop, &mut *job.phase).map(|_| None),
PackageKind::Deb | PackageKind::Rpm | PackageKind::ArchPkg | PackageKind::Flatpak => {
linux::install_package(kind, &file, job.stop, &mut *job.phase).map(|_| None)
}
#[cfg(target_os = "linux")]
PackageKind::AppImage => {
linux::install_appimage(&file, &asset.name, destination.as_deref(), previous, job.replace).map(|(path, replaced)| {
@@ -253,6 +255,7 @@ fn record(version: &str, kind: PackageKind, path: PathBuf, files: Vec<PathBuf>,
/// stays a `.deb` update), else the user's choice, else the best this system supports.
pub fn choose_kinds(existing: Option<&Installed>, record: Option<&Record>, preferred: &[PackageKind]) -> Vec<PackageKind> {
let available = available_kinds();
let can_pacman = which("pacman").is_some();
if let Some(inst) = existing {
let kind = match inst.source {
InstallSource::Launcher => record.map(|r| r.kind),
@@ -260,18 +263,17 @@ pub fn choose_kinds(existing: Option<&Installed>, record: Option<&Record>, prefe
InstallSource::Msi => Some(PackageKind::Msi),
InstallSource::Deb => Some(PackageKind::Deb),
InstallSource::Rpm => Some(PackageKind::Rpm),
// Arch/AUR installs are owned by pacman and updated by the user's AUR helper (see
// `managed_elsewhere`); the launcher opens and removes them but never replaces them.
InstallSource::Pacman => None,
InstallSource::Pacman => Some(PackageKind::ArchPkg),
InstallSource::Flatpak => Some(PackageKind::Flatpak),
InstallSource::AppImage => Some(PackageKind::AppImage),
InstallSource::Binary => Some(PackageKind::TarGz),
};
if let Some(k) = kind.filter(|k| available.contains(k)) {
if let Some(k) = kind.filter(|k| available.contains(k) || (*k == PackageKind::ArchPkg && can_pacman)) {
return vec![k];
}
}
let mut kinds: Vec<PackageKind> = preferred.iter().copied().filter(|k| available.contains(k)).collect();
let mut kinds: Vec<PackageKind> =
preferred.iter().copied().filter(|k| available.contains(k) || (*k == PackageKind::ArchPkg && can_pacman)).collect();
for k in available {
if !kinds.contains(&k) {
kinds.push(k);
@@ -340,12 +342,16 @@ pub fn managed_elsewhere(app: &CraftApp, installed: &Installed, uninstall: bool)
match installed.source {
InstallSource::Pacman if !uninstall => {
let pkg = installed.package.clone().unwrap_or_else(|| app.id.to_string());
let helper = ["paru", "yay"].into_iter().find(|h| which(h).is_some());
let cmd = match helper {
Some(h) => format!("{h} -S {pkg}"),
None => format!("your AUR helper (for example: yay -S {pkg})"),
};
Some(format!("{} was installed from the AUR ({pkg}). Update it with {cmd}", app.name))
if pkg.ends_with("-git") || which("pacman").is_none() {
let helper = ["paru", "yay"].into_iter().find(|h| which(h).is_some());
let cmd = match helper {
Some(h) => format!("{h} -S {pkg}"),
None => format!("your AUR helper (for example: yay -S {pkg})"),
};
Some(format!("{} was installed from the AUR ({pkg}). Update it with {cmd}", app.name))
} else {
None
}
}
InstallSource::Binary => Some(format!("{} was installed outside the launcher. Update or remove it the way it was installed.", app.name)),
_ => None,
@@ -670,6 +676,7 @@ pub(crate) mod linux {
PackageKind::Rpm if which("dnf").is_some() => Some(&["dnf", "install", "-y"]),
PackageKind::Rpm if which("zypper").is_some() => Some(&["zypper", "--non-interactive", "install", "--allow-unsigned-rpm"]),
PackageKind::Rpm => Some(&["rpm", "-U", "--replacepkgs"]),
PackageKind::ArchPkg => Some(&["pacman", "-U", "--noconfirm"]),
_ => None,
}
}
@@ -689,8 +696,9 @@ pub(crate) mod linux {
/// AppImage cannot mount there and a distro package needs root, so both are dead ends.
/// - Running as an AppImage: prefers AppImage.
/// - Otherwise (system install): prefers the distro's own package format (`.deb` on the
/// Debian family, `.rpm` on the Fedora, Red Hat and SUSE families, read from `os-release`
/// rather than from whichever tools are on `PATH`), then Flatpak if available, then AppImage.
/// Debian family, `.rpm` on the Fedora, Red Hat and SUSE families, `.pkg.tar.zst` on Arch,
/// read from `os-release` rather than from whichever tools are on `PATH`), then Flatpak if
/// available, then AppImage.
pub fn available_kinds() -> Vec<PackageKind> {
let flatpak = process::is_flatpak_available();
let is_flatpak = process::in_sandbox();
@@ -702,7 +710,8 @@ pub(crate) mod linux {
let root = root && !immutable_ostree();
let can_deb = root && native == Some(PackageKind::Deb) && which("apt-get").is_some() && which("dpkg").is_some();
let can_rpm = root && native == Some(PackageKind::Rpm) && which("rpm").is_some();
order_available_kinds(is_flatpak, is_appimage, flatpak, can_deb, can_rpm)
let can_arch = root && native == Some(PackageKind::ArchPkg) && which("pacman").is_some();
order_available_kinds(is_flatpak, is_appimage, flatpak, can_deb, can_rpm, can_arch)
}
pub(crate) fn order_available_kinds(
@@ -711,6 +720,7 @@ pub(crate) mod linux {
flatpak_available: bool,
can_deb: bool,
can_rpm: bool,
can_arch: bool,
) -> Vec<PackageKind> {
let mut kinds = Vec::new();
if is_flatpak && flatpak_available {
@@ -727,6 +737,9 @@ pub(crate) mod linux {
if can_rpm {
kinds.push(PackageKind::Rpm);
}
if can_arch {
kinds.push(PackageKind::ArchPkg);
}
if flatpak_available && !kinds.contains(&PackageKind::Flatpak) {
kinds.push(PackageKind::Flatpak);
@@ -746,6 +759,9 @@ pub(crate) mod linux {
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)
}
@@ -2105,39 +2121,42 @@ mod tests {
use super::linux::order_available_kinds;
// Flatpak launcher defaults to Flatpak if available
let kinds = order_available_kinds(true, false, true, true, false);
let kinds = order_available_kinds(true, false, true, true, false, 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);
let kinds = order_available_kinds(false, true, true, true, false, 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);
// Base Linux launcher defaults to native package format (deb, rpm, or arch) if available
let kinds = order_available_kinds(false, false, true, true, false, false);
assert_eq!(kinds.first(), Some(&PackageKind::Deb));
let kinds = order_available_kinds(false, false, true, false, true);
let kinds = order_available_kinds(false, false, true, false, true, false);
assert_eq!(kinds.first(), Some(&PackageKind::Rpm));
let kinds = order_available_kinds(false, false, true, false, false, true);
assert_eq!(kinds.first(), Some(&PackageKind::ArchPkg));
// Base Linux launcher without root/distro packaging falls back to Flatpak then AppImage
let kinds = order_available_kinds(false, false, true, false, false);
let kinds = order_available_kinds(false, false, true, false, false, false);
assert_eq!(kinds.first(), Some(&PackageKind::Flatpak));
assert_eq!(kinds.get(1), Some(&PackageKind::AppImage));
// The tarball runs inside the sandbox, so it is only offered there: with a usable host
// Flatpak it stays an alternative, without one it is the default (an AppImage cannot
// mount in a sandbox, and a distro package needs root).
let kinds = order_available_kinds(true, false, true, false, false);
let kinds = order_available_kinds(true, false, true, false, false, false);
assert_eq!(kinds.first(), Some(&PackageKind::Flatpak));
assert_eq!(kinds.get(1), Some(&PackageKind::TarGz));
let kinds = order_available_kinds(true, false, false, false, false);
let kinds = order_available_kinds(true, false, false, false, false, false);
assert_eq!(kinds, [PackageKind::TarGz, PackageKind::AppImage]);
for (appimage, flatpak) in [(true, true), (false, false), (false, true)] {
assert!(!order_available_kinds(false, appimage, flatpak, true, true).contains(&PackageKind::TarGz));
assert!(!order_available_kinds(false, appimage, flatpak, true, true, false).contains(&PackageKind::TarGz));
}
}
+5
View File
@@ -151,6 +151,9 @@ fn detect(exe: &Path) -> Option<Install> {
if process::capture("rpm", &["-q", LAUNCHER.id], t).is_some() {
return Some(Install::Package(PackageKind::Rpm, exe.to_path_buf()));
}
if process::capture("pacman", &["-Q", LAUNCHER.id], t).is_some() || process::capture("pacman", &["-Q", &LAUNCHER.bundle_id()], t).is_some() {
return Some(Install::Package(PackageKind::ArchPkg, exe.to_path_buf()));
}
None
}
@@ -568,6 +571,7 @@ mod tests {
"artcraft-launcher-0.2.0-linux-x86_64.AppImage",
"artcraft-launcher-0.2.0-linux-x86_64.deb",
"artcraft-launcher-0.2.0-linux-x86_64.flatpak",
"artcraft-launcher-0.2.0-linux-x86_64.pkg.tar.zst",
"artcraft-launcher-web-0.2.0.zip",
];
let release = Release {
@@ -585,6 +589,7 @@ mod tests {
assert_eq!(pick(Os::Linux, Install::AppImage(PathBuf::new()).kind()).as_deref(), Some(names[3]));
assert_eq!(pick(Os::Linux, Install::Package(PackageKind::Deb, PathBuf::new()).kind()).as_deref(), Some(names[4]));
assert_eq!(pick(Os::Linux, Install::Flatpak.kind()).as_deref(), Some(names[5]));
assert_eq!(pick(Os::Linux, Install::Package(PackageKind::ArchPkg, PathBuf::new()).kind()).as_deref(), Some(names[6]));
}
#[test]
+1 -1
View File
@@ -421,7 +421,7 @@ impl LauncherUi {
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] {
for k in [PackageKind::Deb, PackageKind::Rpm, PackageKind::ArchPkg, PackageKind::Flatpak, PackageKind::AppImage] {
ui.selectable_value(&mut choice, Some(k), crate::i18n::t(k.label()));
}
// The tarball is unpacked and run inside the launcher's own sandbox, so it
+1
View File
@@ -139,6 +139,7 @@
Disk image (.dmg) Obraz disku (.dmg)
Debian package (.deb) Balíček Debian (.deb)
RPM package (.rpm) Balíček RPM (.rpm)
Arch package (.pkg.tar.zst) Balíček Arch (.pkg.tar.zst)
Flatpak bundle Balíček Flatpak
Tarball (.tar.gz) Archiv tar (.tar.gz)
photocraft Image editing Úpravy obrázků
1 # Czech (cs) catalog for ArtCraft Launcher.
139
140
141
142
143
144 photocraft
145 vectorcraft
+1
View File
@@ -155,6 +155,7 @@
Disk image (.dmg) Disk-Image (.dmg)
Debian package (.deb) Debian-Paket (.deb)
RPM package (.rpm) RPM-Paket (.rpm)
Arch package (.pkg.tar.zst) Arch-Paket (.pkg.tar.zst)
Flatpak bundle Flatpak-Bundle
Tarball (.tar.gz) Tar-Archiv (.tar.gz)
Can't render this file because it contains an unexpected character in line 11 and column 16.
+1
View File
@@ -152,6 +152,7 @@
Disk image (.dmg) Imagen de disco (.dmg)
Debian package (.deb) Paquete Debian (.deb)
RPM package (.rpm) Paquete RPM (.rpm)
Arch package (.pkg.tar.zst) Paquete Arch (.pkg.tar.zst)
Flatpak bundle Paquete Flatpak
Tarball (.tar.gz) Archivo tar (.tar.gz)
photocraft Image editing Edición de imágenes
Can't render this file because it contains an unexpected character in line 5 and column 48.
+1
View File
@@ -153,6 +153,7 @@
Disk image (.dmg) Image disque (.dmg)
Debian package (.deb) Paquet Debian (.deb)
RPM package (.rpm) Paquet RPM (.rpm)
Arch package (.pkg.tar.zst) Paquet Arch (.pkg.tar.zst)
Flatpak bundle Paquet Flatpak
Tarball (.tar.gz) Archive (.tar.gz)
Can't render this file because it contains an unexpected character in line 5 and column 50.
+1
View File
@@ -155,6 +155,7 @@
Disk image (.dmg) Lemezkép (.dmg)
Debian package (.deb) Debian-csomag (.deb)
RPM package (.rpm) RPM-csomag (.rpm)
Arch package (.pkg.tar.zst) Arch-csomag (.pkg.tar.zst)
Flatpak bundle Flatpak-csomag
Tarball (.tar.gz) Tömörített archívum (.tar.gz)
Can't render this file because it contains an unexpected character in line 11 and column 16.
+1
View File
@@ -157,6 +157,7 @@
Disk image (.dmg) ディスクイメージ (.dmg)
Debian package (.deb) Debian パッケージ (.deb)
RPM package (.rpm) RPM パッケージ (.rpm)
Arch package (.pkg.tar.zst) Arch パッケージ (.pkg.tar.zst)
Flatpak bundle Flatpak バンドル
Tarball (.tar.gz) tarball (.tar.gz)
Can't render this file because it contains an unexpected character in line 14 and column 44.
+1
View File
@@ -156,6 +156,7 @@
Disk image (.dmg) Obraz dysku (.dmg)
Debian package (.deb) Pakiet Debiana (.deb)
RPM package (.rpm) Pakiet RPM (.rpm)
Arch package (.pkg.tar.zst) Pakiet Arch (.pkg.tar.zst)
Flatpak bundle Pakiet Flatpak
Tarball (.tar.gz) Archiwum (.tar.gz)
Can't render this file because it contains an unexpected character in line 11 and column 15.
+1
View File
@@ -143,6 +143,7 @@
Disk image (.dmg) Imagem de disco (.dmg)
Debian package (.deb) Pacote Debian (.deb)
RPM package (.rpm) Pacote RPM (.rpm)
Arch package (.pkg.tar.zst) Pacote Arch (.pkg.tar.zst)
Flatpak bundle Pacote Flatpak
Tarball (.tar.gz) Arquivo (.tar.gz)
1 # Brazilian Portuguese (pt-BR) catalog for ArtCraft Launcher.
143
144
145 # Contextual: each app's tagline, keyed by app id
146 # Contextual: each app's tagline, keyed by app id
147 photocraft
148 vectorcraft
149 filmcraft
+1
View File
@@ -153,6 +153,7 @@
Disk image (.dmg) Образ диска (.dmg)
Debian package (.deb) Пакет Debian (.deb)
RPM package (.rpm) Пакет RPM (.rpm)
Arch package (.pkg.tar.zst) Пакет Arch (.pkg.tar.zst)
Flatpak bundle Пакет Flatpak
Tarball (.tar.gz) Архив (.tar.gz)
Can't render this file because it contains an unexpected character in line 13 and column 44.
+10 -2
View File
@@ -515,8 +515,16 @@ fn message_templates_match_real_messages() {
#[test]
fn complete_catalogs_translate_package_formats() {
use artcraft_launcher_core::PackageKind;
let kinds =
[PackageKind::Msi, PackageKind::PortableZip, PackageKind::Dmg, PackageKind::Deb, PackageKind::Rpm, PackageKind::Flatpak, PackageKind::TarGz];
let kinds = [
PackageKind::Msi,
PackageKind::PortableZip,
PackageKind::Dmg,
PackageKind::Deb,
PackageKind::Rpm,
PackageKind::ArchPkg,
PackageKind::Flatpak,
PackageKind::TarGz,
];
for lang in Lang::all().filter(|l| l.complete_menus()) {
for kind in kinds {
assert!(super::has(lang, kind.label()), "{}: {}", lang.code(), kind.label());
+1
View File
@@ -288,6 +288,7 @@ figcraft Interface design and prototyping 界面设计与原型制作
Disk image (.dmg) 磁盘映像 (.dmg)
Debian package (.deb) Debian 软件包 (.deb)
RPM package (.rpm) RPM 软件包 (.rpm)
Arch package (.pkg.tar.zst) Arch 软件包 (.pkg.tar.zst)
Flatpak bundle Flatpak 包
Tarball (.tar.gz) 压缩包 (.tar.gz)
App details 应用详情
Can't render this file because it contains an unexpected character in line 4 and column 3.