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
@@ -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> {@@ -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> { None@@ -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.@@ -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");+ } }@@ -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]@@ -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() {@@ -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() }; @@ -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();@@ -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);@@ -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)@@ -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 Installiert@@ -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)@@ -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ées@@ -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ítve@@ -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 インストール済み@@ -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 Zainstalowane@@ -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 Instalados@@ -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 Установлены@@ -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} 分钟前@@ -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) }@@ -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 };@@ -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 }); }@@ -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 }); }@@ -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 }); }@@ -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 }); }@@ -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 }); }@@ -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 }); }@@ -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 }); }@@ -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 }); }@@ -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 }); }@@ -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);