Browse / github.com/storytold/craft-launcher / patch
Windows: detect explicitly registered external apps
Windows: detect explicitly registered external apps Windows copies extracted from the official portable ZIPs into custom folders currently appear as “not installed,” even when their executables already exist. This adds an optional `external-apps.json` registration file: an app ID, absolute executable path and optional version let ArtCraft Launcher detect and open the existing copy. The file is read only on Windows, with a 64 KiB limit and executable-name validation. Launcher-owned records and MSI detection retain precedence. Without the file, detection and installation defaults are unchanged. Registered copies stay externally managed: they can
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- JamesTsetsekas (imported from pull request #373, open)
- Base
1579aa8c02acc5483f901109292b18c22d750559- Size
- +463 −20 in 8 files
- Review
- pending
- Shared
- 2026-10-28
- Id
8fb50abfc1ed5f0c8c516667
How it went
No other operator has reported using it yet.
Diff
raw · README.md crates/native/src/backend.rs crates/native/src/detect.rs crates/native/src/paths.rs crates/ui-egui/src/app.rs crates/ui-egui/src/model.rs crates/ui-egui/src/views.rs crates/ui-egui/tests/ui.rs
@@ -130,6 +130,36 @@ on macOS, `%LOCALAPPDATA%\ArtCraft Launcher` on Windows, `ArtCraftLauncherData` portable zip's executable) and keeps the previous two runs as `artcraft-launcher.1.log` and `artcraft-launcher.2.log`. `RUST_LOG` changes what it records (`debug`, `info,zbus=warn`, …). +### Windows apps in custom folders++If you unpack Craft apps yourself into another folder or drive, you can opt in to detecting+those copies by creating `%LOCALAPPDATA%\ArtCraft Launcher\external-apps.json`. For a portable+copy of ArtCraft Launcher, the file goes in `ArtCraftLauncherData\data\external-apps.json`+beside the launcher's executable instead.++```json+{+ "apps": {+ "photocraft": {+ "path": "D:\\CreativeApps\\PhotoCraft\\photocraft.exe",+ "version": "0.7.0"+ }+ }+}+```++Use the app's lowercase catalog id and an absolute path to its existing executable. The file+name must be one of that app's release names, such as `photocraft.exe` (`printcraft.exe` also+works for PdfCraft). `version` is optional; omit it if you do not know the installed version,+and keep it current when updating the app yourself. Restart ArtCraft Launcher after editing+the file.++These copies can be opened from the launcher, but are updated and removed with the tool or+method you used to install them. The launcher does not scan custom folders or run binaries to+discover versions. Its own installation records and Windows Installer entries take priority.+Without this optional file, detection is unchanged. Malformed files and files larger than+64 KiB are ignored; the log explains why. Remove an entry to stop detecting that copy.+ ## Downloads Download the build for your computer from the [latest release](https://github.com/storytold/craft-launcher/releases/latest), or browse [all releases](https://github.com/storytold/craft-launcher/releases). Every release has a `SHA256SUMS.txt` to check the files against. `<ver>` is the version number, for example `0.1.0`.@@ -182,7 +182,7 @@ impl NativeBackend { // Leftovers from interrupted installs or older versions of the launcher. A paused // download stays a month; a finished one kept after a failed install, a week. http::clean_downloads(&paths.downloads(), Duration::from_secs(30 * 86_400), Duration::from_secs(7 * 86_400));- let snapshot = crate::detect::Snapshot::take();+ let snapshot = crate::detect::Snapshot::take(&paths); let manifest = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner).clone(); for app in &SUITE { let found = crate::detect::detect(app, &manifest, &snapshot);@@ -364,7 +364,7 @@ impl NativeBackend { report_partial(app, &shared, &paths, platform, &options); // Look again: the result is whatever is on disk now. let manifest = shared.manifest.lock().unwrap_or_else(PoisonError::into_inner).clone();- let found = crate::detect::detect(app, &manifest, &crate::detect::Snapshot::take());+ let found = crate::detect::detect(app, &manifest, &crate::detect::Snapshot::take(&paths)); // One installer call can cover several apps, and fail after some of them went in. let latest = shared.latest.lock().unwrap_or_else(PoisonError::into_inner).get(app.id).cloned(); let result = match (result, latest) {@@ -1,8 +1,8 @@ //! Finding Craft Apps that are already installed, however they got there. //! //! - macOS: `<App>.app` in `/Applications` or `~/Applications`, read from its `Info.plist`.-//! - Windows: the Windows Installer's uninstall entries (`Learning Machines LLC`), and portable-//! copies the launcher unpacked.+//! - Windows: the Windows Installer's uninstall entries (`Learning Machines LLC`), portable+//! copies the launcher unpacked, and explicitly registered external executables. //! - Linux: AppImages in `~/Applications` (where the launcher and AppImageLauncher put them), //! `~/AppImages` (Gear Lever) and `~/.local/bin`, Debian/RPM packages, Flatpaks, and tarball //! installs.@@ -14,24 +14,37 @@ use artcraft_launcher_core::{CraftApp, InstallSource, Installed, version}; use crate::manifest::Manifest; -/// One look at the system, shared by every app's detection (package lists are read once).+/// One look at the system, shared by every app's detection (package lists and optional external+/// app registrations are read once). #[derive(Default)] pub struct Snapshot { #[allow(dead_code)] packages: Vec<crate::packages::Package>,+ #[cfg(windows)]+ external_apps: external_windows::Apps, } impl Snapshot {- pub fn take() -> Self {+ pub fn take(paths: &crate::paths::Paths) -> Self {+ let _ = paths; #[cfg(target_os = "linux")] { Self { packages: linux::packages() } }- #[cfg(not(target_os = "linux"))]+ #[cfg(windows)]+ {+ Self { external_apps: external_windows::Apps::load(&paths.external_apps()), ..Self::default() }+ }+ #[cfg(not(any(target_os = "linux", windows)))] { Self::default() } }++ #[cfg(windows)]+ fn external(&self, app: &CraftApp) -> Option<Installed> {+ self.external_apps.detect(app)+ } } /// What's installed of `app`, best source first.@@ -43,7 +56,7 @@ pub fn detect(app: &CraftApp, manifest: &Manifest, snapshot: &Snapshot) -> Optio #[cfg(target_os = "macos")] return macos::detect(app); #[cfg(windows)]- return windows::detect(app);+ return windows::detect(app).or_else(|| snapshot.external(app)); #[cfg(target_os = "linux")] return linux::detect(app, snapshot); #[cfg(target_os = "freebsd")]@@ -52,6 +65,80 @@ pub fn detect(app: &CraftApp, manifest: &Manifest, snapshot: &Snapshot) -> Optio None } +/// Explicitly selected executables remain owned by the user, separate from `installed.json`.+#[cfg(windows)]+mod external_windows {+ use std::collections::BTreeMap;+ use std::io::{ErrorKind, Read};++ use serde::Deserialize;++ use super::*;++ const MAX_BYTES: u64 = 64 * 1024;++ #[derive(Default, Deserialize)]+ pub(super) struct Apps {+ apps: BTreeMap<String, App>,+ }++ #[derive(Deserialize)]+ struct App {+ path: PathBuf,+ #[serde(default)]+ version: Option<String>,+ }++ impl Apps {+ pub(super) fn load(path: &Path) -> Self {+ match Self::read(path) {+ Ok(apps) => apps,+ Err(e) => {+ log::warn!("ignoring external-apps.json ({e}); check the file's format, size and permissions");+ Self::default()+ }+ }+ }++ fn read(path: &Path) -> Result<Self, String> {+ let file = match std::fs::File::open(path) {+ Ok(file) => file,+ Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Self::default()),+ Err(e) => return Err(e.to_string()),+ };+ let metadata = file.metadata().map_err(|e| e.to_string())?;+ if !metadata.is_file() || metadata.len() > MAX_BYTES {+ return Err("expected a regular file no larger than 64 KiB".into());+ }+ // Bound the read too: the file can grow after the metadata check.+ let mut bytes = Vec::new();+ file.take(MAX_BYTES + 1).read_to_end(&mut bytes).map_err(|e| e.to_string())?;+ if bytes.len() as u64 > MAX_BYTES {+ return Err("the file is larger than 64 KiB".into());+ }+ serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON or app registration at line {}, column {}", e.line(), e.column()))+ }++ pub(super) fn detect(&self, app: &CraftApp) -> Option<Installed> {+ let rec = self.apps.get(app.id)?;+ let path = &rec.path;+ if !path.is_absolute() || !path.is_file() || !path.extension()?.eq_ignore_ascii_case("exe") {+ return None;+ }+ let stem = path.file_stem()?.to_str()?;+ if !app.stems().any(|s| s.eq_ignore_ascii_case(stem)) {+ return None;+ }+ Some(Installed {+ version: rec.version.as_deref().and_then(version::parse),+ source: InstallSource::Binary,+ location: path.display().to_string(),+ package: None,+ })+ }+ }+}+ fn from_manifest(app: &CraftApp, manifest: &Manifest) -> Option<Installed> { let rec = manifest.apps.get(app.id)?; if !rec.path.exists() {@@ -610,3 +697,183 @@ mod tests { } } }++#[cfg(all(test, windows))]+#[allow(clippy::unwrap_used)]+mod external_windows_tests {+ use std::collections::BTreeMap;++ use super::*;+ use crate::paths::Paths;++ fn create_exe(root: &Path, name: &str) -> PathBuf {+ let exe = root.join("Creative Apps").join(name);+ std::fs::create_dir_all(exe.parent().unwrap()).unwrap();+ // Detection must not run an external executable to learn its version.+ std::fs::write(&exe, b"not executable").unwrap();+ exe+ }++ fn register(paths: &Paths, id: &str, path: &Path, version: Option<&str>) {+ let mut rec = serde_json::json!({ "path": path });+ if let Some(version) = version {+ rec["version"] = version.into();+ }+ let apps = BTreeMap::from([(id, rec)]);+ std::fs::create_dir_all(&paths.data).unwrap();+ std::fs::write(paths.external_apps(), serde_json::to_vec(&serde_json::json!({ "apps": apps })).unwrap()).unwrap();+ }++ fn external(paths: &Paths, id: &str) -> Option<Installed> {+ Snapshot::take(paths).external(CraftApp::by_id(id).unwrap())+ }++ #[test]+ fn registered_custom_exe_can_be_detected() {+ let dir = tempfile::tempdir().unwrap();+ let paths = Paths::under(dir.path());+ let exe = create_exe(dir.path(), "photocraft.exe");+ register(&paths, "photocraft", &exe, Some("0.7.0"));++ let installed = external(&paths, "photocraft").unwrap();+ assert_eq!(installed.source, InstallSource::Binary);+ assert_eq!(installed.location, exe.display().to_string());+ assert_eq!(installed.version.unwrap().to_string(), "0.7.0");+ assert_eq!(installed.package, None);+ }++ #[test]+ fn external_detection_is_disabled_without_registration() {+ let dir = tempfile::tempdir().unwrap();+ let paths = Paths::under(dir.path());+ create_exe(dir.path(), "photocraft.exe");+ assert!(external(&paths, "photocraft").is_none());+ assert!(!paths.data.exists(), "detection must not create a configuration or adopt a copy");+ }++ #[test]+ fn absent_or_invalid_versions_are_unknown() {+ let dir = tempfile::tempdir().unwrap();+ let paths = Paths::under(dir.path());+ let exe = create_exe(dir.path(), "photocraft.exe");+ for v in [None, Some("not a version"), Some("")] {+ register(&paths, "photocraft", &exe, v);+ assert!(external(&paths, "photocraft").unwrap().version.is_none());+ }+ }++ #[test]+ fn registration_accepts_case_insensitive_and_former_executable_names() {+ let dir = tempfile::tempdir().unwrap();+ let paths = Paths::under(dir.path());+ for (id, name) in [("photocraft", "PhotoCraft.EXE"), ("pdfcraft", "PrintCraft.EXE")] {+ let exe = create_exe(dir.path(), name);+ register(&paths, id, &exe, Some("0.7.0"));+ assert_eq!(external(&paths, id).unwrap().location, exe.display().to_string());+ }+ }++ #[test]+ fn registration_rejects_missing_relative_directory_or_other_app_paths() {+ let dir = tempfile::tempdir().unwrap();+ let paths = Paths::under(dir.path());+ let folder = dir.path().join("directory").join("photocraft.exe");+ std::fs::create_dir_all(&folder).unwrap();+ let other = create_exe(dir.path(), "wordcraft.exe");+ let cli = create_exe(dir.path(), "photocraft-cli.exe");+ let wrong_extension = create_exe(dir.path(), "photocraft.txt");+ let candidates = [+ dir.path().join("missing").join("photocraft.exe"),+ "photocraft.exe".into(),+ "C:photocraft.exe".into(),+ folder,+ other,+ cli,+ wrong_extension,+ ];+ for path in candidates {+ register(&paths, "photocraft", &path, Some("0.7.0"));+ assert!(external(&paths, "photocraft").is_none(), "{}", path.display());+ }+ let exe = create_exe(dir.path(), "photocraft.exe");+ register(&paths, "unknown-app", &exe, None);+ assert!(external(&paths, "photocraft").is_none());+ }++ #[test]+ fn malformed_and_oversized_registrations_are_ignored() {+ let dir = tempfile::tempdir().unwrap();+ let paths = Paths::under(dir.path());+ std::fs::create_dir_all(&paths.data).unwrap();+ for bytes in [b"{not json".to_vec(), br#"{"apps":{"photocraft":{"version":"0.7.0"}}}"#.to_vec(), vec![b' '; 64 * 1024 + 1]] {+ std::fs::write(paths.external_apps(), bytes).unwrap();+ assert!(external(&paths, "photocraft").is_none());+ }+ let exe = create_exe(dir.path(), "photocraft.exe");+ register(&paths, "photocraft", &exe, Some("0.7.0"));+ let mut bytes = std::fs::read(paths.external_apps()).unwrap();+ bytes.resize(64 * 1024, b' ');+ std::fs::write(paths.external_apps(), &bytes).unwrap();+ assert!(external(&paths, "photocraft").is_some(), "a valid file exactly at the cap is accepted");+ bytes.push(b' ');+ std::fs::write(paths.external_apps(), bytes).unwrap();+ assert!(external(&paths, "photocraft").is_none(), "even valid JSON is ignored beyond the cap");+ }++ #[test]+ fn registrations_are_read_once_per_pass_and_refresh_on_the_next_pass() {+ let dir = tempfile::tempdir().unwrap();+ let paths = Paths::under(dir.path());+ let exe = create_exe(dir.path(), "photocraft.exe");+ let app = CraftApp::by_id("photocraft").unwrap();+ register(&paths, app.id, &exe, Some("0.7.0"));+ let snapshot = Snapshot::take(&paths);+ register(&paths, app.id, &exe, Some("0.8.0"));+ assert_eq!(snapshot.external(app).unwrap().version.unwrap().to_string(), "0.7.0");+ assert_eq!(external(&paths, app.id).unwrap().version.unwrap().to_string(), "0.8.0");+ std::fs::remove_file(paths.external_apps()).unwrap();+ assert!(external(&paths, app.id).is_none());+ }++ #[test]+ fn launcher_owned_installation_keeps_precedence() {+ let dir = tempfile::tempdir().unwrap();+ let paths = Paths::under(dir.path());+ let exe = create_exe(dir.path(), "photocraft.exe");+ register(&paths, "photocraft", &exe, Some("0.7.0"));+ let managed = create_exe(&dir.path().join("managed"), "photocraft.exe");+ let manifest = Manifest {+ apps: BTreeMap::from([(+ "photocraft".into(),+ crate::manifest::Record {+ version: "0.8.0".into(),+ kind: artcraft_launcher_core::PackageKind::PortableZip,+ path: managed.clone(),+ installed_at: 1,+ files: vec![],+ needs_root: false,+ },+ )]),+ };+ let installed = detect(CraftApp::by_id("photocraft").unwrap(), &manifest, &Snapshot::take(&paths)).unwrap();+ assert_eq!(installed.source, InstallSource::Launcher);+ assert_eq!(installed.location, managed.display().to_string());+ assert_eq!(installed.version.unwrap().to_string(), "0.8.0");+ }++ #[test]+ fn registered_apps_cannot_be_updated_or_removed_by_the_launcher() {+ let dir = tempfile::tempdir().unwrap();+ let paths = Paths::under(dir.path());+ let app = CraftApp::by_id("photocraft").unwrap();+ let exe = create_exe(dir.path(), "photocraft.exe");+ register(&paths, app.id, &exe, None);+ let installed = external(&paths, app.id).unwrap();+ assert!(crate::install::managed_elsewhere(app, &installed, false).is_some());+ let result = crate::install::uninstall(app, &installed, None, &paths, &crate::http::Stop::default(), &mut |_| {});+ assert!(result.is_err());+ assert!(exe.is_file());+ assert!(!paths.manifest().exists());+ assert_eq!(external(&paths, app.id), Some(installed));+ }+}@@ -56,6 +56,11 @@ impl Paths { self.data.join("installed.json") } + /// Optional Windows executables the user registered, without giving the launcher ownership.+ pub fn external_apps(&self) -> PathBuf {+ self.data.join("external-apps.json")+ }+ /// The launcher's own log files (`artcraft-launcher.log` and the previous runs'). pub fn logs(&self) -> PathBuf { self.data.join("logs")@@ -423,7 +423,7 @@ impl LauncherUi { fn announce_updates(&mut self, host: &mut dyn Host) { let mut fresh = Vec::new(); for e in &self.entries {- if !self.in_filter(e, Filter::Updates) {+ if !self.in_filter(e, Filter::Updates) || e.external_windows_copy(self.platform) { continue; } let Some(v) = e.latest_label() else { continue };@@ -460,7 +460,7 @@ impl LauncherUi { let to_update: Vec<(String, String)> = self .entries .iter()- .filter(|e| self.in_filter(e, Filter::Updates) && e.job.is_none() && !e.ready)+ .filter(|e| self.in_filter(e, Filter::Updates) && !e.external_windows_copy(self.platform) && e.job.is_none() && !e.ready) .filter_map(|e| { let v = e.latest_label()?; if self.auto_update_attempted.contains(&(e.app.id.to_string(), v.clone())) { None } else { Some((e.app.id.to_string(), v)) }@@ -4,7 +4,7 @@ use artcraft_launcher_core::platform::{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};-use artcraft_launcher_core::{CraftApp, Installed};+use artcraft_launcher_core::{CraftApp, InstallSource, Installed, Os}; use crate::i18n::t; @@ -36,6 +36,11 @@ impl AppEntry { } } + /// Explicitly registered Windows executables are opened here, but managed by their owner.+ pub fn external_windows_copy(&self, platform: Option<Platform>) -> bool {+ platform.is_some_and(|p| p.os == Os::Windows) && self.installed.as_ref().is_some_and(|i| i.source == InstallSource::Binary)+ }+ /// Whether the latest release has something for `platform`. pub fn has_download(&self, platform: Option<Platform>) -> bool { let (Some(r), Some(p)) = (self.release(), platform) else { return false };@@ -198,7 +198,7 @@ impl LauncherUi { } self.announcements(ui); ui.horizontal(|ui| {- let updates = self.count(Filter::Updates);+ let updates = self.entries.iter().filter(|e| self.in_filter(e, Filter::Updates) && !e.external_windows_copy(self.platform)).count(); let mut filters = vec![ (Filter::All, tl!("All"), tl!("Every Craft app"), None), // The tab names a group ("installed ones"); the status pill says "Installed".@@ -221,8 +221,12 @@ impl LauncherUi { fmt(tl!("Update all ({count})"), &[("count", &updates.to_string())]) }; if widgets::button(ui, &label, Kind::Primary, 0.0).clicked() {- let ids: Vec<&str> =- self.entries.iter().filter(|e| self.in_filter(e, Filter::Updates) && e.job.is_none()).map(|e| e.app.id).collect();+ let ids: Vec<&str> = self+ .entries+ .iter()+ .filter(|e| self.in_filter(e, Filter::Updates) && !e.external_windows_copy(self.platform) && e.job.is_none())+ .map(|e| e.app.id)+ .collect(); for id in ids { self.send(Command::Install { app: id.to_string() }); }@@ -663,6 +667,9 @@ impl LauncherUi { (_, None) => Primary::Unavailable(tl!("Not available")), }; }+ if status.is_installed() && entry.external_windows_copy(self.platform) {+ return Primary::Open;+ } match status { AppStatus::Available if entry.ready => Primary::Retry(tl!("Retry install").into()), AppStatus::UpdateAvailable if entry.ready => {@@ -745,6 +752,7 @@ impl LauncherUi { } if let Some(p) = entry.paused && self.caps.manage_apps+ && !entry.external_windows_copy(self.platform) { let pct = if p.total > 0 { format!(" · {:.0}%", p.done as f32 / p.total as f32 * 100.0) } else { String::new() }; // Resume, then discard and the menu (two squares).@@ -811,6 +819,7 @@ impl LauncherUi { fn menu(&mut self, ui: &mut Ui, entry: &AppEntry, status: &AppStatus, links: bool) { let id = entry.app.id.to_string(); let manage = self.caps.manage_apps;+ let external = entry.external_windows_copy(self.platform); let reveal = self.caps.reveal_label; widgets::more_menu(ui, |ui| { if entry.release().is_some() && ui.button(tl!("Release notes")).clicked() {@@ -819,7 +828,7 @@ impl LauncherUi { } // Only where Install would download: an up-to-date app would be reinstalled. let can_download_again =- manage && entry.ready && entry.paused.is_none() && matches!(status, AppStatus::Available | AppStatus::UpdateAvailable);+ manage && !external && entry.ready && entry.paused.is_none() && matches!(status, AppStatus::Available | AppStatus::UpdateAvailable); if can_download_again && ui.button(tl!("Download again")).clicked() { self.send(Command::Cancel { app: id.clone() }); self.send(Command::Install { app: id.clone() });@@ -834,14 +843,16 @@ impl LauncherUi { self.send(Command::Reveal { app: id.clone() }); ui.close(); }- if *status == AppStatus::UpToDate && ui.button(tl!("Reinstall")).clicked() {+ if !external && *status == AppStatus::UpToDate && ui.button(tl!("Reinstall")).clicked() { self.send(Command::Install { app: id.clone() }); ui.close(); }- ui.separator();- if ui.button(RichText::new(tl!("Uninstall…")).color(Tokens::get(ui.ctx()).bad)).clicked() {- self.dialog = Some(Dialog::ConfirmUninstall(id.clone()));- ui.close();+ if !external {+ ui.separator();+ if ui.button(RichText::new(tl!("Uninstall…")).color(Tokens::get(ui.ctx()).bad)).clicked() {+ self.dialog = Some(Dialog::ConfirmUninstall(id.clone()));+ ui.close();+ } } } if !manage && let (Some(r), Some(p)) = (entry.release(), self.platform) {@@ -1496,3 +1496,128 @@ fn with_animations_on_hovering_a_card_animates() { let frames = frames_to_hover_the_next_card(&mut h); assert!(frames >= 10, "{frames} frames"); }++/// A Windows backend with an external portable PhotoCraft and a normal MSI EffectCraft.+fn external_windows_harness(auto_update: bool) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {+ external_windows_harness_with_notifications(auto_update, false, Rc::default())+}++fn external_windows_harness_with_notifications(+ auto_update: bool,+ notify: bool,+ notices: Rc<RefCell<Vec<(String, String)>>>,+) -> (Harness<'static, Option<LauncherUi>>, Rc<RefCell<Vec<Command>>>) {+ struct Notifications(Rc<RefCell<Vec<(String, String)>>>);+ impl Host for Notifications {+ fn notify(&mut self, title: &str, body: &str) {+ self.0.borrow_mut().push((title.into(), body.into()));+ }+ }+ struct Windows(Fake);+ impl Backend for Windows {+ fn send(&mut self, command: Command) {+ self.0.send(command);+ }+ fn poll(&mut self) -> Vec<Event> {+ self.0.poll()+ }+ fn capabilities(&self) -> Capabilities {+ Capabilities { manage_apps: true, reveal_label: "Show in Explorer", self_update: true }+ }+ fn platform(&self) -> Option<Platform> {+ Some(Platform { os: Os::Windows, arch: Arch::X64 })+ }+ fn now(&self) -> i64 {+ NOW+ }+ }+ artcraft_launcher_ui_egui::i18n::set_test_lang(artcraft_launcher_ui_egui::i18n::Lang::EN);+ let ev = events()+ .into_iter()+ .filter_map(|event| match event {+ Event::Detected { app, installed } => Some(Event::Detected {+ installed: installed.map(|copy| Installed {+ source: if app == "photocraft" || app == "lightcraft" { InstallSource::Binary } else { InstallSource::Msi },+ location: format!("D:\\CreativeApps\\{app}\\{app}.exe"),+ ..copy+ }),+ app,+ }),+ Event::Latest { app, latest: LatestRelease::Found(mut release), from } => {+ for asset in &mut release.assets {+ asset.name = format!("{app}-{}-windows-x64.msi", release.tag.trim_start_matches('v'));+ }+ Some(Event::Latest { app, latest: LatestRelease::Found(release), from })+ }+ Event::Progress { .. } | Event::Paused { .. } | Event::ReadyToInstall { .. } => None,+ other => Some(other),+ })+ .collect();+ let sent = Rc::new(RefCell::new(Vec::new()));+ let mut backend = Some(Windows(Fake {+ events: ev,+ sent: sent.clone(),+ manage: true,+ pictures: Rc::default(),+ asked: Vec::new(),+ clock: Rc::new(Cell::new(NOW)),+ }));+ let h = Harness::builder().with_size(egui::vec2(1180.0, 900.0)).build_ui_state(+ move |ui, state: &mut Option<LauncherUi>| {+ if state.is_none()+ && let Some(b) = backend.take()+ {+ *state = Some(LauncherUi::new(ui.ctx(), Box::new(b), Settings { view: ViewMode::Table, auto_update, notify, ..Settings::default() }));+ }+ if let Some(l) = state.as_mut() {+ l.logic(&ui.ctx().clone(), &mut Notifications(notices.clone()));+ l.ui(ui, &mut Notifications(notices.clone()));+ }+ },+ None,+ );+ (h, sent)+}++#[test]+fn external_windows_copies_open_and_stay_out_of_update_all_and_removal() {+ let (mut h, sent) = external_windows_harness(false);+ h.run_steps(8);+ assert!(h.query_by_label("Update to 0.5.0").is_none());+ h.get_by_label("Update to 0.6.0");+ let row = h.get_all_by_label("PhotoCraft").next().unwrap().rect().center().y;+ h.get_all_by_label("Open").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click();+ h.run_steps(2);+ assert!(sent.borrow().contains(&Command::Launch { app: "photocraft".into() }));+ h.get_by_label("Update 1 app").click();+ h.run_steps(2);+ let installs: Vec<_> = sent.borrow().iter().filter_map(|c| if let Command::Install { app } = c { Some(app.clone()) } else { None }).collect();+ assert_eq!(installs, ["effectcraft"]);+ for name in ["PhotoCraft", "LightCraft"] {+ let row = h.get_all_by_label(name).next().unwrap().rect().center().y;+ h.get_all_by_label("…").find(|n| (n.rect().center().y - row).abs() < 25.0).unwrap().click();+ h.run_steps(3);+ assert!(h.query_by_label("Uninstall…").is_none());+ assert!(h.query_by_label("Reinstall").is_none());+ h.get_by_label("Show in Explorer").click();+ h.run_steps(2);+ }+ assert!(sent.borrow().contains(&Command::Reveal { app: "photocraft".into() }));+ assert!(sent.borrow().contains(&Command::Reveal { app: "lightcraft".into() }));+}++#[test]+fn automatic_updates_skip_external_windows_copies_but_keep_msi_updates() {+ let (mut h, sent) = external_windows_harness(true);+ h.run_steps(4);+ let installs: Vec<_> = sent.borrow().iter().filter_map(|c| if let Command::Install { app } = c { Some(app.clone()) } else { None }).collect();+ assert_eq!(installs, ["effectcraft"]);+}++#[test]+fn update_notifications_skip_external_windows_copies_but_keep_msi_updates() {+ let notices = Rc::new(RefCell::new(Vec::new()));+ let (mut h, _) = external_windows_harness_with_notifications(false, true, notices.clone());+ h.run_steps(4);+ assert_eq!(*notices.borrow(), [("EffectCraft 0.6.0 is available".into(), "Open ArtCraft Launcher to update.".into())]);+}