collective

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

feat(installer): unify Windows setup and completion screens

feat(installer): unify Windows setup and completion screens Use the shared `craft-installer` WiX UI snapshot for Windows setup. Install, repair and removal end on an explicit Finish screen, including removal launched from Windows Settings. Existing folder, shortcut and launch options retain this app’s behavior, and quiet commands remain unattended. Prevent the Settings alias from being treated as a duplicate MSI product; preserve queue and per-user installer behavior. Rebased onto v0.4.1 (`cbae640688c5fa3daa120a7f5e050f8e24148a47`). Qualification uses 0.4.2-rc.1 locally and upgrades from the signed production v0.4.1 MSI. Update rustls to 0.23.46 for

Status
open
Score
0.00
Adopters
0 other operators
Author
skylartaylor (imported from pull request #509, open)
Base
cbae640688c5fa3daa120a7f5e050f8e24148a47
Size
+543 −139 in 21 files
Review
pending
Shared
2026-10-28
Id
6a4055e3fadf5eb97f29c6e2

How it went

No other operator has reported using it yet.

Diff

raw · AGENTS.md Cargo.lock README.md crates/native/src/detect.rs crates/ui-egui/src/app.rs crates/ui-egui/src/dialogs.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/tests/ui.rs packaging/windows/README.md packaging/windows/artcraft-launcher.wxs packaging/windows/installer-ui.wxs packaging/windows/package.ps1

diff --git a/AGENTS.md b/AGENTS.md--- a/AGENTS.md+++ b/AGENTS.md@@ -145,10 +145,10 @@ Release assets (keep these names exact; the launcher's own self-update and the R Packaging facts the code relies on:  - The Windows MSI is **per-user** (no UAC prompt), by default into-  `%LOCALAPPDATA%\Programs\ArtCraft Launcher`. Double-clicked it opens a WixUI setup wizard-  (Welcome; a folder page with a "Create a desktop shortcut" box; Ready; progress; Finish with-  "Launch"), whose art `package.ps1` draws at build time; self-update runs it with `/passive`, no-  wizard. The folder and the shortcut choice are recorded under+  `%LOCALAPPDATA%\Programs\ArtCraft Launcher`. Double-clicked it opens the shared `craft-installer` UI+  (Welcome with a desktop shortcut choice; destination folder; progress; Finish with "Launch").+  Installation and removal have explicit completion messages; self-update runs it with `/passive`,+  without authored dialogs. The folder and the shortcut choice are recorded under   `HKCU\Software\Learning Machines LLC\ArtCraft Launcher` (`InstallDir`,   `DesktopShortcutChoice`) and reused by the next version, and `self_update.rs` reads `InstallDir`   to know the running copy came from the MSI. Its shortcuts set AppUserModelIDdiff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -3899,9 +3899,9 @@ dependencies = [  [[package]] name = "rustls"-version = "0.23.45"+version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index"-checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"+checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [  "log",  "once_cell",diff --git a/README.md b/README.md--- a/README.md+++ b/README.md@@ -61,6 +61,10 @@ hunting through a dozen release pages: it lists every app in the suite, shows wh and which version, installs the right build for your operating system and processor, and tells you when an update is out. It is a small native app that can sit in your menu bar or system tray. +When an installation, update or removal finishes successfully, a completion dialog stays open+until dismissed. Results from several jobs appear one at a time, including jobs that finished+while the launcher was hidden in the tray.+ ## Features  <table>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@@ -137,6 +137,17 @@ fn strip_cli_prefix<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {     } } +// Settings has a separate, visible command entry. Only native MSI entries are product+// codes that can be passed to msiexec; hidden SystemComponent entries remain discoverable.+#[cfg(any(windows, test))]+fn is_msi_product_key(code: &str, windows_installer: u32) -> bool {+    if windows_installer != 1 {+        return false;+    }+    let Some(body) = code.strip_prefix('{').and_then(|s| s.strip_suffix('}')) else { return false };+    body.len() == 36 && body.bytes().enumerate().all(|(i, b)| if matches!(i, 8 | 13 | 18 | 23) { b == b'-' } else { b.is_ascii_hexdigit() })+}+ #[cfg(target_os = "macos")] pub mod macos {     use std::path::{Path, PathBuf};@@ -198,6 +209,9 @@ pub mod windows {             let Ok(root) = RegKey::predef(hive).open_subkey_with_flags(UNINSTALL, KEY_READ | flags) else { continue };             for code in root.enum_keys().flatten() {                 let Ok(key) = root.open_subkey_with_flags(&code, KEY_READ | flags) else { continue };+                if !is_msi_product_key(&code, key.get_value::<u32, _>("WindowsInstaller").unwrap_or_default()) {+                    continue;+                }                 let name: String = key.get_value("DisplayName").unwrap_or_default();                 let publisher: String = key.get_value("Publisher").unwrap_or_default();                 if !is_installer_entry(app, &name, &publisher) || out.iter().any(|m: &MsiInstall| m.product_code == code) {@@ -253,6 +267,30 @@ pub mod windows {     } } +#[cfg(test)]+mod msi_identity_tests {+    use super::is_msi_product_key;++    #[test]+    fn settings_aliases_and_malformed_keys_are_not_msi_product_codes() {+        let code = "{119D7D76-0167-4A9E-8F37-09DA0235F68E}";+        assert!(is_msi_product_key(code, 1));+        assert!(is_msi_product_key(&code.to_lowercase(), 1));+        assert!(!is_msi_product_key(code, 0));+        assert!(!is_msi_product_key(code, 2));+        for key in [+            "Craft.cadcraft",+            "",+            "119D7D76-0167-4A9E-8F37-09DA0235F68E",+            "{119D7D76-0167-4A9E-8F37-09DA0235F68G}",+            "{119D7D76_0167-4A9E-8F37-09DA0235F68E}",+            "{é}",+        ] {+            assert!(!is_msi_product_key(key, 1), "{key}");+        }+    }+}+ #[cfg(target_os = "linux")] pub mod linux {     use std::path::{Path, PathBuf};diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs--- a/crates/ui-egui/src/app.rs+++ b/crates/ui-egui/src/app.rs@@ -1,5 +1,6 @@ //! [`LauncherUi`]: state, event handling and the page layout. +use std::collections::VecDeque; use std::time::Duration;  use artcraft_launcher_core::protocol::{@@ -29,6 +30,67 @@ pub enum TitleBar {     MacOs, } +#[cfg(test)]+mod completion_tests {+    use super::*;++    struct IdleBackend;++    impl Backend for IdleBackend {+        fn send(&mut self, _: Command) {}+        fn poll(&mut self) -> Vec<Event> {+            Vec::new()+        }+        fn capabilities(&self) -> Capabilities {+            Capabilities { manage_apps: true, reveal_label: "Show in file manager", self_update: false }+        }+        fn platform(&self) -> Option<Platform> {+            None+        }+        fn now(&self) -> i64 {+            1+        }+    }++    #[test]+    fn completion_waits_for_launcher_update_dialog_to_close() {+        struct NoHost;+        impl Host for NoHost {}++        let ctx = egui::Context::default();+        let mut ui = LauncherUi::new(&ctx, Box::new(IdleBackend), Settings::default());+        let update = Release { tag: "v0.4.0".into(), ..Release::default() };+        ui.dialog = Some(Dialog::LauncherUpdate(update.clone()));+        ui.handle(&ctx, Event::Progress { app: "filmcraft".into(), phase: JobPhase::Installing });+        ui.handle(&ctx, Event::JobFinished { app: "filmcraft".into(), result: Ok("FilmCraft is installed".into()) });+        let mut host = NoHost;+        // This headless test observes dialog state without rendering texture updates.+        ctx.run_ui(egui::RawInput::default(), |root_ui| ui.dialogs(root_ui.ctx(), &mut host)).textures_delta.clear();+        assert_eq!(ui.dialog, Some(Dialog::LauncherUpdate(update)));+        assert_eq!(ui.completions.len(), 1);+        ui.dialog = None;+        ctx.run_ui(egui::RawInput::default(), |root_ui| ui.dialogs(root_ui.ctx(), &mut host)).textures_delta.clear();+        assert_eq!(ui.dialog, Some(Dialog::Completion { app: "filmcraft".into(), message: "FilmCraft is installed".into() }));+        assert!(ui.completions.is_empty());+    }++    #[test]+    fn hidden_window_result_survives_attach_and_preserves_an_open_dialog() {+        let ctx = egui::Context::default();+        let mut ui = LauncherUi::new(&ctx, Box::new(IdleBackend), Settings::default());+        ui.dialog = Some(Dialog::Settings);+        ui.handle(&ctx, Event::Progress { app: "filmcraft".into(), phase: JobPhase::Installing });+        ui.handle(&ctx, Event::JobFinished { app: "filmcraft".into(), result: Ok("FilmCraft is installed".into()) });+        assert_eq!(ui.dialog, Some(Dialog::Settings));+        assert_eq!(ui.completions.front(), Some(&("filmcraft".into(), "FilmCraft is installed".into())));+        // No UI frames run while the launcher is hidden. Opening a new native window+        // resets context-bound state, but must retain the pending completion.+        ui.attach(&egui::Context::default());+        assert_eq!(ui.completions.len(), 1);+        assert_eq!(ui.dialog, Some(Dialog::Settings));+    }+}+ /// What only the shell can do. Every method has a do-nothing default (the web build). pub trait Host {     /// Who draws the title bar.@@ -73,6 +135,7 @@ pub(crate) enum Dialog {     Notes(String),     ConfirmUninstall(String),     Settings,+    Completion { app: String, message: String },     LauncherUpdate(artcraft_launcher_core::Release), } @@ -84,6 +147,7 @@ impl Dialog {             Dialog::Notes(id) => ("notes", id),             Dialog::ConfirmUninstall(id) => ("uninstall", id),             Dialog::Settings => ("settings", ""),+            Dialog::Completion { app, .. } => ("completion", app),             Dialog::LauncherUpdate(r) => ("launcher-update", &r.tag),         }     }@@ -120,6 +184,8 @@ pub struct LauncherUi {     pub(crate) detail_zoom: bool,     /// The details pictures the backend has sent (or been asked for), by app and picture key.     pub(crate) pictures: std::collections::HashMap<(String, String), crate::details::Picture>,+    /// Finished jobs stay available while the window is hidden or another dialog is open.+    pub(crate) completions: VecDeque<(String, String)>,     started: bool,     applied_theme: Option<theme::ThemeChoice>,     /// The OS asks for fewer animations: what the host last said, and the context's time to ask@@ -188,6 +254,7 @@ impl LauncherUi {             detail_shot: 0,             detail_zoom: false,             pictures: std::collections::HashMap::new(),+            completions: VecDeque::new(),             started: false,             applied_theme: None,             reduce_motion: false,@@ -377,6 +444,9 @@ impl LauncherUi {                 }                 let kept = self.entry(&app).is_some_and(|e| e.ready);                 match result {+                    Ok(msg) if !launcher && last.is_some() && self.entry(&app).is_some() => {+                        self.completions.push_back((app, msg));+                    }                     Ok(msg) => self.toast(ctx, msg, false),                     // The paused card says it all.                     Err(e) if e == PAUSED => {}diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs--- a/crates/ui-egui/src/dialogs.rs+++ b/crates/ui-egui/src/dialogs.rs@@ -12,6 +12,13 @@ use crate::widgets::{self, Cell, Kind};  impl LauncherUi {     pub(crate) fn dialogs(&mut self, ctx: &egui::Context, host: &mut dyn Host) {+        // A completion must not replace settings or a removal confirmation. Queue batch+        // results and show them one at a time, including jobs finished while in the tray.+        if self.dialog.is_none()+            && let Some((app, message)) = self.completions.pop_front()+        {+            self.dialog = Some(Dialog::Completion { app, message });+        }         let Some(dialog) = self.dialog.clone() else {             self.alert_popup(ctx);             return;@@ -27,12 +34,24 @@ impl LauncherUi {             Dialog::ConfirmUninstall(id) => modal.show(ctx, |ui| close = self.uninstall_dialog(ui, id)),             Dialog::Settings => modal.show(ctx, |ui| close = self.settings_dialog(ui, host)),             Dialog::LauncherUpdate(r) => modal.show(ctx, |ui| close = self.launcher_update_dialog(ui, r)),+            Dialog::Completion { app, message } => modal.show(ctx, |ui| close = self.completion_dialog(ui, app, message)),         };         if close || response.should_close() {             self.dialog = None;         }     } +    fn completion_dialog(&self, ui: &mut egui::Ui, app: &str, message: &str) -> bool {+        let t = Tokens::get(ui.ctx());+        ui.set_width(420.0f32.min((ui.ctx().content_rect().width() - 80.0).max(120.0)));+        Self::title(ui, crate::icons::app(app), tl!("Setup complete"), "");+        egui::ScrollArea::vertical().max_height(ui.ctx().content_rect().height() * 0.5).show(ui, |ui| {+            ui.add(egui::Label::new(RichText::new(crate::i18n::msg(message)).color(t.ink)).wrap());+        });+        ui.add_space(20.0);+        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked()).inner+    }+     /// The first alert from the release feed the user hasn't acknowledged, as a popup.     fn alert_popup(&mut self, ctx: &egui::Context) {         let Some(alert) = self.alerts.iter().find(|a| !self.settings.dismissed_notices.contains(&a.id)).cloned() else { return };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@@ -312,3 +312,5 @@ filter	Installed	Nainstalováno 	ArtCraft on GitHub	ArtCraft na GitHubu 	Join the ArtCraft Discord	Připojit se na ArtCraft Discord @plural	ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available	ArtCraft Launcher · {n} dostupná aktualizace|ArtCraft Launcher · {n} dostupné aktualizace|ArtCraft Launcher · {n} dostupných aktualizací++	Setup complete	Nastavení dokončenodiff --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@@ -344,3 +344,5 @@ filter	Installed	Installiert 	Loading pictures…	Bilder werden geladen… 	Pictures couldn't be loaded. Reopen this page to try again.	Die Bilder konnten nicht geladen werden. Beim erneuten Öffnen der Seite wird es noch einmal versucht. @plural	ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available	ArtCraft Launcher · {n} Update verfügbar|ArtCraft Launcher · {n} Updates verfügbar++	Setup complete	Einrichtung abgeschlossendiff --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@@ -325,3 +325,5 @@ filter	Installed	Instaladas 	ArtCraft on GitHub	ArtCraft en GitHub 	Join the ArtCraft Discord	Únete al Discord de ArtCraft @plural	ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available	ArtCraft Launcher · {n} actualización disponible|ArtCraft Launcher · {n} actualizaciones disponibles++	Setup complete	Configuración completadadiff --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@@ -342,3 +342,5 @@ filter	Installed	Installées 	Loading pictures…	Chargement des images… 	Pictures couldn't be loaded. Reopen this page to try again.	Impossible de charger les images. Rouvrir la page permet de réessayer. @plural	ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available	ArtCraft Launcher · {n} mise à jour disponible|ArtCraft Launcher · {n} mises à jour disponibles++	Setup complete	Configuration terminéediff --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@@ -345,3 +345,5 @@ filter	Installed	Telepítve 	ArtCraft on GitHub	ArtCraft a GitHubon 	Join the ArtCraft Discord	Csatlakozás az ArtCraft Discord-közösségéhez @plural	ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available	ArtCraft Launcher · {n} frissítés elérhető|ArtCraft Launcher · {n} frissítés elérhető++	Setup complete	A beállítás befejeződöttdiff --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@@ -347,3 +347,5 @@ filter	Installed	インストール済み 	ArtCraft on GitHub	GitHub の ArtCraft 	Join the ArtCraft Discord	ArtCraft Discord に参加 @plural	ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available	ArtCraft Launcher · {n} 件の更新があります++	Setup complete	セットアップが完了しました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@@ -346,3 +346,5 @@ filter	Installed	Zainstalowane 	ArtCraft on GitHub	ArtCraft na GitHubie 	Join the ArtCraft Discord	Dołącz do Discorda ArtCraft @plural	ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available	ArtCraft Launcher · {n} dostępna aktualizacja|ArtCraft Launcher · {n} dostępne aktualizacje|ArtCraft Launcher · {n} dostępnych aktualizacji++	Setup complete	Konfiguracja zakończonadiff --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@@ -332,3 +332,5 @@ filter	Installed	Instalados 	Loading pictures…	Carregando imagens… 	Pictures couldn't be loaded. Reopen this page to try again.	Não foi possível carregar as imagens. Abra a página novamente para tentar de novo. @plural	ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available	ArtCraft Launcher · {n} atualização disponível|ArtCraft Launcher · {n} atualizações disponíveis++	Setup complete	Configuração concluídadiff --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@@ -340,3 +340,5 @@ filter	Installed	Установлены 	Loading pictures…	Загрузка изображений… 	Pictures couldn't be loaded. Reopen this page to try again.	Не удалось загрузить изображения. Откройте страницу снова, чтобы повторить попытку. @plural	ArtCraft Launcher · {n} update available|ArtCraft Launcher · {n} updates available	ArtCraft Launcher · {n} обновление|ArtCraft Launcher · {n} обновления|ArtCraft Launcher · {n} обновлений++	Setup complete	Настройка завершена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@@ -312,3 +312,5 @@ figcraft	Interface design and prototyping	界面设计与原型制作 @msg	{name} installed: {version} (this release publishes no checksum, so only its size was checked)	{name} 已安装:{version}(此版本未发布校验和,因此只检查了文件大小) @msg	{name} was removed	{name} 已卸载 @msg	{name} is running. Quit it, then try again.	{name} 正在运行。请先退出,然后重试。++	Setup complete	设置已完成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@@ -251,6 +251,58 @@ fn snapshot(h: &mut Harness<'_, Option<LauncherUi>>, name: &str) {     img.save(std::path::Path::new(&dir).join(format!("{name}.png"))).unwrap(); } +#[test]+fn completion_stays_visible_and_batch_results_are_acknowledged_in_order() {+    let mut ev = events();+    ev.extend([+        Event::Progress { app: "filmcraft".into(), phase: JobPhase::Installing },+        Event::Detected {+            app: "filmcraft".into(),+            installed: Some(Installed {+                version: version::parse("0.4.0"),+                source: InstallSource::MacApp,+                location: "/Applications/FilmCraft.app".into(),+                package: None,+            }),+        },+        Event::JobFinished { app: "filmcraft".into(), result: Ok("FilmCraft 0.4.0 is installed".into()) },+        Event::Progress { app: "lightcraft".into(), phase: JobPhase::Removing },+        Event::Detected { app: "lightcraft".into(), installed: None },+        Event::JobFinished { app: "lightcraft".into(), result: Ok("LightCraft was removed".into()) },+    ]);+    let (mut h, sent) = harness_with(ev, ViewMode::Grid, egui::Theme::Dark, true, 1.0);+    // Ten seconds at 60 fps: the old 4.5-second toast would already have disappeared.+    h.run_steps(600);+    h.get_by_label("Setup complete");+    h.get_by_label("FilmCraft 0.4.0 is installed");+    assert!(h.query_by_label("LightCraft was removed").is_none());+    snapshot(&mut h, "install-complete-dark");+    h.get_by_label("Done").click();+    h.run_steps(4);+    h.get_by_label("LightCraft was removed");+    snapshot(&mut h, "uninstall-complete-dark");+    h.get_by_label("Done").click();+    h.run_steps(4);+    assert!(h.query_by_label("Setup complete").is_none());+    assert!(!sent.borrow().iter().any(|cmd| matches!(cmd, Command::Launch { .. })));+}++#[test]+fn cancelled_and_failed_jobs_do_not_show_success_completion() {+    let mut ev = events();+    ev.extend([+        Event::Progress { app: "filmcraft".into(), phase: JobPhase::Installing },+        Event::JobFinished { app: "filmcraft".into(), result: Err(INSTALL_CANCELLED.into()) },+        Event::Progress { app: "lightcraft".into(), phase: JobPhase::Removing },+        Event::JobFinished { app: "lightcraft".into(), result: Err("Permission denied".into()) },+    ]);+    let (mut h, _) = harness_with(ev, ViewMode::Grid, egui::Theme::Light, true, 1.0);+    h.run_steps(6);+    assert!(h.query_by_label("Setup complete").is_none());+    h.get_by_label("Install cancelled");+    h.get_by_label("Permission denied");+}+ #[test] fn keeps_working_with_no_window_then_draws_in_a_new_one() {     let sent = Rc::new(RefCell::new(Vec::new()));diff --git a/packaging/windows/README.md b/packaging/windows/README.mdnew file mode 100644--- /dev/null+++ b/packaging/windows/README.md@@ -0,0 +1,34 @@+# Windows installer UI++`installer-ui.wxs` is generated from the shared `craft-installer` crate in+[craft-libs](https://github.com/storytold/craft-libs/tree/main/crates/installer).+`package.ps1` compiles it with this app's package source. The common UI provides progress,+repair/remove, and explicit install/uninstall/repair success, cancellation and failure screens.++Regenerate or check from a sibling craft-libs checkout:++```sh+cargo run --locked -p craft-installer -- --write --app craft-launcher ../craft-launcher/packaging/windows/installer-ui.wxs+cargo run --locked -p craft-installer -- --check --app craft-launcher ../craft-launcher/packaging/windows/installer-ui.wxs+```++Edit the shared templates/profile and regenerate; avoid editing this snapshot by hand. The+profile retains this app's existing folder, shortcut and launch options; defaults, persistence,+components, associations and signing stay in the app-owned source. Release builds require no+sibling checkout, published crate or additional runtime dependency. `/qn`, `/qb` and `/passive`+remain unattended and do not launch the app.++Source/profile checks and native fixture MSI linking/UI table checks passed for x64, x86 and+ARM64. All 14 profiles passed full UI install, missing-file repair and cached uninstall, and+x86 silent install/removal. Folder upgrades, desktop choices and all six launch profiles also+passed their Windows fixture checks. See the+[Windows verification record](../../../craft-libs/docs/windows-installer-verification.md) for+interactive results, scope validation and remaining acceptance checks with real release payloads.+The shared crate includes reusable linking, full UI, option/upgrade/launch and x86 test runners.+Windows Settings now uses a separate MSI-owned entry whose uninstall command selects full UI,+including the completion screen. Its quiet command and direct `/qn`, `/qb`, `/passive` commands+remain unattended. The main feature references `CraftInstallerSettings`; the shared snapshot+owns this registration and hides the native MSI entry while retaining MSI repair/upgrade APIs.++The Settings display name appends `(Craft Apps)` for compatibility with older Launchers; the+hidden native MSI name stays unchanged. Existing ARP Modify policy is retained explicitly.diff --git a/packaging/windows/artcraft-launcher.wxs b/packaging/windows/artcraft-launcher.wxs--- a/packaging/windows/artcraft-launcher.wxs+++ b/packaging/windows/artcraft-launcher.wxs@@ -4,17 +4,17 @@   extensions: `wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2`).   Built by package.ps1: -    wix build artcraft-launcher.wxs -arch x64 -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext+    wix build artcraft-launcher.wxs installer-ui.wxs -arch x64 -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext       -culture en-US -loc artcraft-launcher.en-us.wxl -d Version=1.2.3 -d DisplayVersion=1.2.3-rc.1-      -d BinDir=... -d IconPath=... -d BannerBmp=... -d DialogBmp=... -o out.msi+      -d BinDir=... -d IconPath=... -o out.msi    Per-user install (no UAC prompt), by default into %LOCALAPPDATA%\Programs\ArtCraft Launcher, so   the launcher can update itself without elevation, plus a Start Menu shortcut. The shortcut   carries the AppUserModelID ai.storyteller.artcraft-launcher, which Windows needs to show toast   notifications from an unpackaged app and to group the taskbar button. No file associations. -  Double-clicked, it opens a setup wizard that names the version: Welcome; a folder page to pick-  where it goes, with a "Create a desktop shortcut" box; Ready; a progress page; and a Finish page+  Double-clicked, it opens the suite's shared setup wizard: Welcome, with a "Create a desktop+  shortcut" box; a folder page to pick where it goes; a progress page; and a Finish page   with a ticked "Launch ArtCraft Launcher" box. There is no licence page (the code is MIT OR   Apache-2.0; nothing to accept). The install folder and the desktop-shortcut choice are recorded   under HKCU and reused by later versions, so an update (the launcher's own self-update runs this@@ -58,7 +58,7 @@      <!-- An update installs where the user put the previous version (recorded by          ArtCraftLauncherApp below; every version since 0.1.0 records it). -->-    <Property Id="INSTALLFOLDER">+    <Property Id="INSTALLFOLDER" Secure="yes">       <RegistrySearch Id="PreviousInstallFolder" Root="HKCU" Key="Software\Learning Machines LLC\ArtCraft Launcher" Name="InstallDir" Type="raw" />     </Property> @@ -79,65 +79,25 @@      <Feature Id="Main" Title="ArtCraft Launcher" Level="1">       <ComponentGroupRef Id="ArtCraftLauncherFiles" />+      <ComponentGroupRef Id="CraftInstallerSettings" />     </Feature> -    <!-- The setup wizard: WixUI_InstallDir without its licence page, and with our folder page-         (ArtCraftInstallDirDlg: the stock one plus the desktop-shortcut box) in place of the stock-         one. A later NewDialog on the same button wins. -->-    <ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLFOLDER" />-    <UI>-      <Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="ArtCraftInstallDirDlg" Order="2" Condition="NOT Installed" />-      <Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="ArtCraftInstallDirDlg" Order="2" Condition="NOT Installed" />-      <Publish Dialog="ExitDialog" Control="Finish" Event="DoAction" Value="LaunchArtCraftLauncher"-          Condition="WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 AND NOT Installed" />--      <Dialog Id="ArtCraftInstallDirDlg" Width="370" Height="270" Title="!(loc.InstallDirDlg_Title)">-        <Control Id="Next" Type="PushButton" X="236" Y="243" Width="56" Height="17" Default="yes" Text="!(loc.WixUINext)">-          <!-- As WixUI_InstallDir does for its own folder page: take the folder, check it, go on. -->-          <Publish Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="1" />-          <Publish Event="DoAction" Value="WixUIValidatePath_$(sys.BUILDARCHSHORT)" Order="2" Condition="NOT WIXUI_DONTVALIDATEPATH" />-          <Publish Event="SpawnDialog" Value="InvalidDirDlg" Order="3" Condition="NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID&lt;&gt;&quot;1&quot;" />-          <Publish Event="NewDialog" Value="VerifyReadyDlg" Order="4" Condition="WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID=&quot;1&quot;" />-        </Control>-        <Control Id="Back" Type="PushButton" X="180" Y="243" Width="56" Height="17" Text="!(loc.WixUIBack)">-          <Publish Event="NewDialog" Value="WelcomeDlg" />-        </Control>-        <Control Id="Cancel" Type="PushButton" X="304" Y="243" Width="56" Height="17" Cancel="yes" Text="!(loc.WixUICancel)">-          <Publish Event="SpawnDialog" Value="CancelDlg" />-        </Control>--        <Control Id="Description" Type="Text" X="25" Y="23" Width="280" Height="15" Transparent="yes" NoPrefix="yes" Text="!(loc.InstallDirDlgDescription)" />-        <Control Id="Title" Type="Text" X="15" Y="6" Width="200" Height="15" Transparent="yes" NoPrefix="yes" Text="!(loc.InstallDirDlgTitle)" />-        <Control Id="BannerBitmap" Type="Bitmap" X="0" Y="0" Width="370" Height="44" TabSkip="no" Text="!(loc.InstallDirDlgBannerBitmap)" />-        <Control Id="BannerLine" Type="Line" X="0" Y="44" Width="370" Height="0" />-        <Control Id="BottomLine" Type="Line" X="0" Y="234" Width="370" Height="0" />--        <Control Id="FolderLabel" Type="Text" X="20" Y="60" Width="290" Height="30" NoPrefix="yes" Text="!(loc.InstallDirDlgFolderLabel)" />-        <Control Id="Folder" Type="PathEdit" X="20" Y="100" Width="320" Height="18" Property="WIXUI_INSTALLDIR" Indirect="yes" />-        <Control Id="ChangeFolder" Type="PushButton" X="20" Y="120" Width="56" Height="17" Text="!(loc.InstallDirDlgChange)">-          <Publish Property="_BrowseProperty" Value="[WIXUI_INSTALLDIR]" Order="1" />-          <Publish Event="SpawnDialog" Value="BrowseDlg" Order="2" />-        </Control>-        <Control Id="PerUserNote" Type="Text" X="20" Y="146" Width="330" Height="24" NoPrefix="yes" Text="!(loc.ArtCraftInstallDirNote)" />--        <Control Id="DesktopShortcut" Type="CheckBox" X="20" Y="186" Width="330" Height="17" Property="INSTALLDESKTOPSHORTCUT" CheckBoxValue="1" Text="!(loc.ArtCraftDesktopShortcut)" />-      </Dialog>-    </UI>-    <!-- Our own art, drawn by package.ps1 from the ArtCraft mark and the Craft apps' icons. -->-    <WixVariable Id="WixUIBannerBmp" Value="$(var.BannerBmp)" />-    <WixVariable Id="WixUIDialogBmp" Value="$(var.DialogBmp)" />--    <!-- The full version (Version is numeric, so it drops a pre-release tag), shown on the wizard's-         pages through artcraft-launcher.en-us.wxl. -->++++     <Property Id="ArtCraftLauncherVersion" Value="$(var.DisplayVersion)" />      <!-- Finish page: say where to find the launcher, and offer to start it (ticked by default). -->-    <Property Id="WIXUI_EXITDIALOGOPTIONALTEXT" Value="ArtCraft Launcher $(var.DisplayVersion) is installed. Start it any time from the Start menu to install and update the Craft App Suite." />     <Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT" Value="Launch ArtCraft Launcher" />     <Property Id="WIXUI_EXITDIALOGOPTIONALCHECKBOX" Value="1" />     <Property Id="WixShellExecTarget" Value="[#ArtCraftLauncherExe]" />     <!-- Runs in the wizard's own process, so the launcher starts as the user. -->     <CustomAction Id="LaunchArtCraftLauncher" BinaryRef="Wix4UtilCA_$(sys.BUILDARCHSHORT)" DllEntry="WixShellExec" Impersonate="yes" Return="ignore" />+    <!-- Shared full UI; app installation policy remains below. -->+    <!-- Preserve the previous stock WiX wizard's ARP Modify policy. -->+    <Property Id="ARPNOMODIFY" Value="1" />+    <UIRef Id="CraftInstallerUI" />   </Package>    <Fragment>diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxsnew file mode 100644--- /dev/null+++ b/packaging/windows/installer-ui.wxs@@ -0,0 +1,285 @@+<?xml version="1.0" encoding="utf-8"?>+<!-- Generated by craft-installer for craft-launcher. Edit the template in craft-libs, then regenerate. -->+<!-- Adapted from PdfCraft installer-ui.wxs (a6bc63a), GridCraft gridcraft.wxs (5b15827),+     and EffectCraft installer-ui.wxs (1b4d47f).+     Copyright (c) 2026 ArtCraft Team and the PdfCraft contributors.+     Copyright (c) 2026 ArtCraft Team and the GridCraft contributors.+     Copyright (c) 2026 ArtCraft Team and the EffectCraft contributors.+     Licensed under MIT OR Apache-2.0; see the repository's LICENSE-MIT and LICENSE-APACHE. -->+<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">+  <Fragment>+    <!-- Windows Installer populates files-in-use lists at runtime. Keep their table even+         though these dialogs have no static ListItem rows (the GridCraft UI does the same). -->+    <EnsureTable Id="ListBox" />+    <!-- Reuse the existing package icon as a fallback; MSI supplies its standard message icon. -->+    <Binary Id="CraftInstallerErrorIcon" SourceFile="$(var.IconPath)" />+    <SetProperty Id="CraftDesktopShortcutChoice" Value="1" Before="InstallWelcome" Sequence="ui"+        Condition="UILevel = 5 AND NOT Installed AND NOT REMOVE AND NOT REINSTALL AND INSTALLDESKTOPSHORTCUT = &quot;1&quot;" />+    <CustomActionRef Id="WixUIValidatePath_$(sys.BUILDARCHSHORT)" />+    <UI Id="CraftInstallerUI">+      <TextStyle Id="CraftInstallerNormal" FaceName="Segoe UI" Size="9" />+      <TextStyle Id="CraftInstallerTitle" FaceName="Segoe UI" Size="12" Bold="yes" />+      <Property Id="DefaultUIFont" Value="CraftInstallerNormal" />+      <Property Id="ErrorDialog" Value="CraftInstallerErrorDlg" />+      <Dialog Id="CraftInstallerErrorDlg" Width="320" Height="120" Title="[ProductName] Setup" ErrorDialog="yes" NoMinimize="yes">+        <Control Id="ErrorText" Type="Text" X="60" Y="15" Width="245" Height="65" TabSkip="no" />+        <Control Id="ErrorIcon" Type="Icon" X="15" Y="15" Width="32" Height="32" FixedSize="yes" IconSize="32" TabSkip="yes" Text="CraftInstallerErrorIcon" />+        <Control Id="A" Type="PushButton" X="15" Y="90" Width="65" Height="20" Text="Abort" TabSkip="yes">+          <Publish Event="EndDialog" Value="ErrorAbort" />+        </Control>+        <Control Id="C" Type="PushButton" X="15" Y="90" Width="65" Height="20" Text="Cancel" TabSkip="yes">+          <Publish Event="EndDialog" Value="ErrorCancel" />+        </Control>+        <Control Id="I" Type="PushButton" X="15" Y="90" Width="65" Height="20" Text="Ignore" TabSkip="yes">+          <Publish Event="EndDialog" Value="ErrorIgnore" />+        </Control>+        <Control Id="N" Type="PushButton" X="15" Y="90" Width="65" Height="20" Text="No" TabSkip="yes">+          <Publish Event="EndDialog" Value="ErrorNo" />+        </Control>+        <Control Id="O" Type="PushButton" X="15" Y="90" Width="65" Height="20" Text="OK" TabSkip="yes">+          <Publish Event="EndDialog" Value="ErrorOk" />+        </Control>+        <Control Id="R" Type="PushButton" X="15" Y="90" Width="65" Height="20" Text="Retry" TabSkip="yes">+          <Publish Event="EndDialog" Value="ErrorRetry" />+        </Control>+        <Control Id="Y" Type="PushButton" X="15" Y="90" Width="65" Height="20" Text="Yes" TabSkip="yes">+          <Publish Event="EndDialog" Value="ErrorYes" />+        </Control>+      </Dialog>+      <Dialog Id="FilesInUse" Width="320" Height="190" Title="[ProductName] Setup" NoMinimize="yes">+        <Control Id="Description" Type="Text" X="15" Y="15" Width="290" Height="35"+            Text="Close the applications listed below, then choose Retry. Ignore may require a restart." />+        <Control Id="Files" Type="ListBox" X="15" Y="55" Width="290" Height="85" Property="FileInUseProcess" />+        <Control Id="Retry" Type="PushButton" X="95" Y="155" Width="65" Height="20" Default="yes" Text="Retry">+          <Publish Event="EndDialog" Value="Retry" />+        </Control>+        <Control Id="Ignore" Type="PushButton" X="167" Y="155" Width="65" Height="20" Text="Ignore">+          <Publish Event="EndDialog" Value="Ignore" />+        </Control>+        <Control Id="Cancel" Type="PushButton" X="240" Y="155" Width="65" Height="20" Cancel="yes" Text="Cancel">+          <Publish Event="EndDialog" Value="Exit" />+        </Control>+      </Dialog>++      <Property Id="_BrowseProperty" Value="INSTALLFOLDER" />+      <Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />+      <Dialog Id="InstallFolder" Width="370" Height="270" Title="[ProductName] Setup">+        <Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\CraftInstallerTitle}Choose the destination folder" />+        <Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="40" Text="[ProductName] will be installed in this folder. Click Install to continue." />+        <Control Id="FolderLabel" Type="Text" X="20" Y="105" Width="330" Height="15" Text="&amp;Destination folder:" />+        <Control Id="Folder" Type="PathEdit" X="20" Y="125" Width="250" Height="20" Property="_BrowseProperty" Indirect="yes"  />+        <Control Id="ChangeFolder" Type="PushButton" X="280" Y="124" Width="70" Height="23" Text="&amp;Browse..." >+          <Publish Event="SpawnDialog" Value="CraftBrowseDlg" />+        </Control>++        <Control Id="Install" Type="PushButton" X="195" Y="235" Width="75" Height="23" Default="yes" ElevationShield="no" Text="&amp;Install">+          <Publish Event="SetTargetPath" Value="INSTALLFOLDER" Order="1" />+          <Publish Event="DoAction" Value="WixUIValidatePath_$(sys.BUILDARCHSHORT)" Order="2" Condition="NOT WIXUI_DONTVALIDATEPATH" />+          <Publish Event="SpawnDialog" Value="CraftInvalidFolder" Order="3" Condition="NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID &lt;&gt; &quot;1&quot;" />+          <Publish Event="EndDialog" Value="Return" Order="99" Condition="WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID = &quot;1&quot;" />+        </Control>+        <Control Id="Cancel" Type="PushButton" X="280" Y="235" Width="70" Height="23" Cancel="yes" Text="Cancel">+          <Publish Event="SpawnDialog" Value="ConfirmCancel" />+        </Control>+      </Dialog>+      <Dialog Id="CraftBrowseDlg" Width="370" Height="270" Title="[ProductName] Setup">+        <Control Id="Title" Type="Text" X="20" Y="15" Width="330" Height="20" Transparent="yes" NoPrefix="yes" Text="{\CraftInstallerTitle}Browse for a folder" />+        <Control Id="LookInLabel" Type="Text" X="20" Y="47" Width="44" Height="12" Text="&amp;Look in:" />+        <Control Id="LookIn" Type="DirectoryCombo" X="66" Y="45" Width="220" Height="80" Property="_BrowseProperty" Indirect="yes" Fixed="yes" Remote="yes">+          <Subscribe Event="IgnoreChange" Attribute="IgnoreChange" />+        </Control>+        <Control Id="Up" Type="PushButton" X="292" Y="44" Width="28" Height="18" Text="&amp;Up" ToolTip="Up one level">+          <Publish Event="DirectoryListUp" Value="0" />+        </Control>+        <Control Id="NewFolder" Type="PushButton" X="322" Y="44" Width="28" Height="18" Text="Ne&amp;w" ToolTip="Create a new folder">+          <Publish Event="DirectoryListNew" Value="0" />+        </Control>+        <Control Id="Folders" Type="DirectoryList" X="20" Y="68" Width="330" Height="120" Property="_BrowseProperty" Indirect="yes" Sunken="yes" TabSkip="no" />+        <Control Id="PathLabel" Type="Text" X="20" Y="196" Width="330" Height="12" Text="&amp;Folder name:" />+        <Control Id="Path" Type="PathEdit" X="20" Y="210" Width="330" Height="18" Property="_BrowseProperty" Indirect="yes" />+        <Control Id="OK" Type="PushButton" X="236" Y="243" Width="56" Height="17" Default="yes" Text="OK">+          <Publish Event="SetTargetPath" Value="[_BrowseProperty]" Order="1" />+                    <Publish Event="DoAction" Value="WixUIValidatePath_$(sys.BUILDARCHSHORT)" Order="3" Condition="NOT WIXUI_DONTVALIDATEPATH" />+          <Publish Event="SpawnDialog" Value="CraftInvalidFolder" Order="4" Condition="NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID &lt;&gt; &quot;1&quot;" />+          <Publish Event="EndDialog" Value="Return" Order="99" Condition="WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID = &quot;1&quot;" />+        </Control>+        <Control Id="Cancel" Type="PushButton" X="304" Y="243" Width="56" Height="17" Cancel="yes" Text="Cancel">+          <Publish Event="Reset" Value="0" Order="1" />+          <Publish Event="EndDialog" Value="Return" Order="2" />+        </Control>+      </Dialog>++      <Dialog Id="CraftInvalidFolder" Width="300" Height="120" Title="[ProductName] Setup">+        <Control Id="Description" Type="Text" X="20" Y="20" Width="260" Height="50" Text="This destination folder cannot be used. Choose a valid local folder and try again." />+        <Control Id="OK" Type="PushButton" X="210" Y="85" Width="70" Height="23" Default="yes" Cancel="yes" Text="OK">+          <Publish Event="EndDialog" Value="Return" />+        </Control>+      </Dialog>++      <Dialog Id="InstallWelcome" Width="370" Height="270" Title="[ProductName] Setup">+        <Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\CraftInstallerTitle}Install [ProductName]" />+        <Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="40"+            Text="Setup will install [ProductName] for your Windows account and add it to the Start menu. Click Next to choose its folder." />+        <Control Id="DesktopShortcut" Type="CheckBox" X="20" Y="125" Width="330" Height="20" Property="CraftDesktopShortcutChoice" CheckBoxValue="1" Text="Create a &amp;desktop shortcut" />+        <Control Id="Install" Type="PushButton" X="195" Y="235" Width="75" Height="23" Default="yes" ElevationShield="no" Text="&amp;Next">+          <Publish Property="INSTALLDESKTOPSHORTCUT" Value="1" Order="1" Condition="CraftDesktopShortcutChoice = &quot;1&quot;" />+          <Publish Property="INSTALLDESKTOPSHORTCUT" Value="0" Order="2" Condition="NOT CraftDesktopShortcutChoice" />+          <Publish Event="EndDialog" Value="Return" Order="99" />+        </Control>+        <Control Id="Cancel" Type="PushButton" X="280" Y="235" Width="70" Height="23" Cancel="yes" Text="Cancel">+          <Publish Event="EndDialog" Value="Exit" />+        </Control>+      </Dialog>++      <Dialog Id="InstallMaintenance" Width="370" Height="270" Title="[ProductName] Setup">+        <Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\CraftInstallerTitle}Maintain [ProductName]" />+        <Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="70"+            Text="[ProductName] is already installed. Choose Repair to restore missing files and shortcuts, or Remove to uninstall it." />+        <Control Id="Repair" Type="PushButton" X="110" Y="235" Width="75" Height="23" Default="yes" ElevationShield="no" Text="&amp;Repair">+          <!-- Feature events do not retain REINSTALL/REMOVE in the client UI session. -->+          <Publish Property="CraftInstallerOperation" Value="repair" Order="1" />+          <Publish Event="ReinstallMode" Value="amus" Order="2" />+          <Publish Event="Reinstall" Value="ALL" Order="3" />+          <Publish Event="EndDialog" Value="Return" Order="4" />+        </Control>+        <Control Id="Remove" Type="PushButton" X="195" Y="235" Width="75" Height="23" ElevationShield="no" Text="R&amp;emove">+          <Publish Property="CraftInstallerOperation" Value="remove" Order="1" />+          <Publish Event="Remove" Value="ALL" Order="2" />+          <Publish Event="EndDialog" Value="Return" Order="3" />+        </Control>+        <Control Id="Cancel" Type="PushButton" X="280" Y="235" Width="70" Height="23" Cancel="yes" Text="Cancel">+          <Publish Event="EndDialog" Value="Exit" />+        </Control>+      </Dialog>++      <Dialog Id="InstallProgress" Width="370" Height="270" Title="[ProductName] Setup" Modeless="yes">+        <Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\CraftInstallerTitle}Setting up [ProductName]" />+        <!-- No ActionText table is authored: subscribing would blank this message (#305). -->+        <Control Id="Status" Type="Text" X="20" Y="60" Width="330" Height="40" Text="Please wait while setup completes." />+        <Control Id="Progress" Type="ProgressBar" X="20" Y="110" Width="330" Height="15" ProgressBlocks="yes">+          <Subscribe Event="SetProgress" Attribute="Progress" />+        </Control>+        <Control Id="Cancel" Type="PushButton" X="280" Y="235" Width="70" Height="23" Cancel="yes" Text="Cancel">+          <Publish Event="SpawnDialog" Value="ConfirmCancel" />+        </Control>+      </Dialog>++      <Dialog Id="ConfirmCancel" Width="300" Height="110" Title="Cancel setup">+        <Control Id="Description" Type="Text" X="20" Y="20" Width="260" Height="35" Text="Cancel [ProductName] setup?" />+        <Control Id="Yes" Type="PushButton" X="130" Y="70" Width="70" Height="23" Text="&amp;Yes">+          <Publish Event="EndDialog" Value="Exit" />+        </Control>+        <Control Id="No" Type="PushButton" X="210" Y="70" Width="70" Height="23" Default="yes" Cancel="yes" Text="&amp;No">+          <Publish Event="EndDialog" Value="Return" />+        </Control>+      </Dialog>++      <Dialog Id="InstallComplete" Width="370" Height="270" Title="[ProductName] Setup">+        <Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\CraftInstallerTitle}Setup completed successfully" HideCondition="REMOVE = &quot;ALL&quot; OR CraftInstallerOperation = &quot;remove&quot;" />+        <Control Id="RemovedTitle" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\CraftInstallerTitle}Uninstall completed successfully" Hidden="yes" ShowCondition="REMOVE = &quot;ALL&quot; OR CraftInstallerOperation = &quot;remove&quot;" />+        <Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="70"+            Text="[ProductName] is installed. Open it from the Start menu. Click Finish to close setup." HideCondition="REMOVE = &quot;ALL&quot; OR REINSTALL OR CraftInstallerOperation OR ACTION = &quot;ADMIN&quot;" />+        <Control Id="Removed" Type="Text" X="20" Y="55" Width="330" Height="70" Hidden="yes"+            Text="[ProductName] was uninstalled. Your documents and settings were kept. Click Finish to close setup."+            ShowCondition="REMOVE = &quot;ALL&quot; OR CraftInstallerOperation = &quot;remove&quot;" />+        <Control Id="Repaired" Type="Text" X="20" Y="55" Width="330" Height="70" Hidden="yes"+            Text="[ProductName] was repaired. Open it from the Start menu. Click Finish to close setup."+            ShowCondition="(REINSTALL OR CraftInstallerOperation = &quot;repair&quot;) AND NOT REMOVE AND CraftInstallerOperation &lt;&gt; &quot;remove&quot; AND ACTION &lt;&gt; &quot;ADMIN&quot;" />+        <Control Id="AdminComplete" Type="Text" X="20" Y="55" Width="330" Height="70" Hidden="yes"+            Text="The administrative image was created. Click Finish to close setup." ShowCondition="ACTION = &quot;ADMIN&quot;" />+        <Control Id="Finish" Type="PushButton" X="280" Y="235" Width="70" Height="23" Default="yes" Cancel="yes" Text="&amp;Finish">+                    <Publish Event="DoAction" Value="LaunchArtCraftLauncher" Order="1" Condition="WIXUI_EXITDIALOGOPTIONALCHECKBOX = &quot;1&quot; AND NOT Installed AND NOT REMOVE AND NOT REINSTALL AND NOT CraftInstallerOperation AND ACTION &lt;&gt; &quot;ADMIN&quot;" />+          <Publish Event="EndDialog" Value="Return" Order="999" />+        </Control>+        <Control Id="Launch" Type="CheckBox" X="20" Y="155" Width="330" Height="20" Property="WIXUI_EXITDIALOGOPTIONALCHECKBOX" CheckBoxValue="1" Text="[WIXUI_EXITDIALOGOPTIONALCHECKBOXTEXT]" HideCondition="Installed OR REMOVE OR REINSTALL OR CraftInstallerOperation OR ACTION = &quot;ADMIN&quot;" />+      </Dialog>++      <Dialog Id="InstallFailed" Width="370" Height="270" Title="[ProductName] Setup">+        <Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\CraftInstallerTitle}Setup failed" />+        <Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="70"+            Text="[ProductName] setup could not be completed. Resolve the reported error and run setup again." />+        <Control Id="Finish" Type="PushButton" X="280" Y="235" Width="70" Height="23" Default="yes" Cancel="yes" Text="&amp;Finish">+          <Publish Event="EndDialog" Value="Return" />+        </Control>+      </Dialog>++      <Dialog Id="InstallCancelled" Width="370" Height="270" Title="[ProductName] Setup">+        <Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\CraftInstallerTitle}Setup cancelled" />+        <Control Id="Description" Type="Text" X="20" Y="55" Width="330" Height="70"+            Text="[ProductName] setup was cancelled. Run setup again when you are ready." />+        <Control Id="Finish" Type="PushButton" X="280" Y="235" Width="70" Height="23" Default="yes" Cancel="yes" Text="&amp;Finish">+          <Publish Event="EndDialog" Value="Return" />+        </Control>+      </Dialog>++      <!-- Shown by Windows Installer (Restart Manager) when an upgrade or repair must replace files+           that a running app holds open. OK closes those programs (MSIRMSHUTDOWN=2) and+           continues; Cancel ends setup. -->+      <Dialog Id="MsiRMFilesInUse" Width="370" Height="200" Title="[ProductName] Setup" KeepModeless="yes">+        <Control Id="Title" Type="Text" X="20" Y="15" Width="330" Height="25" Text="{\CraftInstallerTitle}Files in use" />+        <Control Id="Description" Type="Text" X="20" Y="45" Width="330" Height="30"+            Text="These programs are using files that setup needs to update. Click OK to close them and continue, or Cancel to stop setup." />+        <Control Id="List" Type="ListBox" X="20" Y="80" Width="330" Height="55" Property="FileInUseProcess" Sunken="yes" TabSkip="yes" />+        <Control Id="OK" Type="PushButton" X="195" Y="165" Width="75" Height="23" Default="yes" Text="OK">+          <Publish Property="MSIRMSHUTDOWN" Value="2" Order="1" />+          <Publish Event="EndDialog" Value="Return" Order="2" />+        </Control>+        <Control Id="Cancel" Type="PushButton" X="280" Y="165" Width="70" Height="23" Cancel="yes" Text="Cancel">+          <Publish Event="EndDialog" Value="Exit" />+        </Control>+      </Dialog>++      <InstallUISequence>+        <Show Dialog="InstallWelcome" Before="CostInitialize" Condition="UILevel = 5 AND NOT Installed AND NOT REMOVE AND NOT REINSTALL" />+        <!-- Reinstall/Remove control events require the selection manager initialized by costing. -->+        <Show Dialog="InstallMaintenance" After="CostFinalize" Condition="UILevel = 5 AND Installed AND NOT REMOVE AND NOT REINSTALL" />+        <Show Dialog="InstallFolder" After="InstallMaintenance" Condition="UILevel = 5 AND NOT Installed AND NOT REMOVE AND NOT REINSTALL" />+        <Show Dialog="InstallProgress" Before="ExecuteAction" Condition="UILevel = 5" />+        <Show Dialog="InstallComplete" OnExit="success" Condition="UILevel = 5" />+        <Show Dialog="InstallFailed" OnExit="error" Condition="UILevel = 5" />+        <Show Dialog="InstallCancelled" OnExit="cancel" Condition="UILevel = 5" />+      </InstallUISequence>+      <AdminUISequence>+        <Show Dialog="InstallProgress" Before="ExecuteAction" Condition="UILevel = 5" />+        <Show Dialog="InstallComplete" OnExit="success" Condition="UILevel = 5" />+        <Show Dialog="InstallFailed" OnExit="error" Condition="UILevel = 5" />+        <Show Dialog="InstallCancelled" OnExit="cancel" Condition="UILevel = 5" />+      </AdminUISequence>+    </UI>+  </Fragment>+  <Fragment>+    <!-- Keep MSI registration for repair, upgrades and managed removal, but let Settings+         invoke our explicit UI command instead of Windows Installer's basic-UI ARP path. -->+    <Property Id="ARPSYSTEMCOMPONENT" Value="1" />+    <?if $(sys.BUILDARCH) = x86 ?>+      <?define CraftMsiSystemFolder = SystemFolder ?>+    <?else ?>+      <?define CraftMsiSystemFolder = System64Folder ?>+    <?endif ?>+    <ComponentGroup Id="CraftInstallerSettings" Directory="INSTALLFOLDER">+      <Component Id="CraftInstallerSettingsEntry" Guid="*">+        <RegistryKey Root="HKCU" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\Craft.craft-launcher" ForceDeleteOnUninstall="yes">+          <!-- Legacy Launchers match the canonical MSI name exactly; keep this alias distinct. -->+          <RegistryValue Name="DisplayName" Type="string" Value="[ProductName] (Craft Apps)" KeyPath="yes" />+          <RegistryValue Name="DisplayVersion" Type="string" Value="[ProductVersion]" />+          <RegistryValue Name="Publisher" Type="string" Value="[Manufacturer]" />+          <RegistryValue Name="DisplayIcon" Type="string" Value="&quot;[INSTALLFOLDER]artcraft-launcher.exe&quot;,0" />+          <RegistryValue Name="InstallLocation" Type="string" Value="[INSTALLFOLDER]" />+          <RegistryValue Name="URLInfoAbout" Type="string" Value="[ARPURLINFOABOUT]" />+          <RegistryValue Name="URLUpdateInfo" Type="string" Value="[ARPURLUPDATEINFO]" />+          <RegistryValue Name="HelpLink" Type="string" Value="[ARPHELPLINK]" />+          <!-- Deliberately omit WindowsInstaller: Settings must honor UninstallString. -->+          <RegistryValue Name="UninstallString" Type="string" Value="&quot;[$(var.CraftMsiSystemFolder)]msiexec.exe&quot; /x [ProductCode] /qf /norestart" />+          <RegistryValue Name="QuietUninstallString" Type="string" Value="&quot;[$(var.CraftMsiSystemFolder)]msiexec.exe&quot; /x [ProductCode] /qn /norestart" />+          <RegistryValue Name="ModifyPath" Type="string" Value="&quot;[$(var.CraftMsiSystemFolder)]msiexec.exe&quot; /i [ProductCode] /qf /norestart" />+        </RegistryKey>+      </Component>+      <!-- Preserve the app-owned ARPNOMODIFY policy instead of inventing a new default. -->+      <Component Id="CraftInstallerSettingsNoModify" Guid="*" Condition="ARPNOMODIFY = &quot;1&quot;">+        <RegistryValue Root="HKCU" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\Craft.craft-launcher"+            Name="NoModify" Type="integer" Value="1" KeyPath="yes" />+      </Component>+    </ComponentGroup>+  </Fragment>++</Wix>diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1--- a/packaging/windows/package.ps1+++ b/packaging/windows/package.ps1@@ -95,91 +95,13 @@ Copy-Item (Join-Path $Bin $Exe) $Stage & (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage $Exe)  # ---- MSI ----------------------------------------------------------------------------------------# The setup wizard's art, drawn here (System.Drawing) in getartcraft.com's style so the MSI shows-# ArtCraft rather than WiX's stock bitmaps. Both must be 24-bit BMPs:-#   - dialog, 493x312: the Welcome and Finish pages' backdrop. Their text sits on the right, so-#     that stays white; the left panel is ours: the blue ArtCraft mark, the white wordmark, a mono-#     "Launcher" label and the Craft apps' icons in a hairline grid.-#   - banner, 493x58: across the top of the inner pages. The page title is drawn over its left-#     side, so that stays white; the right end is a dark cell with the mark.-# The ArtCraft mark and wordmark are drawn as supplied (docs/brand/LICENSE-brand.txt).-function New-InstallerArt([string] $BannerOut, [string] $DialogOut) {-  Add-Type -AssemblyName System.Drawing-  $ink = [System.Drawing.ColorTranslator]::FromHtml('#121316')-  $accent = [System.Drawing.ColorTranslator]::FromHtml('#2d81ff')-  $muted = [System.Drawing.Color]::FromArgb(153, 242, 241, 238)-  $line = [System.Drawing.Color]::FromArgb(38, 242, 241, 238)-  # The Craft App Suite, in the launcher's order (crates/core/src/catalog.rs).-  $apps = 'photocraft', 'vectorcraft', 'filmcraft', 'lightcraft', 'pdfcraft', 'effectcraft',-    'designcraft', 'wordcraft', 'deckcraft', 'gridcraft', 'cadcraft', 'soundcraft', 'figcraft'-  $mark = [System.Drawing.Image]::FromFile((Join-Path $Root 'assets\app-icon\hicolor\256x256\apps\ai.storyteller.artcraft-launcher.png'))-  $wordmark = [System.Drawing.Image]::FromFile((Join-Path $Root 'docs\brand\artcraft-logo-white.png'))-  $icons = $apps | ForEach-Object { [System.Drawing.Image]::FromFile((Join-Path $Root "assets\app-icons\$_.png")) }-  # The site's label face; GDI+ draws a variable font at its default instance (Regular).-  $fonts = New-Object System.Drawing.Text.PrivateFontCollection-  $fonts.AddFontFile((Join-Path $Root 'assets\fonts\GeistMono-Variable.ttf'))-  $labelFont = New-Object System.Drawing.Font $fonts.Families[0], 9, ([System.Drawing.FontStyle]::Regular), ([System.Drawing.GraphicsUnit]::Pixel)-  try {-    foreach ($spec in @(@($BannerOut, 493, 58), @($DialogOut, 493, 312))) {-      $bmp = New-Object System.Drawing.Bitmap $spec[1], $spec[2], ([System.Drawing.Imaging.PixelFormat]::Format24bppRgb)-      $g = [System.Drawing.Graphics]::FromImage($bmp)-      $panel = New-Object System.Drawing.SolidBrush $ink-      $stripe = New-Object System.Drawing.SolidBrush $accent-      try {-        $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic-        $g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality-        $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias-        $g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit-        $g.Clear([System.Drawing.Color]::White)-        if ($spec[2] -eq 58) {-          $g.FillRectangle($panel, 405, 0, 88, 58)-          $g.FillRectangle($stripe, 405, 0, 2, 58)-          $g.DrawImage($mark, 431, 11, 36, 36)-        } else {-          $g.FillRectangle($panel, 0, 0, 164, 312)-          $g.FillRectangle($stripe, 162, 0, 2, 312)-          $g.DrawImage($mark, 16, 18, 44, 44)-          $g.DrawImage($wordmark, 22, 74, 120, [int](120 * $wordmark.Height / $wordmark.Width))-          # "LAUNCHER" in mono capitals, spaced like the site's labels (0.15 em).-          $brush = New-Object System.Drawing.SolidBrush $muted-          $x = 22.0-          foreach ($ch in 'LAUNCHER'.ToCharArray()) {-            $g.DrawString([string]$ch, $labelFont, $brush, $x, 94, [System.Drawing.StringFormat]::GenericTypographic)-            $x += $g.MeasureString([string]$ch, $labelFont, 100, [System.Drawing.StringFormat]::GenericTypographic).Width + 1.35-          }-          $brush.Dispose()-          # The apps, three to a row, in 36 px cells divided by hairlines (five rows fit above 312).-          $pen = New-Object System.Drawing.Pen $line, 1-          $left = 22; $top = 118; $cell = 36-          for ($i = 0; $i -lt $icons.Count; $i++) {-            $cx = $left + ($i % 3) * $cell-            $cy = $top + [math]::Floor($i / 3) * $cell-            $g.DrawRectangle($pen, $cx, $cy, $cell, $cell)-            $g.DrawImage($icons[$i], $cx + 5, $cy + 5, $cell - 10, $cell - 10)-          }-          $pen.Dispose()-        }-      } finally { $panel.Dispose(); $stripe.Dispose(); $g.Dispose() }-      $bmp.Save($spec[0], [System.Drawing.Imaging.ImageFormat]::Bmp)-      $bmp.Dispose()-    }-  } finally {-    $labelFont.Dispose(); $fonts.Dispose(); $mark.Dispose(); $wordmark.Dispose()-    $icons | ForEach-Object { $_.Dispose() }-  }-}-$Banner = Join-Path $Stage 'installer-banner.bmp'-$Dialog = Join-Path $Stage 'installer-dialog.bmp'-New-InstallerArt $Banner $Dialog- $Msi = Join-Path $Dist "$Pkg-$Version-windows-$Arch.msi" & (Join-Path $PSScriptRoot 'check-icons.ps1') Invoke-Native 'wix build' {-  wix build (Join-Path $PSScriptRoot "$Pkg.wxs") -arch $Arch `+  wix build (Join-Path $PSScriptRoot "$Pkg.wxs") (Join-Path $PSScriptRoot 'installer-ui.wxs') -arch $Arch `     -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext `     -culture en-US -loc (Join-Path $PSScriptRoot "$Pkg.en-us.wxl") `     -d "Version=$MsiVersion" -d "DisplayVersion=$Version" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root "assets\app-icon\$Pkg.ico")" `-    -d "BannerBmp=$Banner" -d "DialogBmp=$Dialog" `     -o $Msi } Invoke-Native 'MSI shortcut icon validation (ICE50)' {