fix(linux): only update release Arch packages and delegate AUR/repo packages (#586)

- Only consider release Arch packages (bundle ID starting with 'ai.storyteller.') eligible for launcher updates.
- Keep AUR packages (<app>, <app>-bin, <app>-git) directed to the user's AUR helper (yay/paru).
- Direct repository packages (e.g. Omarchy) to 'sudo pacman -Syu' and add translations across all 10 locales.
- Only self-update launcher if installed as the release Arch package ('ai.storyteller.artcraft-launcher'), preventing conflicts on AUR installs.
- Check pacman DB presence before assuming ArchPkg availability.
This commit is contained in:
Dylan M. Taylor
2026-10-18 08:35:47 -04:00
parent ad7f24a912
commit 4e533d6d7a
14 changed files with 116 additions and 24 deletions
+3 -2
View File
@@ -17,7 +17,8 @@ pub enum InstallSource {
Deb,
/// An RPM package.
Rpm,
/// An Arch Linux package (from the AUR: `<app>`, `<app>-bin` or `<app>-git`).
/// An Arch Linux package: the release's own (`ai.storyteller.<app>`), or `<app>`, `<app>-bin`
/// or `<app>-git` from the AUR or a repository.
Pacman,
/// A Flatpak.
Flatpak,
@@ -35,7 +36,7 @@ impl InstallSource {
Self::Msi => "Windows Installer",
Self::Deb => "Debian package",
Self::Rpm => "RPM package",
Self::Pacman => "Arch package (AUR)",
Self::Pacman => "Arch package",
Self::Flatpak => "Flatpak",
Self::AppImage => "AppImage",
Self::Binary => "System binary",
+1 -1
View File
@@ -327,7 +327,7 @@ pub mod linux {
}
/// The local package database of Arch's pacman.
const PACMAN_DB: &str = "/var/lib/pacman/local";
pub(crate) const PACMAN_DB: &str = "/var/lib/pacman/local";
/// True when `db` is a directory, i.e. this system really uses pacman.
pub(crate) fn pacman_db_present(db: &Path) -> bool {
+97 -20
View File
@@ -255,7 +255,6 @@ 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),
@@ -263,17 +262,20 @@ 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),
InstallSource::Pacman => Some(PackageKind::ArchPkg),
// The release's own Arch package is updated like a .deb. AUR builds are updated by the
// user's AUR helper (see `managed_elsewhere`); the launcher opens and removes them but
// never replaces them.
InstallSource::Pacman if is_release_arch_package(inst) => Some(PackageKind::ArchPkg),
InstallSource::Pacman => None,
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) || (*k == PackageKind::ArchPkg && can_pacman)) {
if let Some(k) = kind.filter(|k| available.contains(k)) {
return vec![k];
}
}
let mut kinds: Vec<PackageKind> =
preferred.iter().copied().filter(|k| available.contains(k) || (*k == PackageKind::ArchPkg && can_pacman)).collect();
let mut kinds: Vec<PackageKind> = preferred.iter().copied().filter(|k| available.contains(k)).collect();
for k in available {
if !kinds.contains(&k) {
kinds.push(k);
@@ -282,6 +284,14 @@ pub fn choose_kinds(existing: Option<&Installed>, record: Option<&Record>, prefe
kinds
}
/// Whether a pacman install is the release's own Arch package rather than an AUR build. The
/// release names it by the bundle id (`ai.storyteller.wordcraft`), which no AUR package uses
/// (they are `wordcraft`, `wordcraft-bin` and `wordcraft-git`), so an AUR helper never mistakes it
/// for one of its own and rebuilds it from source.
pub fn is_release_arch_package(installed: &Installed) -> bool {
installed.source == InstallSource::Pacman && installed.package.as_deref().is_some_and(|p| p.starts_with("ai.storyteller."))
}
/// Package formats this machine can install, best first.
pub fn available_kinds() -> Vec<PackageKind> {
#[cfg(target_os = "macos")]
@@ -335,29 +345,57 @@ fn published_checksum(release: &Release, asset: &Asset, http: &Http) -> Result<O
}
/// When `installed` is owned by something the launcher doesn't drive, what to tell the user
/// instead of installing a second copy. AUR packages are removed through pacman like any system
/// package, but updated by the user's AUR helper, which builds as the user and can't run behind
/// the password prompt.
/// instead of installing a second copy. Arch packages other than the release's own are removed
/// through pacman like any system package, but updated by whatever they came from: a system
/// update for a repository package (Omarchy's, Chaotic-AUR, ...), the user's AUR helper (which
/// builds as the user and can't run behind the password prompt) for anything else.
pub fn managed_elsewhere(app: &CraftApp, installed: &Installed, uninstall: bool) -> Option<String> {
match installed.source {
InstallSource::Pacman if !uninstall => {
InstallSource::Pacman if !uninstall && !is_release_arch_package(installed) => {
let pkg = installed.package.clone().unwrap_or_else(|| app.id.to_string());
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
}
Some(pacman_update_hint(app, &pkg, pacman_repo(&pkg).as_deref()))
}
InstallSource::Binary => Some(format!("{} was installed outside the launcher. Update or remove it the way it was installed.", app.name)),
_ => None,
}
}
/// How to update an Arch package the launcher doesn't own: `repo` is the sync repository it
/// comes from, or `None` for a foreign (AUR) package.
fn pacman_update_hint(app: &CraftApp, pkg: &str, repo: Option<&str>) -> String {
if let Some(repo) = repo {
return format!("{} was installed from the {repo} repository ({pkg}). Update it with sudo pacman -Syu", app.name);
}
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})"),
};
format!("{} was installed from the AUR ({pkg}). Update it with {cmd}", app.name)
}
/// The sync repository (`extra`, `omarchy`, ...) that carries the installed package `pkg`, or
/// `None` when no configured repository does (an AUR or locally built package) or pacman can't
/// be asked.
fn pacman_repo(pkg: &str) -> Option<String> {
let pacman = which("pacman")?;
// pacman translates its field names; the C locale keeps "Repository" parseable.
let out = process::command(pacman).args(["-Si", "--", pkg]).env("LC_ALL", "C").output().ok()?;
if !out.status.success() {
return None;
}
parse_pacman_repo(&String::from_utf8_lossy(&out.stdout))
}
/// The `Repository` field of `pacman -Si` output (the first one, when several repos carry it:
/// that is the one pacman installs from).
fn parse_pacman_repo(si: &str) -> Option<String> {
si.lines().find_map(|l| {
let (key, value) = l.split_once(':')?;
(key.trim() == "Repository").then(|| value.trim().to_string()).filter(|r| !r.is_empty())
})
}
/// Removes an installed copy. `phase` hears when the removal has to wait for another install;
/// `stop` cancels it while it waits.
pub fn uninstall(
@@ -710,7 +748,10 @@ 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();
let can_arch = root && native == Some(PackageKind::ArchPkg) && which("pacman").is_some();
let can_arch = root
&& native == Some(PackageKind::ArchPkg)
&& which("pacman").is_some()
&& crate::detect::linux::pacman_db_present(Path::new(crate::detect::linux::PACMAN_DB));
order_available_kinds(is_flatpak, is_appimage, flatpak, can_deb, can_rpm, can_arch)
}
@@ -2092,6 +2133,42 @@ mod tests {
let _ = inst;
}
#[test]
fn only_the_release_arch_package_is_updated_by_the_launcher() {
let pacman = |name: &str| Installed { version: None, source: InstallSource::Pacman, location: String::new(), package: Some(name.into()) };
assert!(is_release_arch_package(&pacman("ai.storyteller.wordcraft")));
for aur in ["wordcraft", "wordcraft-bin", "wordcraft-git"] {
assert!(!is_release_arch_package(&pacman(aur)), "{aur}");
}
let app = artcraft_launcher_core::CraftApp::by_id("wordcraft").unwrap();
assert_eq!(managed_elsewhere(app, &pacman("ai.storyteller.wordcraft"), false), None);
assert_eq!(managed_elsewhere(app, &pacman("wordcraft-bin"), true), None);
}
#[test]
fn arch_packages_from_a_repository_are_updated_with_the_system() {
let app = artcraft_launcher_core::CraftApp::by_id("wordcraft").unwrap();
assert_eq!(
pacman_update_hint(app, "wordcraft-bin", Some("omarchy")),
"WordCraft was installed from the omarchy repository (wordcraft-bin). Update it with sudo pacman -Syu"
);
assert!(pacman_update_hint(app, "wordcraft-bin", None).starts_with("WordCraft was installed from the AUR (wordcraft-bin)."));
let si = "Repository : omarchy\nName : wordcraft-bin\nVersion : 0.6.1-1\n";
assert_eq!(parse_pacman_repo(si).as_deref(), Some("omarchy"));
assert_eq!(parse_pacman_repo("Name : wordcraft-bin\n"), None);
assert_eq!(parse_pacman_repo("Repository :\n"), None);
}
#[test]
fn aur_packages_are_not_updated_as_arch_pkg() {
let pacman = |name: &str| Installed { version: None, source: InstallSource::Pacman, location: String::new(), package: Some(name.into()) };
let app = artcraft_launcher_core::CraftApp::by_id("filmcraft").unwrap();
let installed = pacman("filmcraft-bin");
assert!(managed_elsewhere(app, &installed, false).is_some());
assert!(!choose_kinds(Some(&installed), None, &[]).contains(&PackageKind::ArchPkg));
}
#[test]
fn install_refuses_to_downgrade_when_target_release_is_older() {
let dir = tempfile::tempdir().unwrap();
+5 -1
View File
@@ -151,7 +151,11 @@ 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() {
// The release's own Arch package (named by the bundle id). An AUR or repository package
// (`artcraft-launcher`, `artcraft-launcher-bin`) is left to pacman and the AUR helper.
if crate::detect::linux::pacman_db_present(Path::new(crate::detect::linux::PACMAN_DB))
&& process::capture("pacman", &["-Q", &LAUNCHER.bundle_id()], t).is_some()
{
return Some(Install::Package(PackageKind::ArchPkg, exe.to_path_buf()));
}
None
+1
View File
@@ -266,6 +266,7 @@ filter Installed Nainstalováno
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) Aplikace {name} byla nainstalována ve verzi {version} (toto vydání neobsahuje kontrolní součet, ověřena byla pouze velikost)
@msg {name} was removed Aplikace {name} byla odebrána
@msg {name} is running. Quit it, then try again. Aplikace {name} je spuštěná. Ukončete ji a zkuste to znovu.
@msg {name} was installed from the {repo} repository ({pkg}). Update it with sudo pacman -Syu Aplikace {name} byla nainstalována z repozitáře {repo} ({pkg}). Aktualizujte ji příkazem sudo pacman -Syu
@id app.install Nainstalovat
@id app.update Aktualizovat na {v}
@id app.open Otevřít
1 # Czech (cs) catalog for ArtCraft Launcher.
266 @msg
267 @msg
268 @id @msg
269 @id
270 @id
271 @id
272 @id
+1
View File
@@ -280,6 +280,7 @@ filter Installed Installiert
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} installiert: {version} (diese Version veröffentlicht keine Prüfsumme, daher wurde nur die Größe geprüft)
@msg {name} was removed {name} wurde entfernt
@msg {name} is running. Quit it, then try again. {name} läuft. Beende die App und versuche es erneut.
@msg {name} was installed from the {repo} repository ({pkg}). Update it with sudo pacman -Syu {name} wurde aus dem Repository {repo} installiert ({pkg}). Aktualisiere die App mit sudo pacman -Syu
# Command IDs for menu items (@id)
# Format: @id<TAB>command_id<TAB>translation
Can't render this file because it contains an unexpected character in line 11 and column 16.
+1
View File
@@ -279,6 +279,7 @@ filter Installed Instaladas
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} instalada: {version} (esta versión no publica la suma de comprobación, así que solo se comprobó su tamaño)
@msg {name} was removed Se quitó {name}
@msg {name} is running. Quit it, then try again. {name} está abierta. Ciérrala y vuelve a intentarlo.
@msg {name} was installed from the {repo} repository ({pkg}). Update it with sudo pacman -Syu {name} se instaló desde el repositorio {repo} ({pkg}). Actualízala con sudo pacman -Syu
@id app.install Instalar
@id app.update Actualizar a {v}
@id app.open Abrir
Can't render this file because it contains an unexpected character in line 5 and column 48.
+1
View File
@@ -278,6 +278,7 @@ filter Installed Installées
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} installé : {version} (cette version ne publie pas de somme de contrôle : seule sa taille a été vérifiée)
@msg {name} was removed {name} a été supprimé
@msg {name} is running. Quit it, then try again. {name} est en cours d'exécution. Quittez-le, puis réessayez.
@msg {name} was installed from the {repo} repository ({pkg}). Update it with sudo pacman -Syu {name} a été installé depuis le dépôt {repo} ({pkg}). Mettez-le à jour avec sudo pacman -Syu
# Command IDs for menu items (@id)
# Format: @id<TAB>command_id<TAB>translation
Can't render this file because it contains an unexpected character in line 5 and column 50.
+1
View File
@@ -294,6 +294,7 @@ filter Installed Telepítve
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} telepítve: {version} (ehhez a kiadáshoz nem adtak meg ellenőrzőösszeget, ezért csak a fájl méretét ellenőriztük)
@msg {name} was removed {name} eltávolítva
@msg {name} is running. Quit it, then try again. {name} fut. Zárd be, majd próbáld újra.
@msg {name} was installed from the {repo} repository ({pkg}). Update it with sudo pacman -Syu {name} a(z) {repo} tárolóból lett telepítve ({pkg}). Frissítsd ezzel: sudo pacman -Syu
# Command IDs for menu items (@id)
# Format: @id<TAB>command_id<TAB>translation
Can't render this file because it contains an unexpected character in line 11 and column 16.
+1
View File
@@ -296,6 +296,7 @@ filter Installed インストール済み
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} をインストールしました: {version}(このリリースはチェックサムを公開していないため、サイズのみ確認しました)
@msg {name} was removed {name} をアンインストールしました
@msg {name} is running. Quit it, then try again. {name} が実行中です。終了してから再試行してください。
@msg {name} was installed from the {repo} repository ({pkg}). Update it with sudo pacman -Syu {name} は {repo} リポジトリからインストールされています ({pkg})。sudo pacman -Syu で更新してください。
# Command IDs for menu items (@id)
# Format: @id<TAB>command_id<TAB>translation
Can't render this file because it contains an unexpected character in line 14 and column 44.
+1
View File
@@ -295,6 +295,7 @@ filter Installed Zainstalowane
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) Zainstalowano {name} w wersji {version} (to wydanie nie zawiera sumy kontrolnej, więc sprawdzono tylko rozmiar)
@msg {name} was removed Usunięto {name}
@msg {name} is running. Quit it, then try again. {name} jest uruchomiona. Zamknij ją i spróbuj ponownie.
@msg {name} was installed from the {repo} repository ({pkg}). Update it with sudo pacman -Syu {name} została zainstalowana z repozytorium {repo} ({pkg}). Zaktualizuj ją poleceniem sudo pacman -Syu
# Command IDs for menu items (@id)
# Format: @id<TAB>command_id<TAB>translation
Can't render this file because it contains an unexpected character in line 11 and column 15.
+1
View File
@@ -268,6 +268,7 @@ filter Installed Instalados
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} instalado: {version} (este lançamento não publica soma de verificação, então apenas o tamanho foi verificado)
@msg {name} was removed {name} foi removido
@msg {name} is running. Quit it, then try again. {name} está em execução. Feche-o e tente novamente.
@msg {name} was installed from the {repo} repository ({pkg}). Update it with sudo pacman -Syu {name} foi instalado pelo repositório {repo} ({pkg}). Atualize-o com sudo pacman -Syu
# Command IDs for menu items (@id)
# Format: @id<TAB>command_id<TAB>translation
Can't render this file because it contains an unexpected character in line 252 and column 66.
+1
View File
@@ -277,6 +277,7 @@ filter Installed Установлены
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) Приложение {name} установлено: {version} (в этом релизе нет контрольных сумм, поэтому проверен только размер)
@msg {name} was removed Приложение {name} удалено
@msg {name} is running. Quit it, then try again. Приложение {name} запущено. Закройте его и попробуйте снова.
@msg {name} was installed from the {repo} repository ({pkg}). Update it with sudo pacman -Syu Приложение {name} установлено из репозитория {repo} ({pkg}). Обновите его командой sudo pacman -Syu
# Command IDs for menu items (@id)
# Format: @id<TAB>command_id<TAB>translation
Can't render this file because it contains an unexpected character in line 13 and column 44.
+1
View File
@@ -314,6 +314,7 @@ figcraft Interface design and prototyping 界面设计与原型制作
@msg {name} installed: {version} (this release publishes no checksum, so only its size was checked) {name} 已安装:{version}(此版本未发布校验和,因此只检查了文件大小)
@msg {name} was removed {name} 已卸载
@msg {name} is running. Quit it, then try again. {name} 正在运行。请先退出,然后重试。
@msg {name} was installed from the {repo} repository ({pkg}). Update it with sudo pacman -Syu {name} 是从 {repo} 软件仓库安装的({pkg})。请使用 sudo pacman -Syu 更新。
Install folder 安装文件夹
Default install folder 默认安装文件夹
System default 系统默认
Can't render this file because it contains an unexpected character in line 4 and column 3.