collective

Browse / github.com/storytold/craft-launcher / patch

Show the install format beside every app

Show the install format beside every app Shows how each app arrived, in grid and table: installed apps show their real format, not-installed ones show what Install would download. - Grid cards and the details hero show a format tag beside the status pill, tinted with its tone. - The table gains a dedicated Format column (translated header, not sortable). - Installed labels come from detection (record kind, else source); planned labels come from the backend installable formats plus the user preference, first published asset wins - so a preference the machine cannot install can never leak into labels. - No new translatable literals ex

Status
open
Score
0.00
Adopters
0 other operators
Author
PanDuroDev (imported from pull request #490, open)
Base
3fd2d56999fa00a7b90f159cb686a5fc64347e7e
Size
+285 −29 in 31 files
Review
pending
Shared
2026-10-28
Id
319d306ae5168450a6c2f927

How it went

No other operator has reported using it yet.

Diff

raw · apps/artcraft-launcher-web/src/web.rs apps/artcraft-launcher/src/main.rs crates/core/src/protocol.rs crates/core/src/status.rs crates/native/src/backend.rs crates/native/src/detect.rs crates/native/src/install.rs crates/native/src/self_update.rs crates/ui-egui/src/details.rs crates/ui-egui/src/i18n/cs.tsv crates/ui-egui/src/i18n/de.tsv crates/ui-egui/src/i18n/es.tsv crates/ui-egui/src/i18n/fr.tsv crates/ui-egui/src/i18n/hu.tsv crates/ui-egui/src/i18n/ja.tsv crates/ui-egui/src/i18n/pl.tsv crates/ui-egui/src/i18n/pt-BR.tsv crates/ui-egui/src/i18n/ru.tsv crates/ui-egui/src/i18n/zh-CN.tsv crates/ui-egui/src/model.rs crates/ui-egui/src/views.rs crates/ui-egui/tests/chinese.rs crates/ui-egui/tests/french.rs crates/ui-egui/tests/german.rs crates/ui-egui/tests/hungarian.rs crates/ui-egui/tests/japanese.rs crates/ui-egui/tests/polish.rs crates/ui-egui/tests/portuguese.rs crates/ui-egui/tests/russian.rs crates/ui-egui/tests/spanish.rs crates/ui-egui/tests/ui.rs

diff --git a/apps/artcraft-launcher-web/src/web.rs b/apps/artcraft-launcher-web/src/web.rs--- a/apps/artcraft-launcher-web/src/web.rs+++ b/apps/artcraft-launcher-web/src/web.rs@@ -329,7 +329,7 @@ impl Backend for WebBackend {     }      fn capabilities(&self) -> Capabilities {-        Capabilities { manage_apps: false, reveal_label: "", self_update: false }+        Capabilities { manage_apps: false, reveal_label: "", self_update: false, install_kinds: Vec::new() }     }      fn platform(&self) -> Option<Platform> {diff --git a/apps/artcraft-launcher/src/main.rs b/apps/artcraft-launcher/src/main.rs--- a/apps/artcraft-launcher/src/main.rs+++ b/apps/artcraft-launcher/src/main.rs@@ -768,7 +768,7 @@ mod tests {             std::mem::take(&mut *self.0.news.lock().unwrap_or_else(PoisonError::into_inner))         }         fn capabilities(&self) -> Capabilities {-            Capabilities { manage_apps: true, reveal_label: "Show in Files", self_update: false }+            Capabilities { manage_apps: true, reveal_label: "Show in Files", self_update: false, install_kinds: Vec::new() }         }         fn platform(&self) -> Option<artcraft_launcher_core::Platform> {             Nonediff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs--- a/crates/core/src/protocol.rs+++ b/crates/core/src/protocol.rs@@ -211,7 +211,7 @@ pub enum Event { }  /// What a backend can do on this platform.-#[derive(Clone, Copy, Debug, PartialEq, Eq)]+#[derive(Clone, Debug, PartialEq, Eq)] pub struct Capabilities {     /// Install, update, remove and launch apps (desktop). Without it the UI offers downloads.     pub manage_apps: bool,@@ -220,6 +220,9 @@ pub struct Capabilities {     /// The launcher can update itself in place and restart (an installed build, not a dev build or     /// a Flatpak). Without it the UI offers the download instead.     pub self_update: bool,+    /// Package formats this backend can install, best first (probed once). Empty means+    /// unknown: the UI falls back to the platform's download list.+    pub install_kinds: Vec<PackageKind>, }  /// The UI's view of a backend.diff --git a/crates/core/src/status.rs b/crates/core/src/status.rs--- a/crates/core/src/status.rs+++ b/crates/core/src/status.rs@@ -2,6 +2,7 @@  use serde::{Deserialize, Serialize}; +use crate::platform::PackageKind; use crate::version::{self, Version};  /// How an installed copy got onto the machine, which decides how to update and remove it.@@ -47,6 +48,21 @@ impl InstallSource {     pub fn launcher_can_update(self) -> bool {         matches!(self, Self::Launcher | Self::MacApp | Self::Msi | Self::AppImage)     }++    /// The release package format this source corresponds to, if any: a launcher-managed+    /// copy reports its record's kind instead, and a system package or stray binary has+    /// no release asset behind it.+    pub fn package_kind(self) -> Option<PackageKind> {+        match self {+            Self::Msi => Some(PackageKind::Msi),+            Self::Deb => Some(PackageKind::Deb),+            Self::Rpm => Some(PackageKind::Rpm),+            Self::Flatpak => Some(PackageKind::Flatpak),+            Self::AppImage => Some(PackageKind::AppImage),+            Self::MacApp => Some(PackageKind::Dmg),+            Self::Launcher | Self::Pacman | Self::Binary => None,+        }+    } }  /// An installed copy of an app.@@ -60,6 +76,10 @@ pub struct Installed {     /// The package that installed it, for package-manager installs (`photocraft-bin`).     #[serde(default)]     pub package: Option<String>,+    /// The package format, when the copy came from a release asset (`None` for copies+    /// found outside any install the launcher knows: system binaries, AUR packages).+    #[serde(default)]+    pub kind: Option<PackageKind>, }  /// The status shown on an app's card or row.@@ -123,7 +143,7 @@ mod tests {     use super::*;      fn inst(v: &str) -> Installed {-        Installed { version: version::parse(v), source: InstallSource::Launcher, location: String::new(), package: None }+        Installed { version: version::parse(v), source: InstallSource::Launcher, location: String::new(), package: None, kind: None }     }      #[test]@@ -142,4 +162,18 @@ mod tests {         assert_eq!(status(Some(&inst("garbage")), found(true)), AppStatus::Installed);         assert!(AppStatus::UpdateAvailable.is_installed() && !AppStatus::Available.is_installed());     }++    #[test]+    fn sources_map_to_their_release_format() {+        use InstallSource::*;+        assert_eq!(Msi.package_kind(), Some(PackageKind::Msi));+        assert_eq!(Deb.package_kind(), Some(PackageKind::Deb));+        assert_eq!(Rpm.package_kind(), Some(PackageKind::Rpm));+        assert_eq!(Flatpak.package_kind(), Some(PackageKind::Flatpak));+        assert_eq!(AppImage.package_kind(), Some(PackageKind::AppImage));+        assert_eq!(MacApp.package_kind(), Some(PackageKind::Dmg));+        assert_eq!(Launcher.package_kind(), None, "the record decides");+        assert_eq!(Pacman.package_kind(), None, "no release asset behind it");+        assert_eq!(Binary.package_kind(), None, "no release asset behind it");+    } }diff --git a/crates/native/src/backend.rs b/crates/native/src/backend.rs--- a/crates/native/src/backend.rs+++ b/crates/native/src/backend.rs@@ -969,7 +969,7 @@ impl Backend for NativeBackend {             Some(Os::Windows) => "Show in Explorer",             _ => "Show in Files",         };-        Capabilities { manage_apps: true, reveal_label, self_update: self.self_install.is_some() }+        Capabilities { manage_apps: true, reveal_label, self_update: self.self_install.is_some(), install_kinds: crate::install::available_kinds() }     }      fn platform(&self) -> Option<Platform> {@@ -1313,7 +1313,7 @@ mod tests {     }      fn copy(v: &str) -> Installed {-        Installed { version: version::parse(v), source: InstallSource::Deb, location: String::new(), package: None }+        Installed { version: version::parse(v), source: InstallSource::Deb, location: String::new(), package: None, kind: None }     }      #[test]diff --git a/crates/native/src/detect.rs b/crates/native/src/detect.rs--- a/crates/native/src/detect.rs+++ b/crates/native/src/detect.rs@@ -67,6 +67,7 @@ fn from_manifest(app: &CraftApp, manifest: &Manifest) -> Option<Installed> {         source: InstallSource::Launcher,         location: rec.path.display().to_string(),         package: None,+        kind: Some(rec.kind),     }) } @@ -169,7 +170,13 @@ pub mod macos {         let full_key = format!("{}Version", app.name);         let text =             [full_key.as_str(), "CFBundleShortVersionString", "CFBundleVersion"].iter().find_map(|k| dict.get(k).and_then(plist::Value::as_string));-        Some(Installed { version: text.and_then(version::parse), source: InstallSource::MacApp, location: path.display().to_string(), package: None })+        Some(Installed {+            version: text.and_then(version::parse),+            source: InstallSource::MacApp,+            location: path.display().to_string(),+            package: None,+            kind: InstallSource::MacApp.package_kind(),+        })     } } @@ -249,6 +256,7 @@ pub mod windows {             source: InstallSource::Msi,             location: m.exe.map_or_else(|| app.name.to_string(), |p| p.display().to_string()),             package: Some(m.product_code),+            kind: InstallSource::Msi.package_kind(),         })     } }@@ -374,12 +382,24 @@ pub mod linux {      pub fn detect(app: &CraftApp, snapshot: &Snapshot) -> Option<Installed> {         if let Some((path, version)) = appimages(app).into_iter().next() {-            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,+                kind: InstallSource::AppImage.package_kind(),+            });         }         let order = [InstallSource::Deb, InstallSource::Rpm, InstallSource::Pacman, InstallSource::Flatpak];         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()) });+            return Some(Installed {+                version: packages::version_of(p),+                source: p.manager,+                location,+                package: Some(p.name.clone()),+                kind: p.manager.package_kind(),+            });         }         detect_system_install(app)     }@@ -390,7 +410,13 @@ pub mod linux {     /// to whatever installed it, since it can't tell which package manager that was.     fn detect_system_install(app: &CraftApp) -> Option<Installed> {         let bin = find_binary(app.id)?;-        Some(Installed { version: binary_version(&bin, app), source: InstallSource::Binary, location: bin.display().to_string(), package: None })+        Some(Installed {+            version: binary_version(&bin, app),+            source: InstallSource::Binary,+            location: bin.display().to_string(),+            package: None,+            kind: InstallSource::Binary.package_kind(),+        })     }      fn find_binary(id: &str) -> Option<PathBuf> {@@ -417,7 +443,13 @@ pub mod freebsd {         if !bin.exists() {             return None;         }-        Some(Installed { version: binary_version(&bin, app), source: InstallSource::Binary, location: bin.display().to_string(), package: None })+        Some(Installed {+            version: binary_version(&bin, app),+            source: InstallSource::Binary,+            location: bin.display().to_string(),+            package: None,+            kind: InstallSource::Binary.package_kind(),+        })     }      /// `<app> --version` prints `<app> 0.5.0 (sha, date)`.@@ -525,6 +557,24 @@ mod tests {         assert!(strip_cli_prefix("photocraft-cli 1.0.0", "photocraft").is_none());     } +    #[test]+    fn manifest_records_carry_their_package_format() {+        use crate::manifest::{Manifest, Record};+        use artcraft_launcher_core::PackageKind;+        let dir = tempfile::tempdir().unwrap();+        let exe = dir.path().join("wordcraft.exe");+        std::fs::write(&exe, "x").unwrap();+        let app = CraftApp::by_id("wordcraft").unwrap();+        let mut manifest = Manifest::default();+        manifest.apps.insert(+            app.id.into(),+            Record { version: "0.5.0".into(), kind: PackageKind::PortableZip, path: exe, installed_at: 0, files: Vec::new(), needs_root: false },+        );+        let found = from_manifest(app, &manifest).unwrap();+        assert_eq!(found.source, InstallSource::Launcher);+        assert_eq!(found.kind, Some(PackageKind::PortableZip));+    }+     #[cfg(target_os = "linux")]     #[test]     fn finds_appimages_in_every_folder_newest_first() {diff --git a/crates/native/src/install.rs b/crates/native/src/install.rs--- a/crates/native/src/install.rs+++ b/crates/native/src/install.rs@@ -1645,7 +1645,7 @@ mod tests {      #[test]     fn updates_keep_the_installed_format() {-        let inst = |source| Installed { version: None, source, location: String::new(), package: None };+        let inst = |source| Installed { version: None, source, location: String::new(), package: None, kind: None };         let available = available_kinds();         if let Some(first) = available.first() {             assert_eq!(choose_kinds(None, None, &[]).first(), Some(first));@@ -1769,7 +1769,8 @@ mod tests {             let dir = tempfile::tempdir().unwrap();             let apps = dir.path().join("Applications");             let current = bundle(&apps, "0.5.0");-            let existing = Installed { version: None, source: InstallSource::MacApp, location: current.display().to_string(), package: None };+            let existing =+                Installed { version: None, source: InstallSource::MacApp, location: current.display().to_string(), package: None, kind: None };             let paths = Paths::under(dir.path());             let image = dmg(dir.path(), "0.6.0"); @@ -1799,7 +1800,8 @@ mod tests {             let dir = tempfile::tempdir().unwrap();             let apps = dir.path().join("Applications");             let current = bundle(&apps, "0.5.0");-            let existing = Installed { version: None, source: InstallSource::MacApp, location: current.display().to_string(), package: None };+            let existing =+                Installed { version: None, source: InstallSource::MacApp, location: current.display().to_string(), package: None, kind: None };             let image = dmg(dir.path(), "0.6.0");             let release = Release { tag: "v0.6.0".into(), version: version::parse("0.6.0"), ..Release::default() }; diff --git a/crates/native/src/self_update.rs b/crates/native/src/self_update.rs--- a/crates/native/src/self_update.rs+++ b/crates/native/src/self_update.rs@@ -180,7 +180,8 @@ pub fn update(     match install {         #[cfg(target_os = "macos")]         Install::MacApp(bundle) => {-            let existing = Installed { version: None, source: InstallSource::MacApp, location: bundle.display().to_string(), package: None };+            let existing =+                Installed { version: None, source: InstallSource::MacApp, location: bundle.display().to_string(), package: None, kind: None };             let placed = install::macos::install_dmg(&LAUNCHER, &file, Some(&existing), paths, &install::macos::Expect::release(release));             let _ = std::fs::remove_file(&file);             let placed = placed?;@@ -368,7 +369,7 @@ mod tests {         assert!(ok, "hdiutil create failed");         drop(one_at_a_time);         let current = apps.join("ArtCraft Launcher.app");-        let existing = Installed { version: None, source: InstallSource::MacApp, location: current.display().to_string(), package: None };+        let existing = Installed { version: None, source: InstallSource::MacApp, location: current.display().to_string(), package: None, kind: None };         // An unsigned test bundle: the signature check has its own tests in `install`.         let expect = install::macos::Expect { team: None, version: None };         let placed = install::macos::install_dmg(&LAUNCHER, &dmg, Some(&existing), &Paths::under(dir.path()), &expect).unwrap();diff --git a/crates/ui-egui/src/details.rs b/crates/ui-egui/src/details.rs--- a/crates/ui-egui/src/details.rs+++ b/crates/ui-egui/src/details.rs@@ -230,6 +230,17 @@ impl LauncherUi {                     if !version.trim().is_empty() {                         ui.label(RichText::new(version).size(12.5).color(t.muted));                     }+                    // What it is, like the grid and table badges.+                    if let Some(format) = match &entry.installed {+                        Some(_) => entry.installed_kind_label(),+                        None => entry.planned_kind_label(self.platform, &self.settings.preferred_kinds, &self.caps.install_kinds),+                    } {+                        ui.add_space(4.0);+                        ui.horizontal(|ui| {+                            let (_, tone) = crate::model::entry_status_text(entry, &status, self.caps.manage_apps);+                            crate::widgets::badge(ui, format, crate::widgets::tone_color(t, tone));+                        });+                    }                 });             });             ui.add_space(14.0);diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv--- a/crates/ui-egui/src/i18n/cs.tsv+++ b/crates/ui-egui/src/i18n/cs.tsv@@ -237,6 +237,7 @@ column	App	Aplikace column	Installed	Nainstalováno column	Latest	Nejnovější column	Released	Vydáno+column	Format	Formát filter	Installed	Nainstalováno 	App details	Podrobnosti o aplikaci 	Close (Esc)	Zavřít (Esc)diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv--- a/crates/ui-egui/src/i18n/de.tsv+++ b/crates/ui-egui/src/i18n/de.tsv@@ -258,6 +258,7 @@ column	App	App column	Installed	Lokal column	Latest	Neueste column	Released	Datum+column	Format	Format  # Contextual: the filter tab names a group (the status pill says "Installed") filter	Installed	Installiertdiff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv--- a/crates/ui-egui/src/i18n/es.tsv+++ b/crates/ui-egui/src/i18n/es.tsv@@ -250,6 +250,7 @@ column	App	Aplicación column	Installed	Instalada column	Latest	Última column	Released	Publicada+column	Format	Formato filter	Installed	Instaladas 	App details	Detalles de la app 	Close (Esc)	Cerrar (Esc)diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv--- a/crates/ui-egui/src/i18n/fr.tsv+++ b/crates/ui-egui/src/i18n/fr.tsv@@ -256,6 +256,7 @@ column	App	Application column	Installed	Installée column	Latest	Dernière column	Released	Date de publication+column	Format	Format  # Contextual: the filter tab names a group (the status pill says "Installed") filter	Installed	Installéesdiff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv--- a/crates/ui-egui/src/i18n/hu.tsv+++ b/crates/ui-egui/src/i18n/hu.tsv@@ -259,6 +259,7 @@ column	App	Alkalmazás column	Installed	Telepített column	Latest	Legújabb column	Released	Dátum+column	Format	Formátum  # Contextual: the filter tab names a group (the status pill says "Installed") filter	Installed	Telepítvediff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv--- a/crates/ui-egui/src/i18n/ja.tsv+++ b/crates/ui-egui/src/i18n/ja.tsv@@ -261,6 +261,7 @@ column	App	アプリ column	Installed	インストール済みバージョン column	Latest	最新バージョン column	Released	公開日+column	Format	形式  # Contextual: the filter tab names a group (the status pill says "Installed") filter	Installed	インストール済みdiff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv--- a/crates/ui-egui/src/i18n/pl.tsv+++ b/crates/ui-egui/src/i18n/pl.tsv@@ -260,6 +260,7 @@ column	App	Aplikacja column	Installed	Zainstalowana column	Latest	Najnowsza column	Released	Data wydania+column	Format	Format  # Contextual: the filter tab names a group (the status pill says "Installed") filter	Installed	Zainstalowanediff --git a/crates/ui-egui/src/i18n/pt-BR.tsv b/crates/ui-egui/src/i18n/pt-BR.tsv--- a/crates/ui-egui/src/i18n/pt-BR.tsv+++ b/crates/ui-egui/src/i18n/pt-BR.tsv@@ -246,6 +246,7 @@ column	App	Aplicativo column	Installed	Instalada column	Latest	Última column	Released	Lançamento+column	Format	Formato  # Contextual: the filter tab names a group (the status pill says "Installed") filter	Installed	Instaladosdiff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv--- a/crates/ui-egui/src/i18n/ru.tsv+++ b/crates/ui-egui/src/i18n/ru.tsv@@ -255,6 +255,7 @@ column	App	Приложение column	Installed	Текущая column	Latest	Последняя column	Released	Дата релиза+column	Format	Формат  # Contextual: the filter tab names a group (the status pill says "Installed") filter	Installed	Установленыdiff --git a/crates/ui-egui/src/i18n/zh-CN.tsv b/crates/ui-egui/src/i18n/zh-CN.tsv--- a/crates/ui-egui/src/i18n/zh-CN.tsv+++ b/crates/ui-egui/src/i18n/zh-CN.tsv@@ -173,6 +173,7 @@ column	App	应用 column	Installed	已安装版本 column	Latest	最新版本 column	Released	发布日期+column	Format	格式 filter	Installed	已安装 	{name} details	{name} 详情 @plural	{n} min ago|{n} min ago	{n} 分钟前diff --git a/crates/ui-egui/src/model.rs b/crates/ui-egui/src/model.rs--- a/crates/ui-egui/src/model.rs+++ b/crates/ui-egui/src/model.rs@@ -1,6 +1,6 @@ //! What the UI knows about each app. -use artcraft_launcher_core::platform::{Platform, pick_asset};+use artcraft_launcher_core::platform::{PackageKind, Platform, pick_asset}; use artcraft_launcher_core::protocol::{JobPhase, LatestRelease, PausedDownload}; use artcraft_launcher_core::release::Release; use artcraft_launcher_core::status::{self, AppStatus, Latest};@@ -60,6 +60,27 @@ impl AppEntry {         self.installed.as_ref().map(|i| i.version.as_ref().map(ToString::to_string).unwrap_or_else(|| t("Installed").to_string()))     } +    /// How the installed copy arrived, for the badge: the release format when known,+    /// else where it was found (`None` when nothing is installed).+    pub fn installed_kind_label(&self) -> Option<&'static str> {+        let installed = self.installed.as_ref()?;+        Some(installed.kind.map(|k| k.label()).unwrap_or(installed.source.label()))+    }++    /// What `Install` would download now: the preferred formats the backend can install,+    /// then what it can install, first published asset wins. Empty `installable` (an old+    /// backend) falls back to the platform's download list. A label only; the backend+    /// decides the real one at install time.+    pub fn planned_kind_label(&self, platform: Option<Platform>, preferred: &[PackageKind], installable: &[PackageKind]) -> Option<&'static str> {+        let (release, platform) = (self.release()?, platform?);+        let stems: Vec<&str> = self.app.stems().collect();+        let base: &[PackageKind] = if installable.is_empty() { platform.download_kinds() } else { installable };+        let mut kinds: Vec<PackageKind> = preferred.iter().copied().filter(|k| base.contains(k)).collect();+        let rest: Vec<PackageKind> = base.iter().copied().filter(|k| !kinds.contains(k)).collect();+        kinds.extend(rest);+        kinds.into_iter().find_map(|k| pick_asset(release, &stems, platform, &[k]).map(|(_, kind)| kind.label()))+    }+     pub fn latest_label(&self) -> Option<String> {         self.release().map(Release::version_label)     }diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs--- a/crates/ui-egui/src/views.rs+++ b/crates/ui-egui/src/views.rs@@ -480,6 +480,15 @@ impl LauncherUi {             if let Some(f) = entry.failure() {                 r.on_hover_text(fmt(tl!("Couldn't check for updates: {f}"), &[("f", f)]));             }+            // What it is: the installed format, else what Install would download.+            let format = match &entry.installed {+                Some(_) => entry.installed_kind_label(),+                None => entry.planned_kind_label(self.platform, &self.settings.preferred_kinds, &self.caps.install_kinds),+            };+            if let Some(format) = format {+                ui.add_space(6.0);+                widgets::badge(ui, format, widgets::tone_color(&t, tone));+            }         });         ui.add_space(4.0);         ui.label(RichText::new(version_line(&entry, &status)).size(12.5).color(t.muted));@@ -516,6 +525,7 @@ impl LauncherUi {                 .cell_layout(egui::Layout::left_to_right(egui::Align::Center))                 .column(Column::remainder().at_least(220.0).clip(true))                 .column(Column::exact(176.0))+                .column(Column::exact(150.0))                 .column(Column::exact(84.0))                 .column(Column::exact(84.0))                 .column(Column::exact(104.0))@@ -524,6 +534,7 @@ impl LauncherUi {                     for (key, label) in [                         (Some(SortKey::Name), tl!("App")),                         (Some(SortKey::Status), tl!("Status")),+                        (None, tr_ctx(current(), "column", "Format")),                         (Some(SortKey::Installed), tr_ctx(current(), "column", "Installed")),                         (Some(SortKey::Latest), tr_ctx(current(), "column", "Latest")),                         (Some(SortKey::Released), tr_ctx(current(), "column", "Released")),@@ -533,7 +544,17 @@ impl LauncherUi {                             if key == Some(SortKey::Name) {                                 rule(ui);                             }-                            let Some(k) = key else { return };+                            let Some(k) = key else {+                                // A titled column without sorting: paint the label and stop.+                                if !label.is_empty() {+                                    let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::hover());+                                    let galley = widgets::hud_galley(ui, label, t.faint);+                                    let at = egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0);+                                    ui.painter().galley(at, galley, t.faint);+                                    resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, label));+                                }+                                return;+                            };                             let active = k == self.settings.sort;                             let tip = if active { tl!("Click to reverse the order") } else { tl!("Click to sort by this column") };                             // Measure first so the whole header cell (label + arrow) is the target.@@ -617,6 +638,22 @@ impl LauncherUi {                                     r.on_hover_text(fmt(tl!("Couldn't check for updates: {f}"), &[("f", f)]));                                 }                             });+                            row.col(|ui| {+                                // What it is: the installed format, else what Install would download.+                                let format = match &entry.installed {+                                    Some(_) => entry.installed_kind_label(),+                                    None => entry.planned_kind_label(self.platform, &self.settings.preferred_kinds, &self.caps.install_kinds),+                                };+                                match format {+                                    Some(format) => {+                                        let (_, tone) = model::entry_status_text(&entry, &status, self.caps.manage_apps);+                                        widgets::badge(ui, format, widgets::tone_color(&t, tone));+                                    }+                                    None => {+                                        ui.label(RichText::new("—").color(t.faint));+                                    }+                                }+                            });                             row.col(|ui| {                                 let installed = entry.installed_label();                                 let color = if installed.is_some() { t.ink } else { t.faint };diff --git a/crates/ui-egui/tests/chinese.rs b/crates/ui-egui/tests/chinese.rs--- a/crates/ui-egui/tests/chinese.rs+++ b/crates/ui-egui/tests/chinese.rs@@ -32,7 +32,12 @@ impl Backend for Fake {         std::mem::take(&mut self.events)     }     fn capabilities(&self) -> Capabilities {-        Capabilities { manage_apps: self.manage, reveal_label: "Show in Finder", self_update: self.manage }+        Capabilities {+            manage_apps: self.manage,+            reveal_label: "Show in Finder",+            self_update: self.manage,+            install_kinds: vec![artcraft_launcher_core::PackageKind::Dmg],+        }     }     fn platform(&self) -> Option<Platform> {         Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })@@ -95,6 +100,7 @@ fn events() -> Vec<Event> {             source: InstallSource::MacApp,             location: format!("/Applications/{}.app", app.name),             package: None,+            kind: None,         });         ev.push(Event::Detected { app: app.id.into(), installed: inst });     }diff --git a/crates/ui-egui/tests/french.rs b/crates/ui-egui/tests/french.rs--- a/crates/ui-egui/tests/french.rs+++ b/crates/ui-egui/tests/french.rs@@ -27,7 +27,12 @@ impl Backend for Fake {         std::mem::take(&mut self.events)     }     fn capabilities(&self) -> Capabilities {-        Capabilities { manage_apps: true, reveal_label: "Show in Finder", self_update: true }+        Capabilities {+            manage_apps: true,+            reveal_label: "Show in Finder",+            self_update: true,+            install_kinds: vec![artcraft_launcher_core::PackageKind::Dmg],+        }     }     fn platform(&self) -> Option<Platform> {         Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })@@ -69,6 +74,7 @@ fn events() -> Vec<Event> {             source: InstallSource::MacApp,             location: format!("/Applications/{}.app", app.name),             package: None,+            kind: None,         });         ev.push(Event::Detected { app: app.id.into(), installed });     }diff --git a/crates/ui-egui/tests/german.rs b/crates/ui-egui/tests/german.rs--- a/crates/ui-egui/tests/german.rs+++ b/crates/ui-egui/tests/german.rs@@ -31,7 +31,12 @@ impl Backend for Fake {         std::mem::take(&mut self.events)     }     fn capabilities(&self) -> Capabilities {-        Capabilities { manage_apps: self.manage, reveal_label: "Show in Finder", self_update: self.manage }+        Capabilities {+            manage_apps: self.manage,+            reveal_label: "Show in Finder",+            self_update: self.manage,+            install_kinds: vec![artcraft_launcher_core::PackageKind::Dmg],+        }     }     fn platform(&self) -> Option<Platform> {         Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })@@ -94,6 +99,7 @@ fn events() -> Vec<Event> {             source: InstallSource::MacApp,             location: format!("/Applications/{}.app", app.name),             package: None,+            kind: None,         });         ev.push(Event::Detected { app: app.id.into(), installed: inst });     }diff --git a/crates/ui-egui/tests/hungarian.rs b/crates/ui-egui/tests/hungarian.rs--- a/crates/ui-egui/tests/hungarian.rs+++ b/crates/ui-egui/tests/hungarian.rs@@ -28,7 +28,12 @@ impl Backend for Fake {         std::mem::take(&mut self.events)     }     fn capabilities(&self) -> Capabilities {-        Capabilities { manage_apps: true, reveal_label: "Show in Finder", self_update: true }+        Capabilities {+            manage_apps: true,+            reveal_label: "Show in Finder",+            self_update: true,+            install_kinds: vec![artcraft_launcher_core::PackageKind::Dmg],+        }     }     fn platform(&self) -> Option<Platform> {         Some(Platform { os: self.os, arch: Arch::Arm64 })@@ -77,6 +82,7 @@ fn events() -> Vec<Event> {             source: InstallSource::MacApp,             location: format!("/Applications/{}.app", app.name),             package: None,+            kind: None,         });         ev.push(Event::Detected { app: app.id.into(), installed });     }diff --git a/crates/ui-egui/tests/japanese.rs b/crates/ui-egui/tests/japanese.rs--- a/crates/ui-egui/tests/japanese.rs+++ b/crates/ui-egui/tests/japanese.rs@@ -32,7 +32,12 @@ impl Backend for Fake {         std::mem::take(&mut self.events)     }     fn capabilities(&self) -> Capabilities {-        Capabilities { manage_apps: self.manage, reveal_label: "Show in Finder", self_update: self.manage }+        Capabilities {+            manage_apps: self.manage,+            reveal_label: "Show in Finder",+            self_update: self.manage,+            install_kinds: vec![artcraft_launcher_core::PackageKind::Dmg],+        }     }     fn platform(&self) -> Option<Platform> {         Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })@@ -95,6 +100,7 @@ fn events() -> Vec<Event> {             source: InstallSource::MacApp,             location: format!("/Applications/{}.app", app.name),             package: None,+            kind: None,         });         ev.push(Event::Detected { app: app.id.into(), installed: inst });     }diff --git a/crates/ui-egui/tests/polish.rs b/crates/ui-egui/tests/polish.rs--- a/crates/ui-egui/tests/polish.rs+++ b/crates/ui-egui/tests/polish.rs@@ -27,7 +27,12 @@ impl Backend for Fake {         std::mem::take(&mut self.events)     }     fn capabilities(&self) -> Capabilities {-        Capabilities { manage_apps: true, reveal_label: "Show in Finder", self_update: true }+        Capabilities {+            manage_apps: true,+            reveal_label: "Show in Finder",+            self_update: true,+            install_kinds: vec![artcraft_launcher_core::PackageKind::Dmg],+        }     }     fn platform(&self) -> Option<Platform> {         Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })@@ -69,6 +74,7 @@ fn events() -> Vec<Event> {             source: InstallSource::MacApp,             location: format!("/Applications/{}.app", app.name),             package: None,+            kind: None,         });         ev.push(Event::Detected { app: app.id.into(), installed });     }diff --git a/crates/ui-egui/tests/portuguese.rs b/crates/ui-egui/tests/portuguese.rs--- a/crates/ui-egui/tests/portuguese.rs+++ b/crates/ui-egui/tests/portuguese.rs@@ -25,7 +25,12 @@ impl Backend for Fake {         std::mem::take(&mut self.events)     }     fn capabilities(&self) -> Capabilities {-        Capabilities { manage_apps: true, reveal_label: "Show in Explorer", self_update: true }+        Capabilities {+            manage_apps: true,+            reveal_label: "Show in Explorer",+            self_update: true,+            install_kinds: vec![artcraft_launcher_core::PackageKind::Dmg],+        }     }     fn platform(&self) -> Option<Platform> {         Some(Platform { os: Os::Windows, arch: Arch::X64 })@@ -67,6 +72,7 @@ fn events() -> Vec<Event> {             source: InstallSource::Msi,             location: format!("C:\\Program Files\\{}\\{}.exe", app.name, app.id),             package: None,+            kind: None,         });         ev.push(Event::Detected { app: app.id.into(), installed });     }diff --git a/crates/ui-egui/tests/russian.rs b/crates/ui-egui/tests/russian.rs--- a/crates/ui-egui/tests/russian.rs+++ b/crates/ui-egui/tests/russian.rs@@ -27,7 +27,12 @@ impl Backend for Fake {         std::mem::take(&mut self.events)     }     fn capabilities(&self) -> Capabilities {-        Capabilities { manage_apps: true, reveal_label: "Show in Finder", self_update: true }+        Capabilities {+            manage_apps: true,+            reveal_label: "Show in Finder",+            self_update: true,+            install_kinds: vec![artcraft_launcher_core::PackageKind::Dmg],+        }     }     fn platform(&self) -> Option<Platform> {         Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })@@ -69,6 +74,7 @@ fn events() -> Vec<Event> {             source: InstallSource::MacApp,             location: format!("/Applications/{}.app", app.name),             package: None,+            kind: None,         });         ev.push(Event::Detected { app: app.id.into(), installed });     }diff --git a/crates/ui-egui/tests/spanish.rs b/crates/ui-egui/tests/spanish.rs--- a/crates/ui-egui/tests/spanish.rs+++ b/crates/ui-egui/tests/spanish.rs@@ -27,7 +27,12 @@ impl Backend for Fake {         std::mem::take(&mut self.events)     }     fn capabilities(&self) -> Capabilities {-        Capabilities { manage_apps: true, reveal_label: "Show in Finder", self_update: true }+        Capabilities {+            manage_apps: true,+            reveal_label: "Show in Finder",+            self_update: true,+            install_kinds: vec![artcraft_launcher_core::PackageKind::Dmg],+        }     }     fn platform(&self) -> Option<Platform> {         Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })@@ -69,6 +74,7 @@ fn events() -> Vec<Event> {             source: InstallSource::MacApp,             location: format!("/Applications/{}.app", app.name),             package: None,+            kind: None,         });         ev.push(Event::Detected { app: app.id.into(), installed });     }diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs--- a/crates/ui-egui/tests/ui.rs+++ b/crates/ui-egui/tests/ui.rs@@ -13,7 +13,7 @@ use artcraft_launcher_core::protocol::{ }; use artcraft_launcher_core::release::{Asset, Release}; use artcraft_launcher_core::status::InstallSource;-use artcraft_launcher_core::{Arch, Installed, Os, Platform, SUITE, version};+use artcraft_launcher_core::{Arch, Installed, Os, PackageKind, Platform, SUITE, version}; use artcraft_launcher_ui_egui::settings::ViewMode; use artcraft_launcher_ui_egui::{Host, LauncherUi, Settings}; use egui_kittest::Harness;@@ -83,7 +83,12 @@ impl Backend for Fake {         out     }     fn capabilities(&self) -> Capabilities {-        Capabilities { manage_apps: self.manage, reveal_label: "Show in Finder", self_update: self.manage }+        Capabilities {+            manage_apps: self.manage,+            reveal_label: "Show in Finder",+            self_update: self.manage,+            install_kinds: vec![artcraft_launcher_core::PackageKind::Dmg],+        }     }     fn platform(&self) -> Option<Platform> {         Some(Platform { os: Os::MacOs, arch: Arch::Arm64 })@@ -146,6 +151,7 @@ fn events() -> Vec<Event> {             source: InstallSource::MacApp,             location: format!("/Applications/{}.app", app.name),             package: None,+            kind: None,         });         ev.push(Event::Detected { app: app.id.into(), installed: inst });     }@@ -304,6 +310,33 @@ fn grid_shows_every_status_and_installs_on_click() {     assert!(installs.contains(&"photocraft".to_string()) && installs.contains(&"effectcraft".to_string())); } +#[test]+fn cards_and_rows_name_the_install_format() {+    // Fixtures: MacApp installs of unknown format, dmg releases on this (mac) platform.+    for view in [ViewMode::Grid, ViewMode::Table] {+        let (mut h, _) = harness(view, egui::Theme::Dark, true);+        h.run_steps(8);+        // Installed: where the copy came from (the source, the record kind unknown).+        assert!(h.query_all_by_label_contains("Applications folder").count() >= 1);+        // Not installed: what Install would download (the release format for this platform).+        assert!(h.query_all_by_label_contains("Disk image (.dmg)").count() >= 1);+    }+}++#[test]+fn an_uninstallable_preference_never_labels_the_download() {+    // The user's exact bug: preferring a format this machine cannot install must not+    // leak into labels; the installable format still shows.+    let (mut h, _) = harness(ViewMode::Grid, egui::Theme::Dark, true);+    h.run_steps(2);+    if let Some(l) = h.state_mut().as_mut() {+        l.settings.preferred_kinds = vec![PackageKind::PortableZip];+    }+    h.run_steps(8);+    assert!(h.query_all_by_label_contains("Disk image (.dmg)").count() >= 1);+    assert_eq!(h.query_all_by_label_contains("Portable (.zip)").count(), 0);+}+ #[test] fn downloads_can_be_paused_resumed_and_discarded() {     let (mut h, sent) = harness(ViewMode::Grid, egui::Theme::Dark, true);