mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-09 11:54:14 +00:00
feat(installer): add shared Windows MSI UI generator (INSTALLER-UI)
This commit is contained in:
Generated
+8
@@ -529,6 +529,14 @@ dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-installer"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"roxmltree",
|
||||
]
|
||||
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -17,6 +17,7 @@ craft-print = { path = "crates/print" }
|
||||
craft-quit = { path = "crates/quit" }
|
||||
craft-settings = { path = "crates/settings" }
|
||||
craft-ui = { path = "crates/ui" }
|
||||
craft-installer = { path = "crates/installer" }
|
||||
egui = { version = "0.36", default-features = false }
|
||||
rayon = "1"
|
||||
|
||||
|
||||
@@ -22,6 +22,19 @@ crates/cms/tests/data/FOGRA39.txt: FOGRA39 characterisation data, distributed un
|
||||
|
||||
Source provenance
|
||||
|
||||
The native Windows Installer UI in crates/installer is adapted from:
|
||||
- PdfCraft, packaging/windows/installer-ui.wxs, revision a6bc63a.
|
||||
https://github.com/storytold/pdfcraft
|
||||
Copyright (c) 2026 ArtCraft Team and the PdfCraft contributors.
|
||||
- GridCraft, packaging/windows/gridcraft.wxs, revision 5b15827.
|
||||
https://github.com/storytold/gridcraft
|
||||
Copyright (c) 2026 ArtCraft Team and the GridCraft contributors.
|
||||
- EffectCraft, packaging/windows/installer-ui.wxs, revision 1b4d47f.
|
||||
https://github.com/storytold/effectcraft
|
||||
Copyright (c) 2026 ArtCraft Team and the EffectCraft contributors.
|
||||
These sources are licensed MIT OR Apache-2.0. Their copyright notices are also retained
|
||||
in the generated fragment distributed by consumers.
|
||||
|
||||
The SVG icon preparation in crates/ui is adapted from:
|
||||
- PhotoCraft, crates/ui-egui/src/icons.rs, revision e7a83ca342cdff568254f85f442b9cc8afb08066.
|
||||
https://github.com/storytold/photocraft
|
||||
|
||||
@@ -34,6 +34,11 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
app menu. Unsaved-document decisions and save prompts stay in each app.
|
||||
|
||||
- [`craft-settings`](crates/settings/README.md): `config.toml` settings storage for every app: platform folders, portable mode and overrides, atomic saves while running, backup, damaged-file recovery and schema migrations. Policy in [docs/settings-storage.md](docs/settings-storage.md).
|
||||
- [`craft-installer`](crates/installer/README.md): shared native WiX setup UI and a Rust generator;
|
||||
one UI for all 14 Windows packages, retaining folder/shortcut/launch options and install, repair
|
||||
and uninstall completion screens, with checked-in consumer snapshots for offline builds.
|
||||
- [Install/uninstall audit](docs/install-uninstall-audit.md): the entire suite's packaging and
|
||||
Launcher workflows, extraction boundary and cross-platform library comparison.
|
||||
- [`craft-ui`](crates/ui/README.md): SVG catalogs, icon buttons, tabs, pane controls, transactional docking, print settings and theme helpers for egui frontends;
|
||||
assets, themes, localization and command handling stay in each app. Requires Rust 1.95 or later.
|
||||
- [Shared UI component audit](docs/ui-components-audit.md): source inventory, extraction priorities
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "craft-installer"
|
||||
description = "Shared native installer UI sources for the Craft App Suite"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
roxmltree = "0.20"
|
||||
@@ -0,0 +1,130 @@
|
||||
# craft-installer
|
||||
|
||||
The suite's common Windows MSI interface, rendered by a dependency-free Rust library and CLI.
|
||||
All 13 apps and ArtCraft Launcher use the same native WiX v5 dialogs, typography and completion
|
||||
screens. Each profile preserves the options its existing installer offered:
|
||||
|
||||
| Option | Apps |
|
||||
|---|---|
|
||||
| Destination folder | EffectCraft, FigCraft, LightCraft, PhotoCraft, VectorCraft, Launcher |
|
||||
| Desktop shortcut checkbox | CADCraft, EffectCraft, GridCraft, PdfCraft, VectorCraft, Launcher |
|
||||
| Launch on Finish checkbox | FigCraft, LightCraft, PhotoCraft, VectorCraft, WordCraft, Launcher |
|
||||
| License acceptance | WordCraft |
|
||||
| Per-user installation | Launcher; the 13 apps remain per-machine |
|
||||
|
||||
Defaults, previous-folder searches, shortcut persistence, launch actions, existing component GUIDs,
|
||||
upgrade codes, file associations and signing stay in the app's own `.wxs`. EffectCraft updates
|
||||
keep the destination locked. App-owned launch actions retain their existing execution context,
|
||||
including LightCraft's unelevated shell execution. No new installer framework is introduced.
|
||||
|
||||
## Generate and check
|
||||
|
||||
From a craft-libs checkout (Rust 1.95+):
|
||||
|
||||
```sh
|
||||
cargo run --locked -p craft-installer -- --write --app filmcraft ../filmcraft/packaging/windows/installer-ui.wxs
|
||||
cargo run --locked -p craft-installer -- --check --app filmcraft ../filmcraft/packaging/windows/installer-ui.wxs
|
||||
```
|
||||
|
||||
Use the repository name for `--app`, including `craft-launcher`. `WindowsApp::ALL` lists the
|
||||
14 profiles. Rust callers use `windows_ui`, `write_windows_ui` and `check_windows_ui` with that
|
||||
profile. Unknown profiles fail before writing; drift checks never modify the consumer's file.
|
||||
|
||||
Consumers reference `<UIRef Id="CraftInstallerUI" />` and pass the generated `installer-ui.wxs`
|
||||
to `wix build` alongside their package source. Their main feature must also reference
|
||||
`<ComponentGroupRef Id="CraftInstallerSettings" />`, which installs the Settings registration. Use this as the complete UI, without another
|
||||
`WixUI`/`WixUI_Common` wizard. Folder profiles that already used WiX's path validator keep the
|
||||
UI extension; existing launch actions keep the Util extension. Simple profiles need neither.
|
||||
No bitmap or font file is bundled: Windows renders its own controls and installed UI font. Settings calls the signed system
|
||||
`msiexec.exe` directly; no additional executable, script wrapper or runtime library is installed.
|
||||
|
||||
Check in the snapshots so each app builds independently, without a sibling checkout or new
|
||||
runtime dependency. The crate sets `publish = false`; publishing is unnecessary. When changing
|
||||
a template or profile, regenerate and `--check` every consumer. Templates live here, not in apps.
|
||||
|
||||
## Flow and completion
|
||||
|
||||
The welcome screen collects optional shortcut choices **before costing**. A private checkbox
|
||||
publishes explicit public 1/0 values, preserving opt-out through elevation and avoiding a saved
|
||||
preference being reloaded after the user unchecks it. Folder selection follows `CostFinalize`,
|
||||
uses native browsing and `SetTargetPath`, and retains the existing WiX validator where applicable.
|
||||
Maintenance follows `CostFinalize` before offering Repair/Remove: those feature events require
|
||||
the selection manager. A private UI operation value keeps their completion messages correct even
|
||||
though MSI only forwards the feature event’s `REINSTALL`/`REMOVE` flags to the execution session.
|
||||
Progress, MSI errors and files-in-use dialogs use the same UI.
|
||||
|
||||
Install, uninstall and repair each have an explicit success message and Finish button. Failure
|
||||
and cancellation have separate ending screens. Launch is offered only where it existed before,
|
||||
and only for successful installation/update, never removal, repair, administrative images or
|
||||
cancelled/failed setup. Every authored sequence entry requires `UILevel = 5`; `/qn`, `/qb` and
|
||||
`/passive` stay unattended. Settings/Control Panel get a separate entry at
|
||||
`Uninstall\Craft.<repository>` in HKLM for the apps and HKCU for Launcher. Its `UninstallString`
|
||||
selects `/x {ProductCode} /qf /norestart`, and `QuietUninstallString` selects `/qn /norestart`.
|
||||
The native MSI entry is hidden with `ARPSYSTEMCOMPONENT=1` while MSI registration remains intact.
|
||||
Its displayed name appends `(Craft Apps)`, preventing older Launchers from interpreting its
|
||||
non-GUID registry key as an MSI product code. The hidden native entry retains the canonical
|
||||
name and publisher for Launcher discovery. The visible entry omits `WindowsInstaller`, so Settings honors its command rather than invoking
|
||||
basic MSI removal. `ModifyPath` opens full maintenance; the existing `ARPNOMODIFY` policy is
|
||||
retained, including the implicit policy from the replaced stock WiX wizards. The visible entry is removed by MSI components, including during quiet removal.
|
||||
Plain command-line `/x` still uses Windows' default UI; use `/qf` when calling it interactively. Launcher-managed jobs have their own persistent completion dialog.
|
||||
|
||||
## Validation
|
||||
|
||||
`cargo test -p craft-installer` checks all 14 capability profiles, shortcut propagation, folder
|
||||
and maintenance sequencing, upgrade restrictions, launch guards, all three endings in both UI
|
||||
sequences, dialog references, keyboard dismissal and CLI drift/error behavior.
|
||||
|
||||
The [Windows verification record](../../docs/windows-installer-verification.md) records native
|
||||
linking, table checks, actual install/remove results and remaining limits. GridCraft/PdfCraft's
|
||||
shortcut directory aliases and key paths were corrected during native validation; physical
|
||||
locations, machine scope and component GUIDs remain stable. No package or release was published.
|
||||
|
||||
On Windows, compile and check actual package definitions without installing anything:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -File crates/installer/tests/windows-suite.ps1 -Repositories C:\work\CraftApps -Work C:\CraftInstallerTest\compiled
|
||||
```
|
||||
|
||||
This builds all 14 sources with fixture files for x64, x86 and ARM64. By default it runs full ICE
|
||||
validation on every architecture, then inspects UI tables. `-StartAt` resumes at an app;
|
||||
`-ValidationArchitectures x64` still builds/checks all three architectures but repeats ICE only
|
||||
for x64, useful after the package ownership rules have already passed a complete matrix.
|
||||
Requires WiX 5.0.2 and its UI/Util extensions. Windows PowerShell 5.1 and PowerShell 7 are supported.
|
||||
|
||||
Run the destructive runtime checks **only on a clean disposable Windows desktop** with an
|
||||
administrator account. The fixtures share the real products' UpgradeCodes: running them on a
|
||||
normal development machine could replace real apps. Each runner requires `-DisposableMachine`,
|
||||
checks fixture version 90.0.0 and rejects existing related products before starting. The UI
|
||||
runner accepts `-StartAt <repository-name>` after a failed fixture has been cleaned up; it still
|
||||
checks every profile for existing installations before resuming.
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -File crates/installer/tests/windows-ui.ps1 -DisposableMachine -Packages C:\CraftInstallerTest\compiled
|
||||
powershell -NoProfile -File crates/installer/tests/windows-options.ps1 -DisposableMachine -Packages C:\CraftInstallerTest\compiled -Repositories C:\work\CraftApps
|
||||
powershell -NoProfile -File crates/installer/tests/windows-settings.ps1 -DisposableMachine -Packages C:\CraftInstallerTest\compiled
|
||||
powershell -NoProfile -File crates/installer/tests/windows-x86.ps1 -DisposableMachine -Packages C:\CraftInstallerTest\compiled
|
||||
```
|
||||
|
||||
The UI runner explicitly selects `/qf` and checks every x64 app's full install, missing-file repair, cached-product uninstall,
|
||||
correct completion text and explicit Finish, destination paths containing spaces/non-ASCII,
|
||||
Start menu cleanup, desktop opt-out and a retained user-document sentinel. It also exercises
|
||||
cancellation, maintenance removal and `/qn`, `/qb`, `/passive`. Screenshots, control inventories
|
||||
and verbose MSI logs remain under the fixture output directory.
|
||||
|
||||
The Settings runner checks all 14 entries, interactive and quiet commands, hidden native MSI
|
||||
registration, Modify policy and removal of both entries. Exercise the actual Settings button
|
||||
workflow separately on the visible desktop. The options runner checks folder retention through upgrades, shortcut opt-in/repair/upgrade
|
||||
opt-out, GridCraft removal as SYSTEM, downgrade failure and all six launch checkboxes using an
|
||||
original GUI fixture that records launches/account identity. The x86 runner checks every app's
|
||||
silent install, 32-bit App Paths registration and cached removal. These are packaging/UI checks,
|
||||
not tests of real application binaries, signing, screen readers, UAC consent or ARM64 execution.
|
||||
Existing EffectCraft/GridCraft install tests also consume the shared UI; PdfCraft's table test
|
||||
checks scope, shortcut ownership and native controls.
|
||||
|
||||
For release acceptance, additionally use real packages on Windows 10/11 and native ARM64;
|
||||
check keyboard/DPI/accessibility, UAC and launch privileges, files in use/Restart Manager,
|
||||
reboot-required success, command-line option overrides and actual document/settings locations.
|
||||
|
||||
The shared UI adapts the existing PdfCraft, GridCraft and EffectCraft sources under MIT OR
|
||||
Apache-2.0; see NOTICE. macOS/Linux packaging and the Launcher backend remain platform-owned.
|
||||
See the [suite audit](../../docs/install-uninstall-audit.md) for the extraction rationale.
|
||||
@@ -0,0 +1,40 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::process::ExitCode;
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
let mode = args.next();
|
||||
let flag = args.next();
|
||||
let app = args.next();
|
||||
let path = args.next();
|
||||
let (Some(mode), Some(flag), Some(app), Some(path)) = (mode, flag, app, path) else {
|
||||
return Err("usage: craft-installer --write|--check --app <repository-name> <installer-ui.wxs>".into());
|
||||
};
|
||||
if flag != OsStr::new("--app") || args.next().is_some() {
|
||||
return Err("expected --app, an app name and exactly one output path".into());
|
||||
}
|
||||
let app = app.to_str().ok_or("app name must be UTF-8")?.parse()?;
|
||||
if mode == OsStr::new("--write") {
|
||||
craft_installer::write_windows_ui(app, &path).map_err(|e| e.to_string())
|
||||
} else if mode == OsStr::new("--check") {
|
||||
if craft_installer::check_windows_ui(app, &path).map_err(|e| e.to_string())? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("installer UI differs from craft-libs; regenerate with --write".into())
|
||||
}
|
||||
} else {
|
||||
Err("expected --write or --check".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("{e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Shared Windows Installer UI, rendered as checked-in WiX sources.
|
||||
//!
|
||||
//! Apps keep their installation policy, existing component identities and custom actions. Release
|
||||
//! builds consume snapshots, so they need neither a sibling checkout nor a published crate.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
/// The suite's installer profiles. Options reflect the existing Windows packages.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum WindowsApp {
|
||||
Cadcraft,
|
||||
Deckcraft,
|
||||
Designcraft,
|
||||
Effectcraft,
|
||||
Figcraft,
|
||||
Filmcraft,
|
||||
Gridcraft,
|
||||
Lightcraft,
|
||||
Pdfcraft,
|
||||
Photocraft,
|
||||
Soundcraft,
|
||||
Vectorcraft,
|
||||
Wordcraft,
|
||||
Launcher,
|
||||
}
|
||||
|
||||
impl WindowsApp {
|
||||
/// Every Windows MSI consumer, including the launcher.
|
||||
pub const ALL: [Self; 14] = [
|
||||
Self::Cadcraft,
|
||||
Self::Deckcraft,
|
||||
Self::Designcraft,
|
||||
Self::Effectcraft,
|
||||
Self::Figcraft,
|
||||
Self::Filmcraft,
|
||||
Self::Gridcraft,
|
||||
Self::Lightcraft,
|
||||
Self::Pdfcraft,
|
||||
Self::Photocraft,
|
||||
Self::Soundcraft,
|
||||
Self::Vectorcraft,
|
||||
Self::Wordcraft,
|
||||
Self::Launcher,
|
||||
];
|
||||
|
||||
/// Repository name, used by the generator's `--app` option.
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Cadcraft => "cadcraft",
|
||||
Self::Deckcraft => "deckcraft",
|
||||
Self::Designcraft => "designcraft",
|
||||
Self::Effectcraft => "effectcraft",
|
||||
Self::Figcraft => "figcraft",
|
||||
Self::Filmcraft => "filmcraft",
|
||||
Self::Gridcraft => "gridcraft",
|
||||
Self::Lightcraft => "lightcraft",
|
||||
Self::Pdfcraft => "pdfcraft",
|
||||
Self::Photocraft => "photocraft",
|
||||
Self::Soundcraft => "soundcraft",
|
||||
Self::Vectorcraft => "vectorcraft",
|
||||
Self::Wordcraft => "wordcraft",
|
||||
Self::Launcher => "craft-launcher",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the existing installer offers a destination folder picker.
|
||||
pub const fn has_folder_picker(self) -> bool {
|
||||
matches!(self, Self::Effectcraft | Self::Figcraft | Self::Lightcraft | Self::Photocraft | Self::Vectorcraft | Self::Launcher)
|
||||
}
|
||||
|
||||
/// Public property already used by the app's desktop shortcut component.
|
||||
pub const fn desktop_property(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Cadcraft | Self::Effectcraft | Self::Gridcraft => Some("DESKTOPSHORTCUT"),
|
||||
Self::Pdfcraft | Self::Vectorcraft | Self::Launcher => Some("INSTALLDESKTOPSHORTCUT"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// App-owned launch action; retained only where the old installer offered it.
|
||||
pub const fn launch_action(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Figcraft => Some("LaunchFigcraft"),
|
||||
Self::Lightcraft => Some("LaunchLightcraft"),
|
||||
Self::Photocraft => Some("LaunchPhotocraft"),
|
||||
Self::Vectorcraft => Some("LaunchVectorcraft"),
|
||||
Self::Wordcraft => Some("LaunchWordcraft"),
|
||||
Self::Launcher => Some("LaunchArtCraftLauncher"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for WindowsApp {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(name: &str) -> Result<Self, Self::Err> {
|
||||
Self::ALL.into_iter().find(|app| app.name() == name).ok_or_else(|| format!("unknown Windows app: {name}"))
|
||||
}
|
||||
}
|
||||
|
||||
const UI: &str = include_str!("../templates/installer-ui.wxs");
|
||||
const FOLDER: &str = include_str!("../templates/folder.wxs.in");
|
||||
const LICENSE: &str = include_str!("../templates/license.wxs.in");
|
||||
const FOLDER_INSTALL: &str = include_str!("../templates/folder-install.wxs.in");
|
||||
const SETTINGS: &str = include_str!("../templates/settings.wxs.in");
|
||||
// WiX's existing path validator is retained for the apps that previously used it.
|
||||
const PATH_VALID: &str = "WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID = "1"";
|
||||
const LAUNCH_ALLOWED: &str = "NOT Installed AND NOT REMOVE AND NOT REINSTALL AND NOT CraftInstallerOperation AND ACTION <> "ADMIN"";
|
||||
|
||||
fn path_validation(order: u8) -> String {
|
||||
format!(
|
||||
r#" <Publish Event="DoAction" Value="WixUIValidatePath_$(sys.BUILDARCHSHORT)" Order="{order}" Condition="NOT WIXUI_DONTVALIDATEPATH" />
|
||||
<Publish Event="SpawnDialog" Value="CraftInvalidFolder" Order="{}" Condition="NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID <> "1"" />"#,
|
||||
order + 1
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders the common UI with an app's existing folder, shortcut and launch capabilities.
|
||||
///
|
||||
/// Desktop choices are explicit 1/0 values before costing, including the elevated execute
|
||||
/// sequence. Folder selection follows CostFinalize. Success, failure and cancellation have
|
||||
/// separate screens; full-UI removal reports uninstall completion and never offers launch.
|
||||
/// Settings uses an app-owned ARP entry selecting full UI; its quiet command selects `/qn`.
|
||||
/// Basic (`/qb`, `/passive`) and silent (`/qn`) installs and removals remain unattended.
|
||||
pub fn windows_ui(app: WindowsApp) -> String {
|
||||
let folder = app.has_folder_picker();
|
||||
let validate_path = folder && app != WindowsApp::Effectcraft;
|
||||
let license = app == WindowsApp::Wordcraft;
|
||||
let shield = if app == WindowsApp::Launcher { "no" } else { "yes" };
|
||||
let folder_install = FOLDER_INSTALL.trim_end();
|
||||
let initial_folder_install = format!("{folder_install}\n");
|
||||
let mut extra = if folder {
|
||||
// EffectCraft locks the destination during upgrades; initially focus an enabled control.
|
||||
FOLDER
|
||||
.replace("@@INSTALL_BEFORE_FOLDER@@", if app == WindowsApp::Effectcraft { &initial_folder_install } else { "" })
|
||||
.replace("@@INSTALL_AFTER_FOLDER@@", if app == WindowsApp::Effectcraft { "" } else { folder_install })
|
||||
} else if license {
|
||||
LICENSE.to_owned()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
extra = extra
|
||||
.replace("@@SHIELD@@", shield)
|
||||
.replace("@@PATH_VALID@@", if validate_path { PATH_VALID } else { "1" })
|
||||
.replace("@@INSTALL_VALIDATION@@", &if validate_path { path_validation(2) } else { String::new() })
|
||||
.replace("@@BROWSE_VALIDATION@@", &if validate_path { path_validation(3) } else { String::new() })
|
||||
.replace("@@FOLDER_LOCK@@", if app == WindowsApp::Effectcraft { "DisableCondition=\"WIX_UPGRADE_DETECTED\"" } else { "" })
|
||||
.replace("@@UPGRADE_NOTE@@", if app == WindowsApp::Effectcraft { r#" <Control Id="UpgradeNote" Type="Text" X="20" Y="160" Width="330" Height="40" Hidden="yes" ShowCondition="WIX_UPGRADE_DETECTED" Text="This update uses your existing installation folder." />"# } else { "" });
|
||||
let (mut shortcut_setup, mut shortcut_control, mut shortcut_events) = if let Some(property) = app.desktop_property() {
|
||||
// A private checkbox avoids the MSI convention that an unchecked box clears a public
|
||||
// property. Explicit zero preserves opt-out across AppSearch and elevation.
|
||||
(format!(r#" <SetProperty Id="CraftDesktopShortcutChoice" Value="1" Before="InstallWelcome" Sequence="ui"
|
||||
Condition="UILevel = 5 AND NOT Installed AND NOT REMOVE AND NOT REINSTALL AND {property} = "1"" />"#),
|
||||
r#" <Control Id="DesktopShortcut" Type="CheckBox" X="20" Y="125" Width="330" Height="20" Property="CraftDesktopShortcutChoice" CheckBoxValue="1" Text="Create a &desktop shortcut" />"#.to_owned(),
|
||||
format!(r#" <Publish Property="{property}" Value="1" Order="1" Condition="CraftDesktopShortcutChoice = "1"" />
|
||||
<Publish Property="{property}" Value="0" Order="2" Condition="NOT CraftDesktopShortcutChoice" />"#))
|
||||
} else {
|
||||
(String::new(), String::new(), String::new())
|
||||
};
|
||||
if app == WindowsApp::Effectcraft {
|
||||
// The elevated execute sequence must retain the explicit choice made in the wizard.
|
||||
shortcut_events.push_str(
|
||||
r#"
|
||||
<Publish Property="CRAFTDESKTOPSHORTCUTSELECTED" Value="1" Order="3" />"#,
|
||||
);
|
||||
}
|
||||
if app == WindowsApp::Pdfcraft {
|
||||
// As with desktop choices, publish explicit values so an unchecked option survives elevation.
|
||||
shortcut_setup.push_str(
|
||||
r#"
|
||||
<SetProperty Id="CraftPdfContextMenuChoice" Value="1" Before="InstallWelcome" Sequence="ui"
|
||||
Condition="UILevel = 5 AND NOT Installed AND NOT REMOVE AND NOT REINSTALL AND INSTALLPDFCONTEXTMENU = "1"" />"#,
|
||||
);
|
||||
shortcut_control.push_str(r#"
|
||||
<Control Id="PdfContextMenu" Type="CheckBox" X="20" Y="153" Width="330" Height="20" Property="CraftPdfContextMenuChoice" CheckBoxValue="1" Text="Add "&Edit with PdfCraft" to the right-click menu of PDF files" />"#);
|
||||
shortcut_events.push_str(
|
||||
r#"
|
||||
<Publish Property="INSTALLPDFCONTEXTMENU" Value="1" Order="3" Condition="CraftPdfContextMenuChoice = "1"" />
|
||||
<Publish Property="INSTALLPDFCONTEXTMENU" Value="0" Order="4" Condition="NOT CraftPdfContextMenuChoice" />"#,
|
||||
);
|
||||
}
|
||||
let (launch_control, launch_event) = if let Some(action) = app.launch_action() {
|
||||
(r#" <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 = "ADMIN"" />"#.to_owned(),
|
||||
format!(r#" <Publish Event="DoAction" Value="{action}" Order="1" Condition="WIXUI_EXITDIALOGOPTIONALCHECKBOX = "1" AND {LAUNCH_ALLOWED}" />"#))
|
||||
} else {
|
||||
(String::new(), String::new())
|
||||
};
|
||||
let welcome_event = if license {
|
||||
r#" <Publish Event="NewDialog" Value="InstallLicense" Order="99" />"#
|
||||
} else {
|
||||
r#" <Publish Event="EndDialog" Value="Return" Order="99" />"#
|
||||
};
|
||||
let rendered = UI.replace("@@APP@@", app.name())
|
||||
.replace("@@SETTINGS@@", &SETTINGS
|
||||
.replace("@@APP@@", app.name())
|
||||
.replace("@@REGISTRY_ROOT@@", if app == WindowsApp::Launcher { "HKCU" } else { "HKLM" })
|
||||
.replace("@@EXE@@", &if app == WindowsApp::Launcher { "artcraft-launcher.exe".to_owned() } else { format!("{}.exe", app.name()) }))
|
||||
.replace("@@SHORTCUT_SETUP@@", &shortcut_setup)
|
||||
.replace("@@PATH_ACTION@@", if validate_path { r#" <CustomActionRef Id="WixUIValidatePath_$(sys.BUILDARCHSHORT)" />"# } else { "" })
|
||||
.replace("@@EXTRA_UI@@", &extra)
|
||||
.replace("@@SHIELD@@", shield)
|
||||
.replace("@@WELCOME_SHIELD@@", if folder || license { "no" } else { shield })
|
||||
.replace("@@DESCRIPTION@@", if app == WindowsApp::Launcher { "Setup will install [ProductName] for your Windows account and add it to the Start menu. Click Next to choose its folder." } else if folder { "Setup will install [ProductName] for all users and add it to the Start menu. Click Next to choose its folder." } else if license { "Setup will install [ProductName] for all users and add it to the Start menu. Click Next to review the license." } else { "Setup will install [ProductName] for all users and add it to the Start menu. Click Install to continue." })
|
||||
.replace("@@SHORTCUT_CONTROL@@", &shortcut_control)
|
||||
.replace("@@SHORTCUT_EVENTS@@", &shortcut_events)
|
||||
.replace("@@WELCOME_BUTTON@@", if folder || license { "&Next" } else { "&Install" })
|
||||
.replace("@@WELCOME_EVENT@@", welcome_event)
|
||||
.replace("@@LAUNCH_CONTROL@@", &launch_control)
|
||||
.replace("@@LAUNCH_EVENT@@", &launch_event)
|
||||
.replace("@@FOLDER_SHOW@@", if folder { r#" <Show Dialog="InstallFolder" After="InstallMaintenance" Condition="UILevel = 5 AND NOT Installed AND NOT REMOVE AND NOT REINSTALL" />"# } else { "" });
|
||||
rendered.lines().map(str::trim_end).collect::<Vec<_>>().join("\n") + "\n"
|
||||
}
|
||||
|
||||
/// Writes a snapshot to an explicitly chosen output. Never invokes an installer.
|
||||
pub fn write_windows_ui(app: WindowsApp, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
|
||||
std::fs::write(path, windows_ui(app))
|
||||
}
|
||||
|
||||
/// Checks a consumer's snapshot without modifying it.
|
||||
pub fn check_windows_ui(app: WindowsApp, path: impl AsRef<std::path::Path>) -> std::io::Result<bool> {
|
||||
Ok(std::fs::read_to_string(path)? == windows_ui(app))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<Control Id="Install" Type="PushButton" X="195" Y="235" Width="75" Height="23" Default="yes" ElevationShield="@@SHIELD@@" Text="&Install">
|
||||
<Publish Event="SetTargetPath" Value="INSTALLFOLDER" Order="1" />
|
||||
@@INSTALL_VALIDATION@@
|
||||
<Publish Event="EndDialog" Value="Return" Order="99" Condition="@@PATH_VALID@@" />
|
||||
</Control>
|
||||
@@ -0,0 +1,48 @@
|
||||
<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." />
|
||||
@@INSTALL_BEFORE_FOLDER@@ <Control Id="FolderLabel" Type="Text" X="20" Y="105" Width="330" Height="15" Text="&Destination folder:" />
|
||||
<Control Id="Folder" Type="PathEdit" X="20" Y="125" Width="250" Height="20" Property="_BrowseProperty" Indirect="yes" @@FOLDER_LOCK@@ />
|
||||
<Control Id="ChangeFolder" Type="PushButton" X="280" Y="124" Width="70" Height="23" Text="&Browse..." @@FOLDER_LOCK@@>
|
||||
<Publish Event="SpawnDialog" Value="CraftBrowseDlg" />
|
||||
</Control>
|
||||
@@UPGRADE_NOTE@@
|
||||
@@INSTALL_AFTER_FOLDER@@
|
||||
<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="&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="&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&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="&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" />
|
||||
@@BROWSE_VALIDATION@@
|
||||
<Publish Event="EndDialog" Value="Return" Order="99" Condition="@@PATH_VALID@@" />
|
||||
</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>
|
||||
@@ -0,0 +1,195 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by craft-installer for @@APP@@. 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)" />
|
||||
@@SHORTCUT_SETUP@@
|
||||
@@PATH_ACTION@@
|
||||
<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>
|
||||
|
||||
@@EXTRA_UI@@
|
||||
<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="@@DESCRIPTION@@" />
|
||||
@@SHORTCUT_CONTROL@@
|
||||
<Control Id="Install" Type="PushButton" X="195" Y="235" Width="75" Height="23" Default="yes" ElevationShield="@@WELCOME_SHIELD@@" Text="@@WELCOME_BUTTON@@">
|
||||
@@SHORTCUT_EVENTS@@
|
||||
@@WELCOME_EVENT@@
|
||||
</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="@@SHIELD@@" Text="&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="@@SHIELD@@" Text="R&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="&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="&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 = "ALL" OR CraftInstallerOperation = "remove"" />
|
||||
<Control Id="RemovedTitle" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\CraftInstallerTitle}Uninstall completed successfully" Hidden="yes" ShowCondition="REMOVE = "ALL" OR CraftInstallerOperation = "remove"" />
|
||||
<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 = "ALL" OR REINSTALL OR CraftInstallerOperation OR ACTION = "ADMIN"" />
|
||||
<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 = "ALL" OR CraftInstallerOperation = "remove"" />
|
||||
<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 = "repair") AND NOT REMOVE AND CraftInstallerOperation <> "remove" AND ACTION <> "ADMIN"" />
|
||||
<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 = "ADMIN"" />
|
||||
<Control Id="Finish" Type="PushButton" X="280" Y="235" Width="70" Height="23" Default="yes" Cancel="yes" Text="&Finish">
|
||||
@@LAUNCH_EVENT@@
|
||||
<Publish Event="EndDialog" Value="Return" Order="999" />
|
||||
</Control>
|
||||
@@LAUNCH_CONTROL@@
|
||||
</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="&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="&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" />
|
||||
@@FOLDER_SHOW@@
|
||||
<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>
|
||||
@@SETTINGS@@
|
||||
</Wix>
|
||||
@@ -0,0 +1,13 @@
|
||||
<Dialog Id="InstallLicense" Width="370" Height="270" Title="[ProductName] Setup">
|
||||
<Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="25" Text="{\CraftInstallerTitle}License" />
|
||||
<Control Id="License" Type="ScrollableText" X="20" Y="55" Width="330" Height="130" Sunken="yes" TabSkip="no">
|
||||
<Text SourceFile="$(var.LicenseRtf)" />
|
||||
</Control>
|
||||
<Control Id="Accept" Type="CheckBox" X="20" Y="195" Width="330" Height="20" Property="CraftLicenseAccepted" CheckBoxValue="1" Text="I &accept the license terms" />
|
||||
<Control Id="Install" Type="PushButton" X="195" Y="235" Width="75" Height="23" Default="yes" ElevationShield="yes" Text="&Install" Disabled="yes" EnableCondition="CraftLicenseAccepted = "1"" DisableCondition="NOT CraftLicenseAccepted">
|
||||
<Publish Event="EndDialog" Value="Return" Condition="CraftLicenseAccepted = "1"" />
|
||||
</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>
|
||||
@@ -0,0 +1,34 @@
|
||||
<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="@@REGISTRY_ROOT@@" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\Craft.@@APP@@" 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=""[INSTALLFOLDER]@@EXE@@",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=""[$(var.CraftMsiSystemFolder)]msiexec.exe" /x [ProductCode] /qf /norestart" />
|
||||
<RegistryValue Name="QuietUninstallString" Type="string" Value=""[$(var.CraftMsiSystemFolder)]msiexec.exe" /x [ProductCode] /qn /norestart" />
|
||||
<RegistryValue Name="ModifyPath" Type="string" Value=""[$(var.CraftMsiSystemFolder)]msiexec.exe" /i [ProductCode] /qf /norestart" />
|
||||
</RegistryKey>
|
||||
</Component>
|
||||
<!-- Preserve the app-owned ARPNOMODIFY policy instead of inventing a new default. -->
|
||||
<Component Id="CraftInstallerSettingsNoModify" Guid="*" Condition="ARPNOMODIFY = "1"">
|
||||
<RegistryValue Root="@@REGISTRY_ROOT@@" Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\Craft.@@APP@@"
|
||||
Name="NoModify" Type="integer" Value="1" KeyPath="yes" />
|
||||
</Component>
|
||||
</ComponentGroup>
|
||||
</Fragment>
|
||||
@@ -0,0 +1,23 @@
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn generation_and_drift_check_handle_paths_with_spaces_without_modifying_drift() {
|
||||
let dir = std::env::temp_dir().join(format!("craft installer test {}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("installer ui.wxs");
|
||||
let run = |mode| Command::new(env!("CARGO_BIN_EXE_craft-installer")).arg(mode).args(["--app", "gridcraft"]).arg(&path).output().unwrap();
|
||||
assert!(run("--write").status.success());
|
||||
assert!(run("--check").status.success());
|
||||
std::fs::write(&path, "local change").unwrap();
|
||||
assert!(!run("--check").status.success());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "local change");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_profile_cannot_write_a_file() {
|
||||
let path = std::env::temp_dir().join(format!("craft-invalid-profile-{}.wxs", std::process::id()));
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_craft-installer")).args(["--write", "--app", "unknown-app"]).arg(&path).output().unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert!(!path.exists());
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Exercise fixture MSI options on a clean, disposable Windows desktop.
|
||||
.EXAMPLE
|
||||
.\windows-options.ps1 -DisposableMachine -Packages C:\CraftInstallerTest\compiled -Repositories C:\CraftInstallerTest\repos
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)] [switch] $DisposableMachine,
|
||||
[Parameter(Mandatory)] [string] $Packages,
|
||||
[string] $Results = (Join-Path $Packages 'options'),
|
||||
[Parameter(Mandatory)] [string] $Repositories,
|
||||
[string] $ModelsDirectory
|
||||
)
|
||||
if (-not $DisposableMachine) { throw 'An explicitly disposable Windows machine is required.' }
|
||||
$ErrorActionPreference='Stop'
|
||||
. (Join-Path $PSScriptRoot 'windows-ui.ps1') -DisposableMachine -Packages $Packages -Results $Results -DefineOnly
|
||||
Assert-CleanFixtures x64
|
||||
$Repos=$Repositories
|
||||
function MainPath($app) {
|
||||
$exe=if($app -eq 'craft-launcher'){'artcraft-launcher.exe'}else{"$app.exe"}
|
||||
$scope=if($app -eq 'craft-launcher'){'HKCU:'}else{'HKLM:'}
|
||||
(Get-Item "$scope\Software\Microsoft\Windows\CurrentVersion\App Paths\$exe").GetValue('')
|
||||
}
|
||||
function Assert-SettingsProduct($app,$msi) {
|
||||
$scope=if($app -eq 'craft-launcher'){'HKCU:'}else{'HKLM:'}
|
||||
$key=Get-Item "$scope\Software\Microsoft\Windows\CurrentVersion\Uninstall\Craft.$app"
|
||||
$db=$engine.OpenDatabase($msi,0)
|
||||
$code=Value $db 'SELECT `Value` FROM `Property` WHERE `Property` = ''ProductCode'''
|
||||
$version=Value $db 'SELECT `Value` FROM `Property` WHERE `Property` = ''ProductVersion'''
|
||||
Assert ($key.GetValue('DisplayVersion') -eq $version -and $key.GetValue('UninstallString') -like "*/x $code /qf /norestart") "$app Settings registration did not follow repair/upgrade"
|
||||
}
|
||||
function UpgradePackage($app,$bin='') {
|
||||
$name=if($app -eq 'craft-launcher'){'artcraft-launcher'}else{$app}
|
||||
$pack=Join-Path $Repos "$app\packaging\windows"
|
||||
if(-not $bin){$bin=Join-Path $Packages "$app-bin"}
|
||||
$msi=Join-Path $Packages "$app-upgrade.msi"
|
||||
$modelArgs=@();if($app -eq 'pdfcraft'){if(-not $ModelsDirectory -or -not(Test-Path (Join-Path $ModelsDirectory 'ATTRIBUTION.txt'))){throw 'PdfCraft option fixtures require qualified -ModelsDirectory'};$modelArgs=@('-d',"ModelsDir=$ModelsDirectory")}
|
||||
& wix build "$pack\$name.wxs" "$pack\installer-ui.wxs" -arch x64 @modelArgs -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext -d Version=90.0.1 -d DisplayVersion=90.0.1 -d "BinDir=$bin" -d "IconPath=$Repos\$app\assets\app-icon\$name.ico" -d HasCli=1 -d "LicenseRtf=$Packages\license.rtf" -o $msi | Out-Host
|
||||
Assert ($LASTEXITCODE -eq 0) "$app upgrade fixture failed to build"
|
||||
return $msi
|
||||
}
|
||||
# Upgrade every folder profile, keeping a custom directory selected through the full UI.
|
||||
foreach($app in 'effectcraft','figcraft','lightcraft','photocraft','vectorcraft','craft-launcher') {
|
||||
$old=Join-Path $Packages "$app-x64.msi";$new=UpgradePackage $app
|
||||
$folder="C:\CraftInstallerTest\Upgrade folder $([char]0xe9) $app"
|
||||
$p=Start-Msi $old '/i' "$app-folder-first";Complete $p 'install' "$app-folder-first" $folder
|
||||
$before=MainPath $app
|
||||
$p=Start-Msi $new '/i' "$app-folder-upgrade";Complete $p 'install' "$app-folder-upgrade"
|
||||
Assert ((MainPath $app) -eq $before) "$app upgrade lost the custom directory"
|
||||
Assert-SettingsProduct $app $new
|
||||
if($app -eq 'figcraft'){$p=Start-Msi $old '/i' 'figcraft-downgrade';Complete $p 'fail' 'figcraft-downgrade';Assert ((MainPath $app) -eq $before) 'Failed downgrade changed the installation'}
|
||||
$p=Start-Msi $new '/x' "$app-folder-remove";Complete $p 'remove' "$app-folder-remove"
|
||||
}
|
||||
# Every optional desktop shortcut can be enabled through the full UI and cleaned up.
|
||||
foreach($app in 'cadcraft','effectcraft','gridcraft','pdfcraft','vectorcraft','craft-launcher') {
|
||||
$msi=Join-Path $Packages "$app-x64.msi"
|
||||
$db=$engine.OpenDatabase($msi,0);$product=Value $db 'SELECT `Value` FROM `Property` WHERE `Property` = ''ProductName'''
|
||||
$desktop=Join-Path ([Environment]::GetFolderPath($(if($app -eq 'craft-launcher'){'DesktopDirectory'}else{'CommonDesktopDirectory'}))) "$product.lnk"
|
||||
$p=Start-Msi $msi '/i' "$app-desktop-on";Complete $p 'install' "$app-desktop-on" '' $true
|
||||
Assert (Test-Path -LiteralPath $desktop) "$app desktop opt-in ignored"
|
||||
Remove-Item -LiteralPath $desktop
|
||||
$scope=if($app -eq 'craft-launcher'){'HKCU:'}else{'HKLM:'}
|
||||
Remove-Item "$scope\Software\Microsoft\Windows\CurrentVersion\Uninstall\Craft.$app" -Recurse
|
||||
$p=Start-Msi $msi '/i' "$app-desktop-repair";Complete $p 'repair' "$app-desktop-repair"
|
||||
Assert (Test-Path -LiteralPath $desktop) "$app repair lost desktop opt-in"
|
||||
Assert-SettingsProduct $app $msi
|
||||
$new=UpgradePackage $app
|
||||
$p=Start-Msi $new '/i' "$app-desktop-upgrade-off";Complete $p 'install' "$app-desktop-upgrade-off" '' $false $false $true
|
||||
Assert (-not (Test-Path -LiteralPath $desktop)) "$app upgrade ignored desktop opt-out"
|
||||
Assert-SettingsProduct $app $new
|
||||
$p=Start-Msi $new '/x' "$app-desktop-remove";Complete $p 'remove' "$app-desktop-remove"
|
||||
Assert (-not (Test-Path -LiteralPath $desktop)) "$app left desktop shortcut behind"
|
||||
if($app -eq 'gridcraft') {
|
||||
$p=Start-Msi $msi '/i' 'gridcraft-system-first';Complete $p 'install' 'gridcraft-system-first' '' $true
|
||||
$db=$engine.OpenDatabase($msi,0);$code=Value $db 'SELECT `Value` FROM `Property` WHERE `Property` = ''ProductCode'''
|
||||
$task='CraftInstallerTest-'+[guid]::NewGuid().ToString('N')
|
||||
$log=Join-Path $Results 'gridcraft-system-remove.log'
|
||||
$action=New-ScheduledTaskAction -Execute "$env:WINDIR\System32\msiexec.exe" -Argument "/x $code /qn /norestart /l*v `"$log`""
|
||||
$principal=New-ScheduledTaskPrincipal -UserId SYSTEM -LogonType ServiceAccount -RunLevel Highest
|
||||
try {
|
||||
Register-ScheduledTask -TaskName $task -Action $action -Principal $principal -Settings (New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 5))|Out-Null
|
||||
$started=Get-Date;Start-ScheduledTask $task;$deadline=$started.AddMinutes(5)
|
||||
do {Start-Sleep -Milliseconds 500;$info=Get-ScheduledTaskInfo $task;$state=(Get-ScheduledTask $task).State} while(($info.LastRunTime -lt $started.AddSeconds(-1) -or $state -eq 'Running') -and (Get-Date) -lt $deadline)
|
||||
Assert ($info.LastTaskResult -eq 0) "SYSTEM removal failed: $($info.LastTaskResult)"
|
||||
Assert ($engine.ProductState($code) -eq -1) 'SYSTEM left GridCraft registered'
|
||||
Assert (-not (Test-Path $desktop)) 'SYSTEM left the Public desktop shortcut'
|
||||
$key=Get-Item HKLM:\Software\GridCraft\Installer -ErrorAction SilentlyContinue
|
||||
Assert ($null -eq $key -or $null -eq $key.GetValue('DesktopShortcutComponent')) 'SYSTEM left the shortcut marker'
|
||||
$key=Get-Item HKCU:\Software\GridCraft\Installer -ErrorAction SilentlyContinue
|
||||
Assert ($null -eq $key -or $null -eq $key.GetValue('DesktopShortcutComponent')) 'Shortcut marker leaked into HKCU'
|
||||
'ok gridcraft SYSTEM uninstall cleaned up the machine shortcut and marker'
|
||||
} finally {Stop-ScheduledTask $task -ErrorAction SilentlyContinue;Unregister-ScheduledTask $task -Confirm:$false -ErrorAction SilentlyContinue}
|
||||
}
|
||||
}
|
||||
# EffectCraft's unattended upgrades retain the saved shortcut choice without wizard input.
|
||||
$app='effectcraft';$old=Join-Path $Packages 'effectcraft-x64.msi';$new=UpgradePackage $app
|
||||
$desktop=Join-Path ([Environment]::GetFolderPath('CommonDesktopDirectory')) 'EffectCraft.lnk'
|
||||
foreach($enabled in @($false,$true)) {
|
||||
$tag='effectcraft-quiet-retain-'+$(if($enabled){'on'}else{'off'})
|
||||
$choice=if($enabled){'1'}else{'0'}
|
||||
$p=Start-Msi $old '/i' "$tag-install" "/qn DESKTOPSHORTCUT=$choice"
|
||||
Assert ($p.WaitForExit(300000) -and $p.ExitCode -eq 0) "$tag initial install failed or waited for input"
|
||||
$p=Start-Msi $new '/i' "$tag-upgrade" '/qn'
|
||||
Assert ($p.WaitForExit(300000) -and $p.ExitCode -eq 0) "$tag upgrade failed or waited for input"
|
||||
Assert ((Test-Path $desktop) -eq $enabled) "$tag upgrade changed the saved choice"
|
||||
Assert-SettingsProduct $app $new
|
||||
$p=Start-Msi $new '/x' "$tag-remove" '/qn'
|
||||
Assert ($p.WaitForExit(300000) -and $p.ExitCode -eq 0) "$tag removal failed or waited for input"
|
||||
Assert (-not(Test-Path $desktop)) "$tag removal left the shortcut"
|
||||
}
|
||||
# PDF context-menu choices survive repair and can be changed during a major upgrade.
|
||||
$app='pdfcraft'
|
||||
$old=Join-Path $Packages 'pdfcraft-x64.msi';$new=UpgradePackage $app
|
||||
$menu='HKLM:\Software\Classes\SystemFileAssociations\.pdf\shell\PdfCraft.Edit'
|
||||
foreach($enabled in @($false,$true)) {
|
||||
$tag='pdfcraft-context-'+$(if($enabled){'on'}else{'off'})
|
||||
$p=Start-Msi $old '/i' "$tag-install"
|
||||
Complete $p 'install' "$tag-install" '' $null $false $null $enabled
|
||||
Assert ((Test-Path $menu) -eq $enabled) "$tag initial choice ignored"
|
||||
$p=Start-Msi $old '/i' "$tag-repair";Complete $p 'repair' "$tag-repair"
|
||||
Assert ((Test-Path $menu) -eq $enabled) "$tag repair changed the choice"
|
||||
$p=Start-Msi $new '/i' "$tag-upgrade"
|
||||
Complete $p 'install' "$tag-upgrade" '' $null $false $null (-not $enabled)
|
||||
Assert ((Test-Path $menu) -eq (-not $enabled)) "$tag upgrade ignored the new choice"
|
||||
$p=Start-Msi $new '/i' "$tag-upgraded-repair";Complete $p 'repair' "$tag-upgraded-repair"
|
||||
Assert ((Test-Path $menu) -eq (-not $enabled)) "$tag upgraded repair changed the choice"
|
||||
$p=Start-Msi $new '/x' "$tag-remove";Complete $p 'remove' "$tag-remove"
|
||||
Assert (-not(Test-Path $menu)) "$tag removal left the context menu"
|
||||
}
|
||||
# Record launches with an original GUI fixture that exits immediately.
|
||||
$stub=Join-Path $Results 'LaunchFixture.cs'
|
||||
@'
|
||||
using System;using System.IO;using System.Diagnostics;using System.Security.Principal;
|
||||
[assembly:System.Reflection.AssemblyFileVersion("90.0.1.0")]
|
||||
public static class Program {public static void Main(){var folder=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),"CraftInstallerTest");Directory.CreateDirectory(folder);var id=WindowsIdentity.GetCurrent();File.AppendAllText(Path.Combine(folder,"launches.log"),Process.GetCurrentProcess().MainModule.FileName+"|"+id.User.Value+Environment.NewLine);}}
|
||||
'@ | Set-Content $stub -Encoding UTF8
|
||||
$fixture=Join-Path $Results 'LaunchFixture.exe'
|
||||
& "$env:WINDIR\Microsoft.NET\Framework64\v4.0.30319\csc.exe" /nologo /platform:anycpu /target:winexe "/out:$fixture" $stub
|
||||
Assert ($LASTEXITCODE -eq 0) 'Launch fixture compilation failed'
|
||||
$marker=Join-Path $env:LOCALAPPDATA 'CraftInstallerTest\launches.log'
|
||||
function Launches {if(Test-Path $marker){@(Get-Content $marker)}else{@()}}
|
||||
foreach($app in 'figcraft','lightcraft','photocraft','vectorcraft','wordcraft','craft-launcher') {
|
||||
$bin=Join-Path $Results "$app-launch-bin";New-Item -ItemType Directory -Force $bin|Out-Null
|
||||
foreach($f in (Get-ChildItem (Join-Path $Packages "$app-bin") -Filter *.exe)){Copy-Item $fixture (Join-Path $bin $f.Name)}
|
||||
$msi=UpgradePackage $app $bin
|
||||
$count=@(Launches).Count
|
||||
$p=Start-Msi $msi '/i' "$app-launch-on";Complete $p 'install' "$app-launch-on" '' $null $true
|
||||
$deadline=(Get-Date).AddMinutes(2)
|
||||
while(@(Launches).Count -eq $count -and (Get-Date) -lt $deadline){Start-Sleep -Milliseconds 500}
|
||||
$lines=@(Launches);Assert ($lines.Count -eq $count+1) "$app Finish did not launch exactly once"
|
||||
$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
||||
Assert ($lines[-1].EndsWith("|$sid")) "$app launched as a different account"
|
||||
$p=Start-Msi $msi '/i' "$app-launch-repair";Complete $p 'repair' "$app-launch-repair"
|
||||
Assert (@(Launches).Count -eq $count+1) "$app repair launched the app"
|
||||
$p=Start-Msi $msi '/x' "$app-launch-remove";Complete $p 'remove' "$app-launch-remove"
|
||||
Assert (@(Launches).Count -eq $count+1) "$app removal launched the app"
|
||||
$p=Start-Msi $msi '/i' "$app-launch-off";Complete $p 'install' "$app-launch-off"
|
||||
Assert (@(Launches).Count -eq $count+1) "$app unchecked Finish launched the app"
|
||||
$p=Start-Msi $msi '/x' "$app-launch-off-remove";Complete $p 'remove' "$app-launch-off-remove"
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Verify Settings registration and interactive/quiet removal on a disposable desktop.
|
||||
.DESCRIPTION
|
||||
Uses only version 90.0.0 fixtures from windows-suite.ps1. The actual Settings button
|
||||
workflow must additionally be exercised on the visible desktop; these checks invoke
|
||||
the exact registered commands for every profile and verify their outcomes.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)] [switch] $DisposableMachine,
|
||||
[Parameter(Mandatory)] [string] $Packages,
|
||||
[ValidateSet('x64','x86')] [string] $Architecture = 'x64',
|
||||
[string] $Results = (Join-Path $Packages 'settings')
|
||||
)
|
||||
$ErrorActionPreference='Stop'
|
||||
. (Join-Path $PSScriptRoot 'windows-ui.ps1') -DisposableMachine:$DisposableMachine -Packages $Packages -Results $Results -DefineOnly
|
||||
Assert-CleanFixtures $Architecture
|
||||
|
||||
function Settings-Values($app,$keyName,$native=$false) {
|
||||
if($native -and $app -eq 'craft-launcher'){
|
||||
# An elevated per-user MSI can register its hidden native entry in HKLM.
|
||||
# Search the same hives as Launcher; the visible entry must still be HKCU.
|
||||
$values=Settings-Values 'machine' $keyName
|
||||
if($null -ne $values){return $values}
|
||||
}
|
||||
$hive=if($app -eq 'craft-launcher'){[Microsoft.Win32.RegistryHive]::CurrentUser}else{[Microsoft.Win32.RegistryHive]::LocalMachine}
|
||||
$view=if($Architecture -eq 'x86'){[Microsoft.Win32.RegistryView]::Registry32}else{[Microsoft.Win32.RegistryView]::Registry64}
|
||||
$root=[Microsoft.Win32.RegistryKey]::OpenBaseKey($hive,$view)
|
||||
try {
|
||||
$key=$root.OpenSubKey("Software\Microsoft\Windows\CurrentVersion\Uninstall\$keyName")
|
||||
if($null -eq $key){return $null}
|
||||
try {
|
||||
$values=@{}
|
||||
foreach($name in $key.GetValueNames()){$values[$name]=$key.GetValue($name)}
|
||||
return $values
|
||||
} finally {$key.Close()}
|
||||
} finally {$root.Close()}
|
||||
}
|
||||
function Registered-Removal($command,$code,$quiet,$tag) {
|
||||
$level=if($quiet){'qn'}else{'qf'}
|
||||
Assert ($command -match '^"([^"\r\n]+)" /x (\{[0-9A-Fa-f-]{36}\}) /(qf|qn) /norestart$') "$tag unexpected registered command"
|
||||
$exe=$Matches[1];$product=$Matches[2];$ui=$Matches[3]
|
||||
Assert ($product -eq $code -and $ui -eq $level) "$tag stale product or incorrect UI level"
|
||||
Assert ((Split-Path $exe -Leaf) -eq 'msiexec.exe' -and (Split-Path (Split-Path $exe)) -eq $env:WINDIR) "$tag must use the system installer"
|
||||
$log=Join-Path $Results "$tag.log"
|
||||
$p=Start-Process $exe -ArgumentList "/x $code /$level /norestart /l*v `"$log`"" -PassThru
|
||||
[void]$p.Handle
|
||||
return $p
|
||||
}
|
||||
foreach($app in 'cadcraft','deckcraft','designcraft','effectcraft','figcraft','filmcraft','gridcraft','lightcraft','pdfcraft','photocraft','soundcraft','vectorcraft','wordcraft','craft-launcher') {
|
||||
$msi=Join-Path $Packages "$app-$Architecture.msi"
|
||||
$db=$engine.OpenDatabase($msi,0)
|
||||
$code=Value $db 'SELECT `Value` FROM `Property` WHERE `Property` = ''ProductCode'''
|
||||
$name=Value $db 'SELECT `Value` FROM `Property` WHERE `Property` = ''ProductName'''
|
||||
foreach($quiet in $false,$true) {
|
||||
$tag="$app-settings-"+$(if($quiet){'quiet'}else{'interactive'})
|
||||
$p=Start-Msi $msi '/i' "$tag-install" '/qn'
|
||||
Assert ($p.WaitForExit(300000) -and $p.ExitCode -eq 0) "$tag silent install failed"
|
||||
$entry=Settings-Values $app "Craft.$app"
|
||||
Assert ($null -ne $entry) "$tag visible entry missing"
|
||||
Assert ($entry.DisplayName -eq "$name (Craft Apps)" -and $entry.DisplayVersion -eq '90.0.0') "$tag display metadata changed"
|
||||
Assert ($entry.Publisher -eq 'Learning Machines LLC') "$tag publisher changed"
|
||||
Assert (-not $entry.ContainsKey('WindowsInstaller') -and -not $entry.ContainsKey('SystemComponent')) "$tag must be a visible command entry"
|
||||
Assert (($entry.NoModify -eq 1) -eq ($app -in 'cadcraft','lightcraft','figcraft','photocraft','vectorcraft','wordcraft','craft-launcher')) "$tag changed the Modify policy"
|
||||
$native=Settings-Values $app $code $true
|
||||
Assert ($null -ne $native -and $native.SystemComponent -eq 1) "$tag native MSI entry not hidden"
|
||||
Assert ($native.WindowsInstaller -eq 1 -and $native.DisplayName -eq $name -and $native.Publisher -eq $entry.Publisher) "$tag native entry must remain discoverable by Launcher"
|
||||
Assert ($engine.ProductState($code) -eq 5) "$tag MSI registration missing"
|
||||
$command=if($quiet){$entry.QuietUninstallString}else{$entry.UninstallString}
|
||||
$p=Registered-Removal $command $code $quiet $tag
|
||||
if($quiet){
|
||||
Assert ($p.WaitForExit(300000) -and $p.ExitCode -eq 0) "$tag quiet removal waited for UI or failed"
|
||||
"ok $tag unattended exit 0"
|
||||
}else{Complete $p 'remove' $tag}
|
||||
Assert ($engine.ProductState($code) -eq -1) "$tag product still installed"
|
||||
Assert ($null -eq (Settings-Values $app "Craft.$app")) "$tag left its visible entry behind"
|
||||
Assert ($null -eq (Settings-Values $app $code $true)) "$tag left its native entry behind"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Compile every app's actual WiX sources around fixture files and check the MSI UI tables.
|
||||
.DESCRIPTION
|
||||
Windows only. Requires WiX 5.0.2 and its UI/Util extensions. Creates MSI fixtures in a
|
||||
temporary directory, without installing, launching or uninstalling anything. This checks
|
||||
linking, architecture-specific actions, sequence ordering and the final MSI tables; it does
|
||||
not replace interactive Windows acceptance testing with real app binaries.
|
||||
.EXAMPLE
|
||||
pwsh crates/installer/tests/windows-suite.ps1 -Repositories C:\work\CraftApps
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $Repositories,
|
||||
[string] $StartAt,
|
||||
[string] $Only,
|
||||
[string] $ModelsDirectory,
|
||||
[ValidateSet('x64', 'x86', 'arm64')] [string[]] $ValidationArchitectures = @('x64', 'x86', 'arm64'),
|
||||
[string] $Work = (Join-Path ([IO.Path]::GetTempPath()) ("craft-installer-test-" + [guid]::NewGuid()))
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
trap { Write-Output "FAILED: $_"; exit 1 }
|
||||
$Apps = 'cadcraft', 'deckcraft', 'designcraft', 'effectcraft', 'figcraft', 'filmcraft',
|
||||
'gridcraft', 'lightcraft', 'pdfcraft', 'photocraft', 'soundcraft', 'vectorcraft',
|
||||
'wordcraft', 'craft-launcher'
|
||||
if ($Only) {
|
||||
if ($StartAt) { throw 'Only and StartAt cannot be combined.' }
|
||||
if ($Apps -notcontains $Only) { throw "Unknown app: $Only" }
|
||||
$Apps = @($Only)
|
||||
}
|
||||
if ($StartAt -and $Apps -notcontains $StartAt) { throw "Unknown app: $StartAt" }
|
||||
$Started = -not $StartAt
|
||||
$Folders = 'effectcraft', 'figcraft', 'lightcraft', 'photocraft', 'vectorcraft', 'craft-launcher'
|
||||
$Desktop = @{ cadcraft = 'DESKTOPSHORTCUT'; effectcraft = 'DESKTOPSHORTCUT'; gridcraft = 'DESKTOPSHORTCUT';
|
||||
pdfcraft = 'INSTALLDESKTOPSHORTCUT'; vectorcraft = 'INSTALLDESKTOPSHORTCUT'; 'craft-launcher' = 'INSTALLDESKTOPSHORTCUT' }
|
||||
$Launch = @{ figcraft = 'LaunchFigcraft'; lightcraft = 'LaunchLightcraft'; photocraft = 'LaunchPhotocraft';
|
||||
vectorcraft = 'LaunchVectorcraft'; wordcraft = 'LaunchWordcraft'; 'craft-launcher' = 'LaunchArtCraftLauncher' }
|
||||
$Installer = New-Object -ComObject WindowsInstaller.Installer
|
||||
$Installer.UILevel = 2
|
||||
New-Item -ItemType Directory -Force $Work | Out-Null
|
||||
$License = Join-Path $Work 'license.rtf'
|
||||
[IO.File]::WriteAllText($License, '{\rtf1\ansi Synthetic test fixture license.}')
|
||||
|
||||
function Read-Value([object] $Database, [string] $Sql) {
|
||||
$view = $Database.OpenView($Sql)
|
||||
try {
|
||||
[void] $view.Execute()
|
||||
$row = $view.Fetch()
|
||||
if ($null -eq $row) { return $null }
|
||||
return $row.StringData(1)
|
||||
} finally { [void] $view.Close() }
|
||||
}
|
||||
function Assert-Equal($Actual, $Expected, [string] $What) {
|
||||
if ($Actual -ne $Expected) { throw "$What`: expected '$Expected', got '$Actual'" }
|
||||
}
|
||||
|
||||
foreach ($app in $Apps) {
|
||||
if (-not $Started) {
|
||||
if ($app -ne $StartAt) { continue }
|
||||
$Started = $true
|
||||
}
|
||||
$name = if ($app -eq 'craft-launcher') { 'artcraft-launcher' } else { $app }
|
||||
$repo = Join-Path $Repositories $app
|
||||
$packaging = Join-Path $repo 'packaging/windows'
|
||||
$source = Join-Path $packaging "$name.wxs"
|
||||
$ui = Join-Path $packaging 'installer-ui.wxs'
|
||||
$bin = Join-Path $Work "$app-bin"
|
||||
New-Item -ItemType Directory -Force $bin | Out-Null
|
||||
# Fixtures only supply PE bytes; none is ever run. Include conditional CLI files too.
|
||||
foreach ($match in [regex]::Matches((Get-Content -Raw $source), 'Source="\$\(var\.BinDir\)\\([^"/]+)"')) {
|
||||
Copy-Item (Join-Path $env:SystemRoot 'System32/notepad.exe') (Join-Path $bin $match.Groups[1].Value)
|
||||
}
|
||||
$icon = Join-Path $repo "assets/app-icon/$name.ico"
|
||||
# Match the release package.ps1 extension sets; extra extensions can mask missing dependencies.
|
||||
$extensions = switch ($app) {
|
||||
{ $_ -in 'figcraft','lightcraft','photocraft','vectorcraft','craft-launcher' } { '-ext'; 'WixToolset.UI.wixext'; '-ext'; 'WixToolset.Util.wixext'; break }
|
||||
'wordcraft' { '-ext'; 'WixToolset.Util.wixext'; break }
|
||||
default { @() }
|
||||
}
|
||||
foreach ($arch in 'x64', 'x86', 'arm64') {
|
||||
$msi = Join-Path $Work "$app-$arch.msi"
|
||||
$modelArgs = @()
|
||||
if ($app -eq 'pdfcraft') {
|
||||
if (-not $ModelsDirectory -or -not (Test-Path (Join-Path $ModelsDirectory 'ATTRIBUTION.txt'))) {
|
||||
throw 'PdfCraft fixtures require -ModelsDirectory with the qualified OCR models and licences.'
|
||||
}
|
||||
$modelArgs = @('-d', "ModelsDir=$ModelsDirectory")
|
||||
}
|
||||
& wix build $source $ui -arch $arch @extensions @modelArgs `
|
||||
-d Version=90.0.0 -d DisplayVersion=90.0.0 -d "BinDir=$bin" -d "IconPath=$icon" `
|
||||
-d HasCli=1 -d "LicenseRtf=$License" -o $msi
|
||||
if ($LASTEXITCODE -ne 0) { throw "$app $arch compilation/linking failed" }
|
||||
if ($ValidationArchitectures -contains $arch) {
|
||||
& wix msi validate $msi
|
||||
if ($LASTEXITCODE -ne 0) { throw "$app $arch MSI validation failed" }
|
||||
} else {
|
||||
Write-Host "Skipped ICE validation for $app $arch; compilation and UI table checks still run."
|
||||
}
|
||||
$db = $Installer.OpenDatabase($msi, 0)
|
||||
$allUsers = Read-Value $db 'SELECT `Value` FROM `Property` WHERE `Property` = ''ALLUSERS'''
|
||||
if ($app -eq 'craft-launcher') {
|
||||
if ($allUsers) { throw 'Launcher must remain per-user' }
|
||||
} else {
|
||||
Assert-Equal $allUsers '1' "$app per-machine scope"
|
||||
}
|
||||
Assert-Equal (Read-Value $db 'SELECT `Value` FROM `Property` WHERE `Property` = ''ARPSYSTEMCOMPONENT''') '1' "$app native ARP entry hidden"
|
||||
$settingsKey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\Craft.' + $app
|
||||
$settingsWhere = ' WHERE `Key` = ''' + $settingsKey + ''''
|
||||
$root = if ($app -eq 'craft-launcher') { '1' } else { '2' }
|
||||
Assert-Equal (Read-Value $db ('SELECT `Root` FROM `Registry`' + $settingsWhere + ' AND `Name` = ''DisplayName''')) $root "$app Settings registration scope"
|
||||
Assert-Equal (Read-Value $db ('SELECT `Value` FROM `Registry`' + $settingsWhere + ' AND `Name` = ''DisplayName''')) '[ProductName] (Craft Apps)' "$app legacy Launcher compatibility"
|
||||
$noModify = if ($app -in 'cadcraft','lightcraft','figcraft','photocraft','vectorcraft','wordcraft','craft-launcher') { '1' } else { $null }
|
||||
Assert-Equal (Read-Value $db 'SELECT `Value` FROM `Property` WHERE `Property` = ''ARPNOMODIFY''') $noModify "$app original Modify policy"
|
||||
$systemFolder = if ($arch -eq 'x86') { 'SystemFolder' } else { 'System64Folder' }
|
||||
$command = '"[' + $systemFolder + ']msiexec.exe" /x [ProductCode] '
|
||||
Assert-Equal (Read-Value $db ('SELECT `Value` FROM `Registry`' + $settingsWhere + ' AND `Name` = ''UninstallString''')) ($command + '/qf /norestart') "$app interactive Settings removal"
|
||||
Assert-Equal (Read-Value $db ('SELECT `Value` FROM `Registry`' + $settingsWhere + ' AND `Name` = ''QuietUninstallString''')) ($command + '/qn /norestart') "$app quiet Settings removal"
|
||||
if (Read-Value $db ('SELECT `Value` FROM `Registry`' + $settingsWhere + ' AND `Name` = ''WindowsInstaller''')) { throw "$app Settings must invoke its command, not MSI's basic UI API" }
|
||||
Assert-Equal (Read-Value $db 'SELECT `Feature_` FROM `FeatureComponents` WHERE `Component_` = ''CraftInstallerSettingsEntry''') 'Main' "$app Settings component selected"
|
||||
Assert-Equal (Read-Value $db 'SELECT `Condition` FROM `Component` WHERE `Component` = ''CraftInstallerSettingsNoModify''') 'ARPNOMODIFY = "1"' "$app change-button policy"
|
||||
Assert-Equal (Read-Value $db 'SELECT `Control_First` FROM `Dialog` WHERE `Dialog` = ''InstallComplete''') 'Finish' "$app completion focus"
|
||||
if ($Folders -contains $app) {
|
||||
$first = if ($app -eq 'effectcraft') { 'Install' } else { 'Folder' }
|
||||
Assert-Equal (Read-Value $db 'SELECT `Control_First` FROM `Dialog` WHERE `Dialog` = ''InstallFolder''') $first "$app enabled folder-dialog focus"
|
||||
}
|
||||
Assert-Equal (Read-Value $db 'SELECT `Name` FROM `Binary` WHERE `Name` = ''CraftInstallerErrorIcon''') 'CraftInstallerErrorIcon' "$app warning icon binary linked"
|
||||
Assert-Equal (Read-Value $db 'SELECT `Type` FROM `Control` WHERE `Dialog_` = ''CraftInstallerErrorDlg'' AND `Control` = ''ErrorIcon''') 'Icon' "$app native warning icon"
|
||||
foreach ($sequence in 'InstallUISequence', 'AdminUISequence') {
|
||||
foreach ($exit in @(@('InstallComplete', '-1'), @('InstallCancelled', '-2'), @('InstallFailed', '-3'))) {
|
||||
$where = ' WHERE `Action` = ''' + $exit[0] + ''''
|
||||
Assert-Equal (Read-Value $db ('SELECT `Condition` FROM `' + $sequence + '`' + $where)) 'UILevel = 5' "$app $sequence exit UI level"
|
||||
Assert-Equal (Read-Value $db ('SELECT `Sequence` FROM `' + $sequence + '`' + $where)) $exit[1] "$app $sequence exit sequence"
|
||||
}
|
||||
}
|
||||
foreach ($ending in 'InstallComplete', 'InstallCancelled', 'InstallFailed') {
|
||||
Assert-Equal (Read-Value $db ('SELECT `Argument` FROM `ControlEvent` WHERE `Dialog_` = ''' + $ending + ''' AND `Control_` = ''Finish'' AND `Event` = ''EndDialog''')) 'Return' "$app $ending Finish"
|
||||
}
|
||||
$removed = Read-Value $db 'SELECT `Text` FROM `Control` WHERE `Dialog_` = ''InstallComplete'' AND `Control` = ''Removed'''
|
||||
if ($removed -notmatch 'was uninstalled') { throw "$app lost its removal message" }
|
||||
$welcome = [int](Read-Value $db 'SELECT `Sequence` FROM `InstallUISequence` WHERE `Action` = ''InstallWelcome''')
|
||||
$cost = [int](Read-Value $db 'SELECT `Sequence` FROM `InstallUISequence` WHERE `Action` = ''CostInitialize''')
|
||||
if ($welcome -ge $cost) { throw "$app shortcut choice must precede costing" }
|
||||
$maintenance = [int](Read-Value $db 'SELECT `Sequence` FROM `InstallUISequence` WHERE `Action` = ''InstallMaintenance''')
|
||||
$finalize = [int](Read-Value $db 'SELECT `Sequence` FROM `InstallUISequence` WHERE `Action` = ''CostFinalize''')
|
||||
$execute = [int](Read-Value $db 'SELECT `Sequence` FROM `InstallUISequence` WHERE `Action` = ''ExecuteAction''')
|
||||
if ($maintenance -le $finalize -or $maintenance -ge $execute) { throw "$app maintenance must follow costing before execution (MSI error 2731)" }
|
||||
foreach ($choice in @(@('Repair', 'repair'), @('Remove', 'remove'))) {
|
||||
Assert-Equal (Read-Value $db ('SELECT `Argument` FROM `ControlEvent` WHERE `Dialog_` = ''InstallMaintenance'' AND `Control_` = ''' + $choice[0] + ''' AND `Event` = ''[CraftInstallerOperation]''')) $choice[1] "$app client maintenance outcome"
|
||||
}
|
||||
if ($Folders -contains $app) {
|
||||
$folder = [int](Read-Value $db 'SELECT `Sequence` FROM `InstallUISequence` WHERE `Action` = ''InstallFolder''')
|
||||
if ($folder -le $finalize) { throw "$app folder dialog must follow CostFinalize" }
|
||||
}
|
||||
if ($Desktop.ContainsKey($app)) {
|
||||
$property = $Desktop[$app]
|
||||
if ($app -ne 'craft-launcher') {
|
||||
$secure = Read-Value $db 'SELECT `Value` FROM `Property` WHERE `Property` = ''SecureCustomProperties'''
|
||||
if (($secure -split ';') -notcontains $property) { throw "$app shortcut choice is not secure" }
|
||||
}
|
||||
foreach ($value in '1', '0') {
|
||||
$condition = Read-Value $db ('SELECT `Condition` FROM `ControlEvent` WHERE `Dialog_` = ''InstallWelcome'' AND `Control_` = ''Install'' AND `Event` = ''[' + $property + ']'' AND `Argument` = ''' + $value + '''')
|
||||
if (-not $condition) { throw "$app missing explicit shortcut value $value" }
|
||||
}
|
||||
}
|
||||
if ($Launch.ContainsKey($app)) {
|
||||
$condition = Read-Value $db ('SELECT `Condition` FROM `ControlEvent` WHERE `Dialog_` = ''InstallComplete'' AND `Control_` = ''Finish'' AND `Event` = ''DoAction'' AND `Argument` = ''' + $Launch[$app] + '''')
|
||||
foreach ($guard in 'NOT Installed', 'NOT REMOVE', 'NOT REINSTALL') {
|
||||
if (-not $condition.Contains($guard)) { throw "$app launch is missing $guard" }
|
||||
}
|
||||
Assert-Equal (Read-Value $db 'SELECT `Value` FROM `Property` WHERE `Property` = ''WIXUI_EXITDIALOGOPTIONALCHECKBOX''') '1' "$app launch default"
|
||||
}
|
||||
Write-Output "ok $app $arch compiled MSI UI contract"
|
||||
}
|
||||
}
|
||||
Write-Output "MSI fixtures retained in $Work"
|
||||
@@ -0,0 +1,217 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Exercise full MSI install, repair and cached-product removal on a disposable Windows desktop.
|
||||
.DESCRIPTION
|
||||
Use only the version 90.0.0 fixtures built by windows-suite.ps1. Requires an interactive,
|
||||
administrator desktop with no existing suite installations. Never run against a developer's
|
||||
normal Windows installation: fixture packages share the real products' UpgradeCodes.
|
||||
.EXAMPLE
|
||||
.\windows-ui.ps1 -DisposableMachine -Packages C:\CraftInstallerTest\compiled
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)] [switch] $DisposableMachine,
|
||||
[Parameter(Mandatory)] [string] $Packages,
|
||||
[string] $Results = (Join-Path $Packages 'interactive'),
|
||||
[switch] $DefineOnly,
|
||||
[string] $StartAt
|
||||
)
|
||||
if (-not $DisposableMachine) { throw 'An explicitly disposable Windows machine is required.' }
|
||||
$ErrorActionPreference='Stop'
|
||||
New-Item -ItemType Directory -Force $Results | Out-Null
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Add-Type @'
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
public class MsiWindow {
|
||||
public IntPtr Handle; public string Text; public string Class; public bool Enabled; public long Style;
|
||||
}
|
||||
public static class MsiDesktop {
|
||||
[DllImport("user32.dll")] public static extern int GetSystemMetrics(int n);
|
||||
public delegate bool Callback(IntPtr h,IntPtr p);
|
||||
[DllImport("user32.dll")] static extern bool EnumWindows(Callback c,IntPtr p);
|
||||
[DllImport("user32.dll")] static extern bool EnumChildWindows(IntPtr h,Callback c,IntPtr p);
|
||||
[DllImport("user32.dll",CharSet=CharSet.Unicode)] static extern int GetWindowText(IntPtr h,StringBuilder s,int n);
|
||||
[DllImport("user32.dll",CharSet=CharSet.Unicode)] static extern int GetClassName(IntPtr h,StringBuilder s,int n);
|
||||
[DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr h);
|
||||
[DllImport("user32.dll")] static extern bool IsWindowEnabled(IntPtr h);
|
||||
[DllImport("user32.dll")] static extern int GetWindowLong(IntPtr h,int n);
|
||||
[DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h,uint m,IntPtr w,IntPtr l);
|
||||
[DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern IntPtr SendMessage(IntPtr h,uint m,IntPtr w,string l);
|
||||
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
||||
static MsiWindow Read(IntPtr h) {var t=new StringBuilder(4096); var c=new StringBuilder(256);GetWindowText(h,t,t.Capacity);GetClassName(h,c,c.Capacity);return new MsiWindow{Handle=h,Text=t.ToString(),Class=c.ToString(),Enabled=IsWindowEnabled(h),Style=GetWindowLong(h,-16)};}
|
||||
public static MsiWindow[] Windows() {var a=new List<MsiWindow>();EnumWindows((h,p)=>{if(IsWindowVisible(h))a.Add(Read(h));return true;},IntPtr.Zero);return a.ToArray();}
|
||||
public static MsiWindow[] Controls(IntPtr root) {var a=new List<MsiWindow>();EnumChildWindows(root,(h,p)=>{if(IsWindowVisible(h))a.Add(Read(h));return true;},IntPtr.Zero);return a.ToArray();}
|
||||
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h,uint m,IntPtr w,IntPtr l);
|
||||
public static void Click(IntPtr h){SendMessage(h,0xF5,IntPtr.Zero,IntPtr.Zero);}
|
||||
[StructLayout(LayoutKind.Sequential)] struct GuiThreadInfo {
|
||||
public uint Size,Flags; public IntPtr Active,Focus,Capture,MenuOwner,MoveSize,Caret;
|
||||
public int Left,Top,Right,Bottom;
|
||||
}
|
||||
[DllImport("user32.dll")] static extern bool GetGUIThreadInfo(uint thread,ref GuiThreadInfo info);
|
||||
public static IntPtr Focused(){var g=new GuiThreadInfo{Size=(uint)Marshal.SizeOf(typeof(GuiThreadInfo))};return GetGUIThreadInfo(0,ref g)?g.Focus:IntPtr.Zero;}
|
||||
public static int Checked(IntPtr h){return SendMessage(h,0xF0,IntPtr.Zero,IntPtr.Zero).ToInt32();}
|
||||
public static void SetText(IntPtr h,string s){SendMessage(h,0xC,IntPtr.Zero,s);}
|
||||
}
|
||||
'@
|
||||
$engine=New-Object -ComObject WindowsInstaller.Installer
|
||||
$keyboard=New-Object -ComObject WScript.Shell
|
||||
$defaultDesktop=@{cadcraft=$false;effectcraft=$true;gridcraft=$false;pdfcraft=$true;vectorcraft=$true;'craft-launcher'=$true}
|
||||
function Value($db,$sql) { $v=$db.OpenView($sql);try{[void]$v.Execute();$r=$v.Fetch();if($r){$r.StringData(1)}}finally{[void]$v.Close()} }
|
||||
function Assert($yes,$message){if(-not $yes){throw $message}}
|
||||
function Start-Msi($msi,$operation,$tag,$extra='/qf') {
|
||||
Write-Host "starting $tag"
|
||||
$log=Join-Path $Results "$tag.log"
|
||||
$p=Start-Process msiexec.exe -ArgumentList "$operation `"$msi`" /norestart /l*v `"$log`" $extra" -PassThru
|
||||
[void]$p.Handle
|
||||
return $p
|
||||
}
|
||||
function Save-Desktop($tag) {
|
||||
$bitmap = New-Object Drawing.Bitmap ([MsiDesktop]::GetSystemMetrics(78)), ([MsiDesktop]::GetSystemMetrics(79))
|
||||
$graphics = [Drawing.Graphics]::FromImage($bitmap)
|
||||
try {
|
||||
$graphics.CopyFromScreen([MsiDesktop]::GetSystemMetrics(76), [MsiDesktop]::GetSystemMetrics(77), 0, 0, $bitmap.Size)
|
||||
$bitmap.Save((Join-Path $Results "$tag.png"), [Drawing.Imaging.ImageFormat]::Png)
|
||||
} finally { $graphics.Dispose(); $bitmap.Dispose() }
|
||||
}
|
||||
function Dialog {
|
||||
@([MsiDesktop]::Windows() | Where-Object { $_.Class -in '#32770','MsiDialogCloseClass' -and ($_.Text -like '* Setup' -or $_.Text -in 'Cancel setup','Windows Installer') })
|
||||
}
|
||||
function Controls($d){@([MsiDesktop]::Controls($d.Handle))}
|
||||
function Button($c,$text){ @($c | Where-Object { $_.Class -eq 'Button' -and ($_.Text -replace '&','') -eq $text -and $_.Enabled }) | Select-Object -First 1 }
|
||||
function Click($c,$text){$b=Button $c $text;Assert ($null -ne $b) "Missing enabled button $text";[MsiDesktop]::Click($b.Handle)}
|
||||
function Complete($p,$mode,$tag,$folder='',$desktop=$null,$launchChecked=$false,$desktopInitial=$null,$contextMenu=$null,$contextMenuInitial=$true) {
|
||||
$deadline=(Get-Date).AddMinutes(12);$done=$false;$folderSeen=$false;$welcome=$false;$browsePending=$false;$browseDone=$false
|
||||
while((Get-Date) -lt $deadline) {
|
||||
$p.Refresh();if($p.HasExited){throw "$tag exited before completion, code $($p.ExitCode)"}
|
||||
foreach($d in (Dialog)) {
|
||||
[void][MsiDesktop]::SetForegroundWindow($d.Handle)
|
||||
$c=Controls $d;$text=($c.Text -join "`n")
|
||||
if($mode -eq 'remove' -and $d.Text -eq 'Windows Installer' -and $text -match 'Are you sure you want to uninstall this product') {Click $c 'Yes';break}
|
||||
if($text -match 'Setup completed successfully|Uninstall completed successfully|Setup cancelled|Setup failed') {
|
||||
if(-not (Button $c 'Finish')){continue}
|
||||
switch($mode){
|
||||
'remove' {Assert ($text -match 'Uninstall completed successfully' -and $text -match 'was uninstalled') "$tag missing removal confirmation"}
|
||||
'repair' {Assert ($text -match 'was repaired') "$tag missing repair confirmation"}
|
||||
'cancel' {Assert ($text -match 'Setup cancelled') "$tag missing cancellation confirmation"}
|
||||
'fail' {Assert ($text -match 'Setup failed') "$tag missing failure confirmation"}
|
||||
default {Assert ($text -match 'is installed') "$tag missing installation confirmation"}
|
||||
}
|
||||
$launch=@($c | Where-Object {$_.Class -eq 'Button' -and $_.Text -like 'Launch*'})
|
||||
if($mode -in 'remove','repair','cancel','fail'){Assert ($launch.Count -eq 0) "$tag must not offer launch"}
|
||||
foreach($l in $launch){Assert ([MsiDesktop]::Checked($l.Handle) -eq 1) "$tag launch default changed";if(-not $launchChecked){[MsiDesktop]::Click($l.Handle)}}
|
||||
$summary=@{tag=$tag;mode=$mode;title=$d.Text;controls=@($c | ForEach-Object {@{text=$_.Text;class=$_.Class;enabled=$_.Enabled}})}
|
||||
$summary|ConvertTo-Json -Depth 8|Set-Content (Join-Path $Results "$tag.json") -Encoding UTF8
|
||||
Start-Sleep -Seconds 2;$p.Refresh();Assert (-not $p.HasExited) "$tag completion did not wait for Finish"
|
||||
Save-Desktop $tag
|
||||
Click $c 'Finish';$done=$true;break
|
||||
}
|
||||
if($text -match 'setup could not be completed' -and $mode -ne 'fail'){throw "$tag failed: $text"}
|
||||
if(Button $c 'Repair') {Click $c $(if($mode -eq 'repair'){'Repair'}else{'Remove'});break}
|
||||
if($text -match 'Browse for a folder') {Click $c 'Cancel';$browsePending=$false;$browseDone=$true;break}
|
||||
if($text -match 'Choose the destination folder') {
|
||||
$folderSeen=$true
|
||||
if($browsePending){continue}
|
||||
if($tag -eq 'figcraft-install' -and -not $browseDone){$b=Button $c 'Browse...';Assert ($null -ne $b) 'Browse option missing';[void][MsiDesktop]::PostMessage($b.Handle,0xF5,[IntPtr]::Zero,[IntPtr]::Zero);$browsePending=$true;break}
|
||||
if($folder){
|
||||
$edit=@($c|Where-Object {$_.Class -in 'Edit','RichEdit20W'})|Select-Object -First 1
|
||||
$install=Button $c 'Install'
|
||||
if($null -eq $edit -or $null -eq $install){continue}
|
||||
# PathEdit commits its property on focus loss; BM_CLICK alone leaves it uncommitted.
|
||||
if(-not $keyboard.AppActivate($p.Id)){continue}
|
||||
# Do not assume the dialog initially focuses PathEdit: EffectCraft must focus an
|
||||
# enabled Install button when its folder is locked during upgrades.
|
||||
for($focusStep=0;$focusStep -lt 8 -and [MsiDesktop]::Focused() -ne $edit.Handle;$focusStep++){
|
||||
$keyboard.SendKeys('{TAB}');Start-Sleep -Milliseconds 250
|
||||
}
|
||||
Assert ([MsiDesktop]::Focused() -eq $edit.Handle) "$tag could not focus the destination field"
|
||||
[MsiDesktop]::SetText($edit.Handle,$folder)
|
||||
$keyboard.SendKeys('{TAB}');Start-Sleep -Milliseconds 250
|
||||
}
|
||||
Click $c 'Install';break
|
||||
}
|
||||
if($text -match 'accept the license terms') {
|
||||
$check=@($c|Where-Object {$_.Class -eq 'Button' -and $_.Text -match 'accept'})|Select-Object -First 1
|
||||
if($check -and [MsiDesktop]::Checked($check.Handle) -ne 1){[MsiDesktop]::Click($check.Handle)}
|
||||
$c=Controls $d;if(-not (Button $c 'Install')){continue};Click $c 'Install';break
|
||||
}
|
||||
if($text -match 'Setup will install') {
|
||||
$welcome=$true
|
||||
if($mode -eq 'cancel'){Click $c 'Cancel';break}
|
||||
if($null -ne $desktop){$check=@($c|Where-Object {$_.Class -eq 'Button' -and $_.Text -match 'desktop'})|Select-Object -First 1;if($null -eq $check){continue};Assert (([MsiDesktop]::Checked($check.Handle) -eq 1) -eq $(if($null -eq $desktopInitial){$defaultDesktop[$app]}else{$desktopInitial})) "$tag changed the fresh shortcut default";if(([MsiDesktop]::Checked($check.Handle) -eq 1) -ne $desktop){[MsiDesktop]::Click($check.Handle)}}
|
||||
if($null -ne $contextMenu){
|
||||
$check=@($c|Where-Object {$_.Class -eq 'Button' -and $_.Text -match 'Edit with PdfCraft'})|Select-Object -First 1
|
||||
if($null -eq $check){continue}
|
||||
Assert (([MsiDesktop]::Checked($check.Handle) -eq 1) -eq $contextMenuInitial) "$tag changed the fresh PDF context-menu default"
|
||||
if(([MsiDesktop]::Checked($check.Handle) -eq 1) -ne $contextMenu){[MsiDesktop]::Click($check.Handle)}
|
||||
}
|
||||
if(Button $c 'Next'){Click $c 'Next'}else{Click $c 'Install'};break
|
||||
}
|
||||
if($mode -eq 'fail' -and (Button $c 'OK')){Click $c 'OK';break}
|
||||
}
|
||||
if($done){break};Start-Sleep -Milliseconds 500
|
||||
}
|
||||
Assert $done "$tag timed out waiting for completion"
|
||||
Assert ($p.WaitForExit(120000)) "$tag did not exit after Finish"
|
||||
$expected=switch($mode){'cancel'{1602};'fail'{1603};default{0}}
|
||||
Assert ($p.ExitCode -eq $expected) "$tag exit code $($p.ExitCode), expected $expected"
|
||||
"ok $tag completion and Finish exit $($p.ExitCode)"
|
||||
}
|
||||
function Assert-CleanFixtures($architecture) {
|
||||
foreach ($name in 'cadcraft','deckcraft','designcraft','effectcraft','figcraft','filmcraft','gridcraft','lightcraft','pdfcraft','photocraft','soundcraft','vectorcraft','wordcraft','craft-launcher') {
|
||||
$db = $engine.OpenDatabase((Join-Path $Packages "$name-$architecture.msi"), 0)
|
||||
Assert ((Value $db 'SELECT `Value` FROM `Property` WHERE `Property` = ''ProductVersion''') -eq '90.0.0') "$name must be a generated test fixture"
|
||||
$upgrade = Value $db 'SELECT `Value` FROM `Property` WHERE `Property` = ''UpgradeCode'''
|
||||
foreach ($existing in $engine.RelatedProducts($upgrade)) { throw "$name has an existing installation ($existing); use a clean disposable machine." }
|
||||
}
|
||||
}
|
||||
if($DefineOnly){return}
|
||||
Assert-CleanFixtures x64
|
||||
$apps='cadcraft','deckcraft','designcraft','effectcraft','figcraft','filmcraft','gridcraft','lightcraft','pdfcraft','photocraft','soundcraft','vectorcraft','wordcraft','craft-launcher'
|
||||
$folders='effectcraft','figcraft','lightcraft','photocraft','vectorcraft','craft-launcher'
|
||||
$desktopApps='cadcraft','effectcraft','gridcraft','pdfcraft','vectorcraft','craft-launcher'
|
||||
$first=0
|
||||
if($StartAt){$first=[Array]::IndexOf($apps,$StartAt);Assert ($first -ge 0) "Unknown start profile $StartAt"}
|
||||
foreach($app in $apps[$first..($apps.Count-1)]) {
|
||||
$msi=Join-Path $Packages "$app-x64.msi"
|
||||
$db=$engine.OpenDatabase($msi,0);$code=Value $db 'SELECT `Value` FROM `Property` WHERE `Property` = ''ProductCode'''
|
||||
$product=Value $db 'SELECT `Value` FROM `Property` WHERE `Property` = ''ProductName'''
|
||||
$scope=if($app -eq 'craft-launcher'){'HKCU:'}else{'HKLM:'}
|
||||
$exe=if($app -eq 'craft-launcher'){'artcraft-launcher.exe'}else{"$app.exe"}
|
||||
$pathKey="$scope\Software\Microsoft\Windows\CurrentVersion\App Paths\$exe"
|
||||
$start=Join-Path ([Environment]::GetFolderPath($(if($app -eq 'craft-launcher'){'Programs'}else{'CommonPrograms'}))) "$product.lnk"
|
||||
$desktopLink=Join-Path ([Environment]::GetFolderPath($(if($app -eq 'craft-launcher'){'DesktopDirectory'}else{'CommonDesktopDirectory'}))) "$product.lnk"
|
||||
$folder=if($folders -contains $app){"C:\CraftInstallerTest\Custom folder $([char]0xe9) $app"}else{''}
|
||||
$desktop=if($desktopApps -contains $app){$false}else{$null}
|
||||
$p=Start-Msi $msi '/i' "$app-install";Complete $p 'install' "$app-install" $folder $desktop
|
||||
Assert ($engine.ProductState($code) -eq 5) "$app not registered installed"
|
||||
$installedExe=(Get-Item $pathKey).GetValue('')
|
||||
Assert (Test-Path -LiteralPath $installedExe) "$app executable missing"
|
||||
if($folder){Assert ((Split-Path $installedExe).TrimEnd('\') -eq $folder.TrimEnd('\')) "$app ignored folder selection"}
|
||||
Assert (Test-Path -LiteralPath $start) "$app Start menu shortcut missing"
|
||||
if($desktopApps -contains $app){Assert (-not (Test-Path -LiteralPath $desktopLink)) "$app ignored desktop opt-out"}
|
||||
Remove-Item -LiteralPath $installedExe
|
||||
$p=Start-Msi $msi '/i' "$app-repair";Complete $p 'repair' "$app-repair"
|
||||
Assert (Test-Path -LiteralPath $installedExe) "$app repair did not restore the executable"
|
||||
if($desktopApps -contains $app){Assert (-not (Test-Path -LiteralPath $desktopLink)) "$app repair changed desktop opt-out"}
|
||||
$sentinel=Join-Path $env:LOCALAPPDATA "CraftInstallerTest\$app-user-document.txt";New-Item -ItemType Directory -Force (Split-Path $sentinel)|Out-Null;Set-Content $sentinel 'keep me'
|
||||
$p=Start-Msi $code '/x' "$app-uninstall";Complete $p 'remove' "$app-uninstall"
|
||||
Assert ($engine.ProductState($code) -eq -1) "$app still registered installed"
|
||||
Assert (-not (Test-Path -LiteralPath $installedExe)) "$app executable survived removal"
|
||||
Assert (-not (Test-Path -LiteralPath $start)) "$app Start menu shortcut survived removal"
|
||||
Assert (-not (Test-Path -LiteralPath $desktopLink)) "$app desktop shortcut survived removal"
|
||||
Assert ((Get-Content $sentinel) -eq 'keep me') "$app removed user document"
|
||||
}
|
||||
$msi=Join-Path $Packages 'figcraft-x64.msi'
|
||||
$p=Start-Msi $msi '/i' 'figcraft-cancel';Complete $p 'cancel' 'figcraft-cancel'
|
||||
$p=Start-Msi $msi '/i' 'figcraft-maintenance-first' '/qn';Assert ($p.WaitForExit(180000) -and $p.ExitCode -eq 0) 'FigCraft maintenance fixture install failed'
|
||||
$p=Start-Msi $msi '/i' 'figcraft-maintenance-remove';Complete $p 'remove' 'figcraft-maintenance-remove'
|
||||
$db=$engine.OpenDatabase($msi,0);$code=Value $db 'SELECT `Value` FROM `Property` WHERE `Property` = ''ProductCode'''
|
||||
Assert ($engine.ProductState($code) -eq -1) 'Maintenance Remove left FigCraft installed'
|
||||
foreach($level in '/qn','/qb','/passive') {
|
||||
$tag='figcraft-'+($level -replace '/','')
|
||||
$p=Start-Msi $msi '/i' "$tag-install" $level;Assert ($p.WaitForExit(180000)) "$tag install hung";Assert ($p.ExitCode -eq 0) "$tag install failed $($p.ExitCode)"
|
||||
$p=Start-Msi $msi '/x' "$tag-uninstall" $level;Assert ($p.WaitForExit(180000)) "$tag uninstall hung";Assert ($p.ExitCode -eq 0) "$tag uninstall failed $($p.ExitCode)"
|
||||
"ok $tag noninteractive install/uninstall"
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Exercise fixture MSI options on a clean, disposable Windows desktop.
|
||||
.EXAMPLE
|
||||
.\windows-x86.ps1 -DisposableMachine -Packages C:\CraftInstallerTest\compiled
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)] [switch] $DisposableMachine,
|
||||
[Parameter(Mandatory)] [string] $Packages,
|
||||
[string] $Results = (Join-Path $Packages 'x86')
|
||||
)
|
||||
if (-not $DisposableMachine) { throw 'An explicitly disposable Windows machine is required.' }
|
||||
$ErrorActionPreference='Stop'
|
||||
. (Join-Path $PSScriptRoot 'windows-ui.ps1') -DisposableMachine -Packages $Packages -Results $Results -DefineOnly
|
||||
Assert-CleanFixtures x86
|
||||
foreach($app in 'cadcraft','deckcraft','designcraft','effectcraft','figcraft','filmcraft','gridcraft','lightcraft','pdfcraft','photocraft','soundcraft','vectorcraft','wordcraft','craft-launcher') {
|
||||
$msi=Join-Path $Packages "$app-x86.msi"
|
||||
$db=$engine.OpenDatabase($msi,0)
|
||||
$code=Value $db 'SELECT `Value` FROM `Property` WHERE `Property` = ''ProductCode'''
|
||||
$exe=if($app -eq 'craft-launcher'){'artcraft-launcher.exe'}else{"$app.exe"}
|
||||
$hive=if($app -eq 'craft-launcher'){[Microsoft.Win32.RegistryHive]::CurrentUser}else{[Microsoft.Win32.RegistryHive]::LocalMachine}
|
||||
$p=Start-Msi $msi '/i' "$app-x86-install" '/qn';Assert ($p.WaitForExit(300000)) "$app x86 install hung";Assert ($p.ExitCode -eq 0) "$app x86 install failed $($p.ExitCode)"
|
||||
Assert ($engine.ProductState($code) -eq 5) "$app x86 not registered"
|
||||
$root=[Microsoft.Win32.RegistryKey]::OpenBaseKey($hive,[Microsoft.Win32.RegistryView]::Registry32)
|
||||
try{$key=$root.OpenSubKey("Software\Microsoft\Windows\CurrentVersion\App Paths\$exe");Assert ($null -ne $key) "$app x86 App Paths missing";$path=$key.GetValue('');$key.Close();Assert (Test-Path -LiteralPath $path) "$app x86 file missing"}finally{$root.Close()}
|
||||
$p=Start-Msi $code '/x' "$app-x86-remove" '/qn';Assert ($p.WaitForExit(300000)) "$app x86 removal hung";Assert ($p.ExitCode -eq 0) "$app x86 removal failed $($p.ExitCode)"
|
||||
Assert ($engine.ProductState($code) -eq -1) "$app x86 still registered"
|
||||
Assert (-not (Test-Path -LiteralPath $path)) "$app x86 executable remains"
|
||||
"ok $app x86 silent install and cached-package removal"
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
use craft_installer::{WindowsApp, windows_ui};
|
||||
use roxmltree::Document;
|
||||
|
||||
#[test]
|
||||
fn full_ui_has_distinct_terminal_screens_in_both_sequences() {
|
||||
let source = windows_ui(WindowsApp::Designcraft);
|
||||
let doc = Document::parse(&source).unwrap();
|
||||
for sequence in ["InstallUISequence", "AdminUISequence"] {
|
||||
let sequence = doc.descendants().find(|n| n.has_tag_name(sequence)).unwrap();
|
||||
let endings: Vec<_> = sequence.children().filter(|n| n.attribute("OnExit").is_some()).collect();
|
||||
assert_eq!(endings.len(), 3);
|
||||
for (outcome, dialog) in [("success", "InstallComplete"), ("cancel", "InstallCancelled"), ("error", "InstallFailed")] {
|
||||
let show = endings.iter().find(|n| n.attribute("OnExit") == Some(outcome)).unwrap();
|
||||
assert_eq!(show.attribute("Condition"), Some("UILevel = 5"), "basic and silent installs must stay unattended");
|
||||
assert_eq!(show.attribute("Dialog"), Some(dialog));
|
||||
let dialog = doc.descendants().find(|n| n.has_tag_name("Dialog") && n.attribute("Id") == Some(dialog)).unwrap();
|
||||
let finish = dialog.children().find(|n| n.attribute("Id") == Some("Finish")).unwrap();
|
||||
assert_eq!(finish.attribute("Default"), Some("yes"));
|
||||
assert_eq!(finish.attribute("Cancel"), Some("yes"));
|
||||
assert!(finish.children().any(|n| n.attribute("Event") == Some("EndDialog") && n.attribute("Value") == Some("Return")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_and_repair_do_not_display_the_install_message() {
|
||||
let source = windows_ui(WindowsApp::Designcraft);
|
||||
let doc = Document::parse(&source).unwrap();
|
||||
let dialog = doc.descendants().find(|n| n.has_tag_name("Dialog") && n.attribute("Id") == Some("InstallComplete")).unwrap();
|
||||
let control = |id| dialog.children().find(|n| n.attribute("Id") == Some(id)).unwrap();
|
||||
assert_eq!(
|
||||
control("Description").attribute("HideCondition"),
|
||||
Some("REMOVE = \"ALL\" OR REINSTALL OR CraftInstallerOperation OR ACTION = \"ADMIN\"")
|
||||
);
|
||||
assert_eq!(control("Removed").attribute("Hidden"), Some("yes"));
|
||||
assert_eq!(control("Removed").attribute("ShowCondition"), Some("REMOVE = \"ALL\" OR CraftInstallerOperation = \"remove\""));
|
||||
assert!(control("Removed").attribute("Text").unwrap().contains("was uninstalled"));
|
||||
assert_eq!(
|
||||
control("Repaired").attribute("ShowCondition"),
|
||||
Some("(REINSTALL OR CraftInstallerOperation = \"repair\") AND NOT REMOVE AND CraftInstallerOperation <> \"remove\" AND ACTION <> \"ADMIN\"")
|
||||
);
|
||||
assert!(control("Repaired").attribute("Text").unwrap().contains("was repaired"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_authored_dialog_target_resolves_and_msi_support_dialogs_exist() {
|
||||
let source = windows_ui(WindowsApp::Designcraft);
|
||||
let doc = Document::parse(&source).unwrap();
|
||||
let exists = |id| doc.descendants().any(|n| n.has_tag_name("Dialog") && n.attribute("Id") == Some(id));
|
||||
for node in doc.descendants() {
|
||||
if node.has_tag_name("Show") {
|
||||
assert!(exists(node.attribute("Dialog").unwrap()));
|
||||
}
|
||||
if matches!(node.attribute("Event"), Some("SpawnDialog" | "NewDialog")) {
|
||||
assert!(exists(node.attribute("Value").unwrap()));
|
||||
}
|
||||
}
|
||||
for id in ["CraftInstallerErrorDlg", "FilesInUse", "MsiRMFilesInUse"] {
|
||||
assert!(exists(id), "missing Windows Installer support dialog {id}");
|
||||
}
|
||||
let error = doc.descendants().find(|n| n.has_tag_name("Dialog") && n.attribute("Id") == Some("CraftInstallerErrorDlg")).unwrap();
|
||||
let icon = error.children().find(|n| n.attribute("Id") == Some("ErrorIcon")).unwrap();
|
||||
assert_eq!(icon.attribute("Type"), Some("Icon"));
|
||||
assert_eq!(icon.attribute("FixedSize"), Some("yes"));
|
||||
assert_eq!(icon.attribute("Text"), Some("CraftInstallerErrorIcon"));
|
||||
assert!(doc.descendants().any(|n| n.has_tag_name("Binary")
|
||||
&& n.attribute("Id") == Some("CraftInstallerErrorIcon")
|
||||
&& n.attribute("SourceFile") == Some("$(var.IconPath)")));
|
||||
assert_eq!(icon.attribute("IconSize"), Some("32"));
|
||||
assert!(doc.descendants().any(|n| n.has_tag_name("EnsureTable") && n.attribute("Id") == Some("ListBox")));
|
||||
// Dialogs stay separate from the Settings registration components.
|
||||
let ui = doc.descendants().find(|n| n.has_tag_name("UI")).unwrap();
|
||||
for node in ui.descendants() {
|
||||
assert!(!matches!(node.tag_name().name(), "Component" | "RegistryValue" | "CustomAction" | "File"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_profiles_preserve_the_existing_options_and_launch_guards() {
|
||||
// Independent inventory of the released installers: folder, desktop choice, launch choice.
|
||||
let expected = [
|
||||
(WindowsApp::Cadcraft, false, Some("DESKTOPSHORTCUT"), None),
|
||||
(WindowsApp::Deckcraft, false, None, None),
|
||||
(WindowsApp::Designcraft, false, None, None),
|
||||
(WindowsApp::Effectcraft, true, Some("DESKTOPSHORTCUT"), None),
|
||||
(WindowsApp::Figcraft, true, None, Some("LaunchFigcraft")),
|
||||
(WindowsApp::Filmcraft, false, None, None),
|
||||
(WindowsApp::Gridcraft, false, Some("DESKTOPSHORTCUT"), None),
|
||||
(WindowsApp::Lightcraft, true, None, Some("LaunchLightcraft")),
|
||||
(WindowsApp::Pdfcraft, false, Some("INSTALLDESKTOPSHORTCUT"), None),
|
||||
(WindowsApp::Photocraft, true, None, Some("LaunchPhotocraft")),
|
||||
(WindowsApp::Soundcraft, false, None, None),
|
||||
(WindowsApp::Vectorcraft, true, Some("INSTALLDESKTOPSHORTCUT"), Some("LaunchVectorcraft")),
|
||||
(WindowsApp::Wordcraft, false, None, Some("LaunchWordcraft")),
|
||||
(WindowsApp::Launcher, true, Some("INSTALLDESKTOPSHORTCUT"), Some("LaunchArtCraftLauncher")),
|
||||
];
|
||||
assert_eq!(expected.len(), WindowsApp::ALL.len());
|
||||
for (app, folder, desktop, launch) in expected {
|
||||
let source = windows_ui(app);
|
||||
assert!(!source.contains("@@"), "unexpanded template in {}", app.name());
|
||||
let doc = Document::parse(&source).unwrap();
|
||||
let dialog = |id| doc.descendants().find(|n| n.has_tag_name("Dialog") && n.attribute("Id") == Some(id));
|
||||
assert_eq!(dialog("InstallFolder").is_some(), folder, "{} folder option", app.name());
|
||||
let complete = dialog("InstallComplete").unwrap();
|
||||
let first_focusable = complete
|
||||
.children()
|
||||
.find(|n| {
|
||||
n.has_tag_name("Control") && matches!(n.attribute("Type"), Some("PushButton" | "CheckBox")) && n.attribute("TabSkip") != Some("yes")
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(first_focusable.attribute("Id"), Some("Finish"), "repair/removal must not initially focus the hidden Launch control");
|
||||
if app.has_folder_picker() {
|
||||
let folder_dialog = doc.descendants().find(|n| n.has_tag_name("Dialog") && n.attribute("Id") == Some("InstallFolder")).unwrap();
|
||||
let first = folder_dialog
|
||||
.children()
|
||||
.find(|n| matches!(n.attribute("Type"), Some("PathEdit" | "PushButton")) && n.attribute("TabSkip") != Some("yes"))
|
||||
.unwrap();
|
||||
assert_eq!(first.attribute("Id"), Some(if app == WindowsApp::Effectcraft { "Install" } else { "Folder" }));
|
||||
}
|
||||
let control = |id| doc.descendants().find(|n| n.has_tag_name("Control") && n.attribute("Id") == Some(id));
|
||||
assert_eq!(control("DesktopShortcut").is_some(), desktop.is_some());
|
||||
assert_eq!(control("Launch").is_some(), launch.is_some());
|
||||
let welcome = doc.descendants().find(|n| n.has_tag_name("Show") && n.attribute("Dialog") == Some("InstallWelcome")).unwrap();
|
||||
assert_eq!(welcome.attribute("Before"), Some("CostInitialize"));
|
||||
// Repair and Remove publish feature events: the selection manager must already exist.
|
||||
let maintenance = doc.descendants().find(|n| n.has_tag_name("Show") && n.attribute("Dialog") == Some("InstallMaintenance")).unwrap();
|
||||
assert_eq!(maintenance.attribute("After"), Some("CostFinalize"));
|
||||
for (button, operation) in [("Repair", "repair"), ("Remove", "remove")] {
|
||||
assert!(control(button).unwrap().children().any(|n| n.attribute("Property") == Some("CraftInstallerOperation")
|
||||
&& n.attribute("Value") == Some(operation)
|
||||
&& n.attribute("Order") == Some("1")));
|
||||
}
|
||||
if let Some(property) = desktop {
|
||||
for (value, condition) in [("1", "CraftDesktopShortcutChoice = \"1\""), ("0", "NOT CraftDesktopShortcutChoice")] {
|
||||
assert!(doc.descendants().any(|n| n.has_tag_name("Publish")
|
||||
&& n.attribute("Property") == Some(property)
|
||||
&& n.attribute("Value") == Some(value)
|
||||
&& n.attribute("Condition") == Some(condition)));
|
||||
}
|
||||
}
|
||||
if let Some(action) = launch {
|
||||
let event = doc.descendants().find(|n| n.has_tag_name("Publish") && n.attribute("Value") == Some(action)).unwrap();
|
||||
assert_eq!(event.parent().unwrap().attribute("Id"), Some("Finish"));
|
||||
let condition = event.attribute("Condition").unwrap();
|
||||
for guard in ["WIXUI_EXITDIALOGOPTIONALCHECKBOX = \"1\"", "NOT Installed", "NOT REMOVE", "NOT REINSTALL", "ACTION <> \"ADMIN\""] {
|
||||
assert!(condition.contains(guard), "{} launch missing {guard}", app.name());
|
||||
}
|
||||
assert!(control("Launch").unwrap().attribute("HideCondition").unwrap().contains("REMOVE"));
|
||||
}
|
||||
if folder {
|
||||
let show = doc.descendants().find(|n| n.has_tag_name("Show") && n.attribute("Dialog") == Some("InstallFolder")).unwrap();
|
||||
assert_eq!(show.attribute("After"), Some("InstallMaintenance"));
|
||||
assert!(show.attribute("Condition").unwrap().contains("NOT REMOVE AND NOT REINSTALL"));
|
||||
let edit = control("Folder").unwrap();
|
||||
assert_eq!(edit.attribute("DisableCondition"), if app == WindowsApp::Effectcraft { Some("WIX_UPGRADE_DETECTED") } else { None });
|
||||
assert_eq!(source.contains("WixUIValidatePath_"), app != WindowsApp::Effectcraft);
|
||||
}
|
||||
assert_eq!(dialog("InstallLicense").is_some(), app == WindowsApp::Wordcraft);
|
||||
let repair = control("Repair").unwrap();
|
||||
assert_eq!(repair.attribute("ElevationShield"), Some(if app == WindowsApp::Launcher { "no" } else { "yes" }));
|
||||
for node in doc.descendants() {
|
||||
if node.has_tag_name("Show") {
|
||||
assert!(node.attribute("Condition").unwrap().contains("UILevel = 5"));
|
||||
assert!(dialog(node.attribute("Dialog").unwrap()).is_some());
|
||||
}
|
||||
if matches!(node.attribute("Event"), Some("SpawnDialog" | "NewDialog")) {
|
||||
assert!(dialog(node.attribute("Value").unwrap()).is_some());
|
||||
}
|
||||
assert!(!matches!(node.tag_name().name(), "CustomAction" | "File"));
|
||||
}
|
||||
for sequence in ["InstallUISequence", "AdminUISequence"] {
|
||||
let sequence = doc.descendants().find(|n| n.has_tag_name(sequence)).unwrap();
|
||||
assert_eq!(sequence.children().filter(|n| n.attribute("OnExit").is_some()).count(), 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_removal_uses_full_ui_and_keeps_quiet_removal_separate() {
|
||||
for app in WindowsApp::ALL {
|
||||
let source = windows_ui(app);
|
||||
let doc = Document::parse(&source).unwrap();
|
||||
let group = doc.descendants().find(|n| n.attribute("Id") == Some("CraftInstallerSettings")).unwrap();
|
||||
let entry = group.children().find(|n| n.attribute("Id") == Some("CraftInstallerSettingsEntry")).unwrap();
|
||||
let key = entry.children().find(|n| n.has_tag_name("RegistryKey")).unwrap();
|
||||
assert_eq!(key.attribute("Root"), Some(if app == WindowsApp::Launcher { "HKCU" } else { "HKLM" }));
|
||||
// A stable key path keeps component identity valid across ProductCode changes.
|
||||
assert_eq!(key.attribute("Key"), Some(format!("Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Craft.{}", app.name()).as_str()));
|
||||
assert_eq!(key.attribute("ForceDeleteOnUninstall"), Some("yes"));
|
||||
let value = |name| key.children().find(|n| n.attribute("Name") == Some(name)).unwrap();
|
||||
assert_eq!(value("DisplayName").attribute("KeyPath"), Some("yes"));
|
||||
assert_eq!(
|
||||
value("DisplayName").attribute("Value"),
|
||||
Some("[ProductName] (Craft Apps)"),
|
||||
"legacy Launcher must not mistake the Settings alias for an MSI product"
|
||||
);
|
||||
assert_eq!(value("UninstallString").attribute("Value"), Some("\"[$(var.CraftMsiSystemFolder)]msiexec.exe\" /x [ProductCode] /qf /norestart"));
|
||||
assert_eq!(
|
||||
value("QuietUninstallString").attribute("Value"),
|
||||
Some("\"[$(var.CraftMsiSystemFolder)]msiexec.exe\" /x [ProductCode] /qn /norestart")
|
||||
);
|
||||
assert_eq!(value("ModifyPath").attribute("Value"), Some("\"[$(var.CraftMsiSystemFolder)]msiexec.exe\" /i [ProductCode] /qf /norestart"));
|
||||
assert!(!key.children().any(|n| n.attribute("Name") == Some("WindowsInstaller")));
|
||||
assert!(
|
||||
doc.descendants()
|
||||
.any(|n| n.has_tag_name("Property") && n.attribute("Id") == Some("ARPSYSTEMCOMPONENT") && n.attribute("Value") == Some("1"))
|
||||
);
|
||||
let policy = group.children().find(|n| n.attribute("Id") == Some("CraftInstallerSettingsNoModify")).unwrap();
|
||||
assert_eq!(policy.attribute("Condition"), Some("ARPNOMODIFY = \"1\""));
|
||||
// Registration contains no new executable payload or privileged custom action.
|
||||
assert!(!group.descendants().any(|n| matches!(n.tag_name().name(), "File" | "CustomAction")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_context_menu_choice_preserves_default_and_opt_out_through_elevation() {
|
||||
for app in WindowsApp::ALL {
|
||||
let source = windows_ui(app);
|
||||
let doc = Document::parse(&source).unwrap();
|
||||
let control = doc.descendants().find(|n| n.has_tag_name("Control") && n.attribute("Id") == Some("PdfContextMenu"));
|
||||
assert_eq!(control.is_some(), app == WindowsApp::Pdfcraft);
|
||||
if let Some(control) = control {
|
||||
assert_eq!(control.attribute("Property"), Some("CraftPdfContextMenuChoice"));
|
||||
assert_eq!(control.attribute("CheckBoxValue"), Some("1"));
|
||||
assert!(doc.descendants().any(|n| n.has_tag_name("SetProperty")
|
||||
&& n.attribute("Id") == Some("CraftPdfContextMenuChoice")
|
||||
&& n.attribute("Condition").unwrap().contains("INSTALLPDFCONTEXTMENU = \"1\"")));
|
||||
for (value, condition) in [("1", "CraftPdfContextMenuChoice = \"1\""), ("0", "NOT CraftPdfContextMenuChoice")] {
|
||||
assert!(doc.descendants().any(|n| n.has_tag_name("Publish")
|
||||
&& n.attribute("Property") == Some("INSTALLPDFCONTEXTMENU")
|
||||
&& n.attribute("Value") == Some(value)
|
||||
&& n.attribute("Condition") == Some(condition)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
# Install and uninstall across the Craft App Suite
|
||||
|
||||
Source audit: 2026-10-10; suite UI unified locally 2026-10-11. Covers the 13 application repositories and ArtCraft Launcher. The
|
||||
`print-*` directories in this workspace are separate worktrees of PdfCraft, DesignCraft and
|
||||
craft-libs, rather than additional products.
|
||||
|
||||
## Recommendation and implemented boundary
|
||||
|
||||
Keep the current native packaging stack. Windows already uses WiX v5 throughout, macOS uses
|
||||
signed/notarized application bundles in DMGs, and Linux uses nfpm plus AppImage/Flatpak tooling.
|
||||
ArtCraft Launcher already supplies a single Rust install/remove backend for the suite. Replacing
|
||||
these with a different installer framework would create more work than the completion-screen fix.
|
||||
|
||||
Extract the repeated native UI source into [`craft-installer`](../crates/installer/README.md),
|
||||
not the application-specific component trees or the entire Launcher backend. All 14 Windows
|
||||
packages now reference the same UI, rendered with profiles retaining their existing options. Checked-in fragments let
|
||||
each repository build independently, including before this unpublished local work is merged.
|
||||
The generator's `--check` mode detects drift against the canonical crate templates and profile.
|
||||
|
||||
The Launcher UI now queues successful install/update/removal results for its 12 catalogued apps and shows an explicit
|
||||
completion dialog until dismissed. Results wait behind an open settings, notes or confirmation
|
||||
dialog, and survive the window being hidden in the tray. Batch results appear one at a time.
|
||||
The existing translated backend message identifies the app and operation; the UI does not infer
|
||||
success from a filesystem scan or display success for paused/cancelled/failed jobs.
|
||||
|
||||
## Windows inventory
|
||||
|
||||
All 14 repositories use WiX MSI plus a portable ZIP. The following is the source state before
|
||||
this change; extension-based wizards supply their terminal dialogs through WiX itself.
|
||||
|
||||
| App | Revision inspected | Existing full UI | Action |
|
||||
|---|---|---|---|
|
||||
| CADCraft | `06f742c` | Custom `CadcraftUI`, WiX Common support | Adopt; preserve options |
|
||||
| DeckCraft | `f448576` | None | Adopt `CraftInstallerUI` |
|
||||
| DesignCraft | `a127c6d` | None | Adopt `CraftInstallerUI` |
|
||||
| EffectCraft | `1b4d47f` | Original destination/maintenance/progress/outcome wizard | Adopt; preserve folder/upgrade policy |
|
||||
| FigCraft | `5b321a7` | `WixUI_InstallDir`, versioned Finish, launch option | Adopt; preserve options |
|
||||
| FilmCraft | `794a25f` | None | Adopt `CraftInstallerUI` |
|
||||
| GridCraft | `5b15827` | Original desktop-shortcut UI, success/cancel/error dialogs | Adopt; preserve saved shortcut choice |
|
||||
| LightCraft | `964d9338` | Custom WiX wizard, destination and launch option | Adopt; preserve options |
|
||||
| PdfCraft | `a6bc63a` | Original welcome/maintenance/progress/outcome UI | Adopt; preserve desktop-shortcut choice |
|
||||
| PhotoCraft | `0c72d954` | `WixUI_InstallDir`, launch option | Adopt; preserve options |
|
||||
| SoundCraft | `330a698` | None | Adopt `CraftInstallerUI` |
|
||||
| VectorCraft | `6f7f32c6` | `WixUI_InstallDir`, version text and launch option | Adopt; preserve options |
|
||||
| WordCraft | `fc0c11a` | `WixUI_Minimal`, launch option | Adopt; preserve options |
|
||||
| ArtCraft Launcher | `bd1182e` | Per-user `WixUI_InstallDir`, saved destination/shortcut, launch option | Adopt; preserve self-update contract |
|
||||
|
||||
The applications install per machine; the Launcher installs per user. Stable `UpgradeCode` and
|
||||
component GUIDs, `AllowSameVersionUpgrades`, associations, signing and shortcut registration are
|
||||
not shared policy. All 14 modified package definitions preserve those identities and physical installation scopes.
|
||||
Native validation required public directory aliases for GridCraft/PdfCraft machine shortcuts;
|
||||
PdfCraft uses HKMU shortcut key paths resolving to HKLM, with its explicit GUIDs unchanged.
|
||||
Original/current GridCraft native MSIs confirm every component GUID matches.
|
||||
Their generated UI uses native MSI controls and no external artwork. Existing app-owned launch
|
||||
custom actions and WiX path validation are retained.
|
||||
|
||||
Settings uses an MSI-owned `Craft.<app>` registry entry with an explicit full UI uninstall
|
||||
command and a separate quiet command. The native MSI entry is hidden rather than removed,
|
||||
keeping managed product registration and Launcher detection. The visible display name appends
|
||||
`(Craft Apps)` so existing Launchers cannot mistake the alias for a product code. Stock wizard
|
||||
`ARPNOMODIFY` policies remain explicit in consumers. These shared registry components are the
|
||||
only newly extracted installation ownership; executable/file associations stay app-owned.
|
||||
|
||||
The completion fragment distinguishes installation, repair and `REMOVE="ALL"`. Success, error
|
||||
and cancellation have separate `OnExit` entries in both install and administrative UI sequences.
|
||||
All authored dialogs require `UILevel = 5`, so unattended installs cannot hang on Finish.
|
||||
This matches [WiX's terminal dialog sequencing](https://docs.firegiant.com/wix/schema/wxs/show/).
|
||||
|
||||
## Other platforms and removal ownership
|
||||
|
||||
| Platform/format | Install path | Removal path | Completion owner |
|
||||
|---|---|---|---|
|
||||
| macOS DMG | Copy signed `.app` to Applications; Launcher mounts, stages and swaps it | Finder/Launcher moves bundle to Trash | Finder for manual copies; persistent Launcher result for managed jobs |
|
||||
| Windows MSI | `msiexec`; Launcher uses `/passive /norestart`, serializes its own MSI jobs | Windows Installer product registration, `/x` | Native Finish for full UI and Settings removal; persistent Launcher result for passive jobs |
|
||||
| Linux DEB/RPM | nfpm packages, distro installer under polkit through Launcher | Same distro package manager | Distro UI or persistent Launcher result |
|
||||
| Linux Flatpak | App-owned manifests and bundle scripts, Flatpak | Flatpak remove | Flatpak UI or persistent Launcher result |
|
||||
| Linux AppImage | Portable file, menu integration in app/Launcher | Remove app file and matching desktop entry | Persistent Launcher result for managed jobs; manual deletion otherwise |
|
||||
| Linux tarball | Extract FHS tree; FilmCraft additionally includes `install.sh` and terminal completion text | Manual installation has no common ownership manifest/uninstaller | Terminal/manual flow; no new graphical wizard |
|
||||
| FreeBSD tarball | Existing package scripts; Launcher records installed files and prefix | Launcher removes recorded files, using polkit when required | Persistent Launcher result for managed jobs; manual removal otherwise |
|
||||
| Windows portable ZIP | Extract and launch | Delete extracted directory | Manual operation |
|
||||
| Web | Deploy static WASM output | Remove hosting files/browser shortcut | Hosting/browser operation |
|
||||
|
||||
The common contracts are package ownership, result handling and completion presentation, rather
|
||||
than one set of cross-platform file operations. Documents and settings stay outside installer
|
||||
ownership. Package-manager hooks refresh integration caches on install and remove; adding GUI
|
||||
dialogs in those hooks would interfere with unattended package operations.
|
||||
|
||||
FigCraft is the thirteenth app audited here, but is not in the Launcher's current `SUITE` catalog.
|
||||
Its native MSI now uses the shared completion screen; adding it to the Launcher catalog is outside
|
||||
this installer UI change.
|
||||
|
||||
## Existing Rust backend and extraction cost
|
||||
|
||||
`craft-launcher/crates/native/src/install.rs` already centralizes download/checksum verification,
|
||||
format selection, retries, queueing, installing, updating and removal. Its callers are
|
||||
`backend.rs` and `self_update.rs`; state is shared with app catalog/status/protocol types,
|
||||
HTTP cancellation, installed-file manifests, platform detection and configured paths. The pure
|
||||
`core` and egui UI also compile to WASM, while native filesystem/process code stays target gated.
|
||||
|
||||
Moving that backend to craft-libs today would move or adapt those dependencies and self-update
|
||||
contracts, with only one concrete backend consumer. It would not reduce duplicate installers in
|
||||
the 13 app repositories, which contain package-building scripts rather than copies of this engine.
|
||||
Keep it in the Launcher. The first shared crate therefore owns a complete native UI template and the Settings registration
|
||||
and its generator/checker; it has no runtime dependencies or dependencies on application code.
|
||||
|
||||
The generator now has explicit profiles for the richer wizards. Six profiles retain folder
|
||||
selection, four retain optional desktop shortcuts, six retain launch checkboxes and WordCraft
|
||||
retains license acceptance. The Launcher's per-user folder/shortcut registry remains readable by
|
||||
self-update. GridCraft's saved shortcut policy stays app-owned. The shared checkbox publishes
|
||||
explicit 1/0 values before costing so unchecking survives elevation; folder selection follows
|
||||
CostFinalize. EffectCraft's upgrade folder remains locked. Launch actions retain their original
|
||||
privilege context and are gated off during removal, repair and administrative image creation.
|
||||
|
||||
## Rust ecosystem comparison
|
||||
|
||||
[`cargo-packager`](https://docs.rs/cargo-packager/latest/cargo_packager/) 0.11.8 is a concrete
|
||||
Rust CLI/library option: its documentation lists macOS app/DMG, Linux DEB/AppImage/Pacman and
|
||||
Windows NSIS/WiX MSI. It accepts custom packaging configuration and can be used as a library.
|
||||
Its documented format list does not include this suite's RPM, Flatpak or FreeBSD distribution
|
||||
flows. Adopting it would still require adapters and revalidation of existing MSI component
|
||||
identities, associations, signing/notarization and the Launcher's asset/self-update contracts.
|
||||
That migration is unnecessary for the requested completion fix. No new production library is added.
|
||||
|
||||
## Validation and practical limits
|
||||
|
||||
Completed locally: craft-libs workspace formatting/clippy and all 86 tests; Launcher full
|
||||
`xtask ci` from the completion-dialog change (formatting, workspace clippy, passing workspace tests,
|
||||
core/web WASM checks). Two pre-existing Launcher tests remain ignored: the storyteller-web
|
||||
sample contract and a real-network release download. The new crate passes a WASM library check.
|
||||
Launcher completion screenshots were rendered and inspected using the existing egui test harness.
|
||||
|
||||
- Shared crate tests cover all 14 profiles, the three terminal outcomes in both sequences,
|
||||
removal/repair text, folder and shortcut sequencing, launch guards and CLI drift/error handling.
|
||||
- All 14 consumer snapshots pass the generator's drift check. Old/new package, feature, component,
|
||||
file, shortcut, registry, search, destination and upgrade attributes were compared for unchanged
|
||||
installation ownership and identities. UI state/control definitions are replaced; folder
|
||||
properties retain secure propagation after removal of the old WiX UI extension wizard. Native
|
||||
shortcut validation fixes change GridCraft/PdfCraft directory references and PdfCraft key-path
|
||||
root authoring while preserving physical scope and component identities.
|
||||
- All 42 generated UI/architecture combinations compile to WiX 5.0.2 libraries for x64/x86/ARM64.
|
||||
WiX warns that Linux is unsupported, so these checks cover source compilation, not MSI linking
|
||||
or runtime behavior. All 41 packaging/test PowerShell scripts parse with PowerShell 7.4.6.
|
||||
- Launcher tests cover persistent install/removal completion after the old toast expiry,
|
||||
ordered batch dismissal, failed/cancelled jobs, hidden-window state and existing-dialog behavior.
|
||||
- The new `crates/installer/tests/windows-suite.ps1` builds all 14 actual package definitions with
|
||||
fixture files on Windows for all three architectures, validates MSIs and inspects their UI tables.
|
||||
It does not install anything. Existing EffectCraft/GridCraft tests compile the new UI, and
|
||||
PdfCraft's table checks now verify explicit checked/unchecked shortcut events.
|
||||
- Native Windows linking/UI tables passed for all 42 fixture packages; all 14 x86 silent
|
||||
install/cached-remove checks passed. Validation and functional GUI testing are recorded in the
|
||||
[Windows verification record](windows-installer-verification.md). The local evaluation VM
|
||||
exposed and verified fixes for maintenance sequencing and client completion state. The dedicated
|
||||
Windows 11 clone additionally verifies actual Settings removal for both scopes, including
|
||||
machine UAC consent, and all-profile x64/x86 interactive/quiet registered removal. Fixture
|
||||
tests do not verify real app payloads, accessibility, Restart Manager or reboot acceptance.
|
||||
|
||||
Two existing backend details limit how much a generic result screen can claim: Windows treats
|
||||
MSI return codes `3010`/`1641` as success without forwarding restart information, and FreeBSD's
|
||||
`remove_files` currently ignores individual file-removal failures. Neither is changed by this
|
||||
UI extraction. A future backend outcome type should carry reboot requirements, and recorded-file
|
||||
removal should propagate permission/I/O errors before reporting success. Manual Linux/FreeBSD
|
||||
tarball installation also needs an ownership manifest before a safe common uninstaller is viable.
|
||||
|
||||
No repositories were pushed, no packages/sites/releases were published, and no PRs were created.
|
||||
@@ -0,0 +1,409 @@
|
||||
# Windows installer verification — 2026-10-11
|
||||
|
||||
All work is local. No packages were published and no repositories were pushed or made into PRs.
|
||||
|
||||
## Initial Windows Server environment and payloads
|
||||
|
||||
A local QEMU Windows Server 2022 Datacenter evaluation VM with Desktop Experience was created
|
||||
for this task. No existing usable Windows VM configuration was found. The guest is x64,
|
||||
with two virtual CPUs and 4 GiB RAM; KVM is unavailable to the host account, so it uses software
|
||||
emulation. WiX 5.0.2 uses .NET 8.0.31; tests run on Windows PowerShell 5.1.
|
||||
|
||||
Packages use actual app `.wxs` sources, shared UI snapshots, original icons and a synthetic
|
||||
license. System notepad files supply fixture PE bytes. Launch checks use an original managed
|
||||
GUI fixture which records the executable path and account SID and exits immediately. These
|
||||
are installer tests, not builds or runtime tests of the Rust applications. Folder automation
|
||||
activates the desktop dialog, recognizes MSI's `RichEdit20W` PathEdit and changes keyboard focus
|
||||
to commit edits before Install. FigCraft's Browse/Cancel/typed-folder case was also reproduced
|
||||
with direct VM keyboard input and confirmed in App Paths. The runner retains the child process
|
||||
handle so PowerShell 5.1 reports exit codes reliably; phase wrappers also require an explicit
|
||||
successful completion marker and reject failure output.
|
||||
|
||||
## Defects found and verification corrections
|
||||
|
||||
- Maintenance originally preceded costing. Repair/Remove feature events require the selection
|
||||
manager initialized by `CostFinalize`; clicking Repair produced repeated MSI error 2731.
|
||||
Welcome/shortcut choices remain before costing; maintenance now follows `CostFinalize`, with
|
||||
destination selection after maintenance. Native MSI table tests enforce that ordering.
|
||||
- Repair's feature event forwards `REINSTALL` to the execution session without setting it in
|
||||
the client UI. The successful repair therefore displayed the install message. A private UI
|
||||
operation value now selects the repair/removal confirmation for maintenance buttons; direct
|
||||
`/x` and command-line repair still use their MSI properties. Launch is suppressed for both.
|
||||
The corrected CADCraft install/repair/maintenance-remove probe passed all three Finish screens.
|
||||
- GridCraft/PdfCraft machine shortcut components used standard directory identifiers that ICE
|
||||
classifies as user directories, causing ICE38/43/57 validation errors. Public directory aliases
|
||||
resolve to the same all-users Start menu/desktop locations. PdfCraft's shortcut key paths use
|
||||
HKMU (HKLM under its enforced `ALLUSERS=1`); GridCraft retains its existing HKMU key path.
|
||||
Plain shortcut targets resolve through `INSTALLFOLDER`, avoiding cross-component file references.
|
||||
PdfCraft's explicit component GUIDs are retained. Native compilation of original/current
|
||||
GridCraft x64 sources confirmed all four component GUIDs match.
|
||||
- Windows Installer COM `Execute`/`Close` return values polluted the table helper's result on
|
||||
PowerShell 5.1. They are explicitly discarded. PdfCraft's Unicode label assertion now uses a
|
||||
character code, avoiding UTF-8-without-BOM misreading on PowerShell 5.1; its native table test passes.
|
||||
|
||||
See Microsoft's [error 2731 definition](https://learn.microsoft.com/en-us/windows/win32/msi/windows-installer-error-messages)
|
||||
and WiX's [costing requirements](https://docs.firegiant.com/wix/schema/wxs/costfinalize/).
|
||||
|
||||
## Native results
|
||||
|
||||
The first complete matrix linked and ran full ICE validation and UI table checks for all
|
||||
42 app/architecture combinations after the shortcut fixes. The final UI revision also passed
|
||||
actual linking and UI table checks for all 42 combinations, with full ICE validation repeated
|
||||
on all 14 x64 packages. Other architectures received actual linking and final UI table checks.
|
||||
The default test script validates all architectures; this emulated rerun explicitly uses `-ValidationArchitectures x64`.
|
||||
|
||||
The final native matrix passed for CADCraft, DeckCraft, DesignCraft, EffectCraft, FigCraft,
|
||||
FilmCraft, GridCraft, LightCraft, PdfCraft, PhotoCraft, SoundCraft, VectorCraft, WordCraft and
|
||||
ArtCraft Launcher on x64, x86 and ARM64. All 14 x86 packages passed silent installation,
|
||||
32-bit App Paths/file checks and cached-product removal on the x64 guest. PdfCraft's own
|
||||
native MSI table test also passed against the final package.
|
||||
|
||||
All 14 x64 profiles passed full UI (`/qf`) installation, missing-file repair and cached-product
|
||||
removal. Each operation displayed the correct persistent completion text, hid launch during
|
||||
repair/removal, waited for Finish and returned 0. The six folder profiles accepted a path with
|
||||
spaces/non-ASCII; all Start menu shortcuts were present then removed, all four desktop profiles
|
||||
honored opt-out through repair, and the external user-document sentinel survived removal.
|
||||
|
||||
FigCraft also passed cancellation (Finish returned 1602), maintenance Remove and unattended
|
||||
`/qn`, `/qb`, `/passive` installation/removal. The UI matrix resumed after automation corrections;
|
||||
CADCraft/DeckCraft/DesignCraft and EffectCraft passing prefixes were retained, and the remaining
|
||||
profiles plus additional cases completed in the final phase. No package UI changed during those
|
||||
runner corrections.
|
||||
|
||||
All six destination profiles retained their custom directories through a 90.0.0 → 90.0.1
|
||||
upgrade, including EffectCraft's locked upgrade directory. All four desktop profiles passed
|
||||
opt-in, deleted-shortcut repair, upgrade opt-out and removal. GridCraft also passed removal as
|
||||
SYSTEM through a scheduled task: the Public desktop link and machine marker were removed,
|
||||
and no HKCU marker was created. FigCraft's blocked downgrade displayed the failure ending,
|
||||
returned 1603 and left the newer installation/path intact.
|
||||
|
||||
All six launch profiles passed a checked Finish launch exactly once under the desktop account,
|
||||
unchecked Finish without a launch, repair without a launch and removal without a launch.
|
||||
LightCraft exercised its retained `WixUnelevatedShellExec` action; the other five exercised their
|
||||
retained `WixShellExec` actions. The original managed fixture records the path/account SID; the
|
||||
built-in Administrator limitation below still applies.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| Final native linking/UI tables | 42/42 packages: 14 profiles × x64/x86/ARM64 |
|
||||
| Full ICE validation | First complete 42-package matrix; final revision repeated on 14/14 x64 packages |
|
||||
| Full UI install / repair / cached uninstall | 14/14 each; 42 distinct successful operations |
|
||||
| Custom folder / upgrade retention | 6/6 profiles |
|
||||
| Desktop default / opt-out / opt-in / repair / upgrade opt-out | 4/4 profiles |
|
||||
| Checked / unchecked launch and no launch on repair/removal | 6/6 profiles |
|
||||
| Cached x86 silent install/removal | 14/14 on x64 Windows |
|
||||
| Cancellation / maintenance Remove / downgrade failure | FigCraft: 1602 / 0 / 1603 with correct ending screens |
|
||||
| Unattended `/qn`, `/qb`, `/passive` | FigCraft install and removal; all completed without Finish interaction |
|
||||
| SYSTEM removal | GridCraft shortcut and registry marker cleanup passed |
|
||||
|
||||
The options phase completed 66 additional full UI endings plus the SYSTEM cleanup assertion.
|
||||
The final state check confirms no related fixture products or test scheduled tasks remain.
|
||||
Verbose logs, control inventories and screenshots are retained as private VM artifacts.
|
||||
Normal WiX CLI commands are provided in the crate. To reduce repeated .NET startup costs under
|
||||
emulation, the final linking/options runs invoke the same pinned WiX CLI entry point in a resident
|
||||
private process; this helper is not shipped in any app or crate.
|
||||
|
||||
Existing nonfatal warnings are retained: ICE61 for the intentional same-version upgrade policy;
|
||||
Launcher also emits ICE69 for its existing cross-component shortcut file references and ICE91
|
||||
for its explicitly per-user installation directory. Validation errors are not suppressed.
|
||||
|
||||
## Reproduce and limits
|
||||
|
||||
The [crate README](../crates/installer/README.md#validation) has commands for linking/table tests,
|
||||
full UI checks, options/upgrades/launches and x86 silent installation/removal. Runtime scripts
|
||||
require an explicitly disposable desktop, fixture version 90.0.0 and no existing related
|
||||
products. They preserve verbose logs, control inventories and completion screenshots.
|
||||
|
||||
The runtime runner selects `/qf` explicitly. On this guest, plain `msiexec /x` uses
|
||||
`UILevel=3`, confirms removal and bypasses authored dialogs. Use full UI or maintenance Remove
|
||||
for the completion screen. This initial revision did not override Settings removal; the
|
||||
Windows 11 follow-up below adds the explicit full UI command entry.
|
||||
|
||||
The evaluation guest runs as built-in Administrator. UAC consent and a genuinely unelevated
|
||||
launch token are not verified; launch tests can confirm the account and launch count only.
|
||||
Native ARM64 execution, real app payloads, release signing, Windows 10, screen readers,
|
||||
non-default DPI, locked files/Restart Manager, reboot-required outcomes and all command-line
|
||||
option overrides remain release acceptance checks. The VM's credentials and OS image are
|
||||
private build artifacts outside the repositories.
|
||||
|
||||
## Windows 11 Settings follow-up — 2026-10-12
|
||||
|
||||
The follow-up uses a dedicated validated clone of the provisioned clean-tools baseline:
|
||||
`/home/codex/work/VMs/craft-windows/clones/win-installer-settings-20261011-01`.
|
||||
The primary VM and baseline were preserved. The clone runs Windows 11 Pro build 26200,
|
||||
KVM, four vCPUs and 16 GiB RAM, with the `craftagent` desktop and normal UAC enabled.
|
||||
The environment verifier performed a native Rust MSVC compile/link/run. Actual task sources
|
||||
were separately exported to `C:\CraftApps\worktrees\installer-settings-20261011-01`, with
|
||||
repository HEADs, tracked patches and SHA-256 for every exported source file. Native Cargo
|
||||
outputs use separate targets under `C:\CraftApps\targets\installer-settings`.
|
||||
|
||||
The Settings entry is owned by MSI components, with full UI `UninstallString`, unattended
|
||||
`QuietUninstallString`, and the existing Modify policy. Native MSI registration remains
|
||||
hidden and available for repair/upgrades/Launcher discovery. The visible label appends
|
||||
`(Craft Apps)` to avoid matching existing Launchers' canonical-name detector. The updated
|
||||
Launcher additionally requires a GUID key and `WindowsInstaller=1` before passing a key to MSI.
|
||||
The stock wizards' implicit `ARPNOMODIFY=1` is retained explicitly for FigCraft, PhotoCraft,
|
||||
VectorCraft, WordCraft and Launcher, alongside CADCraft and LightCraft's explicit policies.
|
||||
|
||||
Completed native checks for the final package sources:
|
||||
|
||||
- All 42 packages linked and passed full ICE validation and MSI table assertions: all 14
|
||||
profiles on x64, x86 and ARM64. Validation errors were not suppressed; the same pre-existing
|
||||
ICE61 and Launcher ICE69/91 warnings remain.
|
||||
- The actual shared Rust crate compiled and passed all seven tests and clippy on Windows.
|
||||
Launcher native backend compiled and passed 62 tests, with one existing network test ignored,
|
||||
and clippy with warnings denied. Linux craft-libs passed 86 tests and workspace formatting/clippy;
|
||||
Launcher full `xtask ci` passed formatting, clippy, tests and both WASM checks.
|
||||
- Actual Windows Settings menu and confirmation buttons were clicked for the per-user Launcher
|
||||
and per-machine CADCraft. Both dispatched the registered `/x {ProductCode} /qf /norestart`
|
||||
command on the active desktop. CADCraft required and received Windows Installer UAC consent;
|
||||
Launcher did not. Both displayed the correct persistent uninstall completion, hid launch,
|
||||
waited for Finish, exited 0, and removed their product/visible Settings registration.
|
||||
- All 14 profiles on both x64 and x86 passed registered-command full UI removal: 28 correct
|
||||
uninstall endings, each persistent until Finish, returning 0 with launch hidden. Their
|
||||
visible entries retained the expected scope, display metadata and Modify policy; hidden
|
||||
native entries retained canonical names/publisher/WindowsInstaller identity. All product
|
||||
and visible/native entry cleanup assertions passed.
|
||||
- Unattended `/qn` installation passed 56 times (two per profile/architecture), and the
|
||||
registered quiet uninstall passed 28 times. All returned 0 without desktop input or Finish.
|
||||
- The options runner passed all 66 full UI endings: six custom-folder upgrades, four desktop
|
||||
opt-in/repair/upgrade-opt-out profiles, six checked/unchecked launch profiles, no launch on
|
||||
repair/removal, and FigCraft's blocked downgrade (expected failure ending, exit 1603).
|
||||
GridCraft removal as SYSTEM cleaned up its machine shortcut/marker. Upgrade checks verify
|
||||
the Settings version/ProductCode refresh; deleted Settings keys were restored during repair
|
||||
for all four desktop profiles. Launch fixtures confirm the account SID and count, not token
|
||||
integrity level.
|
||||
- An initial test assumed a per-user native MSI ARP entry would always live in HKCU. On this
|
||||
elevated test desktop, Windows registered Launcher's hidden native entry in HKLM while its
|
||||
visible entry remained HKCU and its files remained in the user's LocalAppData. The test now
|
||||
searches both native hives as Launcher does, while still enforcing HKCU for the visible entry.
|
||||
This correction did not change any package or production source.
|
||||
|
||||
The fixture payload limitation from the initial run still applies: package tests use actual
|
||||
WiX definitions/icons and synthetic PE/license/launch fixtures, not all 13 Rust app binaries.
|
||||
Native ARM64 execution, release signing, Windows 10, accessibility/DPI, locked-file/reboot cases
|
||||
and additional command-line overrides remain release acceptance checks.
|
||||
|
||||
Astra reviewed all source changes and untracked installer assets across the 15 worktrees.
|
||||
It found the old-Launcher alias compatibility problem and the five lost implicit Modify
|
||||
policies described above. Both were fixed and re-reviewed. Astra's final source review has
|
||||
no remaining actionable findings; it also reviewed the repair/upgrade and native-hive tests.
|
||||
|
||||
Evidence is retained in the clone's `state/` and guest `C:\CraftApps\evidence\installer-settings`:
|
||||
MSIs, source identity/patches, full matrix/native Cargo logs, Settings dispatch process records,
|
||||
control inventories, desktop screenshots and runtime logs. `source-qualification.txt` verifies
|
||||
that the final runtime source export has byte-identical compiled WiX/icon/generator/template
|
||||
sources to the successful matrix. No packages were published, no repositories pushed and no PRs opened.
|
||||
|
||||
The final cleanup check found no related fixture products, visible Settings aliases or test
|
||||
scheduled tasks. Settings was closed normally, evidence was copied to the host and ZIP integrity
|
||||
verified. The task clone was gracefully stopped and `running=false` confirmed; the clone and
|
||||
evidence are retained. The shared primary VM and clean-tools baseline were not changed.
|
||||
|
||||
|
||||
## Fresh Windows VM and native Launcher payload — 2026-10-12
|
||||
|
||||
This run follows `/home/codex/.codex/AGENTS.md` in a separate, checksum-verified full-copy
|
||||
clone: `/home/codex/work/VMs/craft-windows/clones/win-installers-verify-20261012-02`
|
||||
(SSH 2225, VNC 5905). The Windows 11 Pro 26200 baseline and primary VM remain untouched.
|
||||
The environment verifier passed a real native Rust/MSVC compile/link/run. Task sources,
|
||||
native targets and evidence are separated under the `installer-verify-20261012-02` name.
|
||||
The source manifests record every exported source file against the host worktrees, including
|
||||
unpublished patches and all 14 generated UI snapshots; the final export contains 315 files.
|
||||
|
||||
The first fresh fixture run passed all 42 MSI links/full ICE/table checks, 44 full UI endings,
|
||||
28 registered interactive removals across x64/x86, all registered quiet removals, and the
|
||||
66 options endings plus GridCraft SYSTEM cleanup. A Launcher shortcut-property sharing
|
||||
warning (MSI 1946, HRESULT 32) required acknowledgment during full UI repair. It exposed two
|
||||
shared UI defects: missing `ErrorIcon` (diagnostic 2835) and attempting to initially focus a
|
||||
hidden Launch checkbox during repair (2836). The shared template now authors ErrorIcon with
|
||||
a local Binary sourced from the package's existing `IconPath`, and puts Finish first among
|
||||
focusable completion controls. The icon fallback adds no extension requirement. MSI retains
|
||||
its native warning message and response buttons; warnings are not suppressed.
|
||||
|
||||
Astra caught that the initial icon solution depended on an extension that some real packaging
|
||||
commands do not load. The native matrix now matches every release command's extension set,
|
||||
and asserts the icon Binary row and completion `Control_First=Finish`. All 14 snapshots were
|
||||
regenerated natively and passed drift checks. The shared crate passed its seven Windows tests
|
||||
and clippy with warnings denied. Astra's final source review found no remaining actionable issues.
|
||||
|
||||
The actual Launcher workspace passed 168 native tests, with two pre-existing tests ignored,
|
||||
and produced a freshly compiled Windows x64 GUI executable with static CRT. An initial snapshot
|
||||
harness failure was corrected by creating its output directory, then the complete test suite
|
||||
was rerun successfully; both attempts are retained. Build outputs used a fresh task-specific
|
||||
target, not the baseline's application cache. The existing `package.ps1 -SkipBuild` recipe
|
||||
packaged that exact executable, passed PE/icon checks and full native MSI validation. It is
|
||||
an unsigned opt-level=0/debug=0 acceptance build, not an optimized release-performance or
|
||||
signing qualification. The executable SHA-256 is
|
||||
`46D3081AB5C50F25EF1F631C47221F2B27B5D9AA5B755864EB737661A36B3B89`.
|
||||
|
||||
Host evidence lives in `/home/codex/work/evidence/installer-verify-20261012-02`; the clone also
|
||||
retains screenshots in `state/` and full guest records in
|
||||
`C:\CraftApps\evidence\installer-verify-20261012-02`. Logs retain existing ICE61 and Launcher
|
||||
ICE69/91 warnings. The Binary identifier also produces WiX's merge-module length warning;
|
||||
these packages are standalone MSIs. No validation errors or runtime warnings were suppressed.
|
||||
|
||||
|
||||
Final checks and targeted follow-up:
|
||||
|
||||
- All 42 variants passed full linking/ICE/UI table verification with the actual release
|
||||
extension sets. The corrected UI passed all 44 install/repair/removal/cancellation endings,
|
||||
all 28 x64/x86 registered interactive removals, 56 unattended fixture installations and 28
|
||||
registered quiet removals. FigCraft also passed `/qn`, `/qb`, `/passive` install/removal.
|
||||
- The options rerun completed all 66 endings and SYSTEM cleanup. A scan of its verbose logs
|
||||
found one additional 2836: EffectCraft's upgrade locks its destination controls, but initially
|
||||
focused the disabled Folder field. Its Install control now comes first, preserving the
|
||||
lock and all installation events. The other 13 generated snapshots are byte-identical to
|
||||
the accepted 42-package matrix. EffectCraft alone was recompiled and passed full ICE/table
|
||||
checks on x64/x86/ARM64, superseding its three earlier artifacts.
|
||||
- The final EffectCraft desktop probe passed editable custom-folder first install, missing-file
|
||||
repair, locked-folder 90.0.0 → 90.0.1 upgrade, refreshed Settings metadata and removal, with
|
||||
no 2835/2836 diagnostics. The harness initially assumed PathEdit already had focus; direct
|
||||
UI Automation focus was also unsupported by this native control. It now tabs to the field
|
||||
and checks its actual HWND via `GetGUIThreadInfo` before setting text and committing through
|
||||
focus loss. Both failed probes and the successful repeat are preserved. The pending probe
|
||||
was cancelled normally; the first installed fixture was removed through its owned MSI.
|
||||
- The freshly built Launcher MSI installed into a chosen path containing spaces/non-ASCII,
|
||||
honored desktop opt-out and launched the exact qualified executable from Finish on the
|
||||
active Explorer desktop (session 2). Its real window was inspected and quit normally through
|
||||
Settings. Missing-file repair restored the identical SHA-256 without launching. Its Start
|
||||
menu shortcut retained the correct target and `ai.storyteller.artcraft-launcher` notification ID.
|
||||
- Actual Windows Settings menu and confirmation buttons removed that Launcher package via
|
||||
the registered system `msiexec /x {ProductCode} /qf /norestart`. Completion waited for Finish,
|
||||
hid launch and exited 0. The executable and visible entry were removed; the external test
|
||||
document and the real saved `app.ron` settings remained byte-identical.
|
||||
- The actual Launcher package also passed all six `/qn`, `/qb`, `/passive` install/removal
|
||||
operations without desktop input, Finish or app launch. Each quiet installation’s payload hash and final saved-settings
|
||||
retention were checked.
|
||||
|
||||
Astra re-reviewed the icon dependency fix, EffectCraft's focus/order change, regenerated
|
||||
snapshots and the final keyboard-focus harness. No actionable source findings remain.
|
||||
The final acceptance audit treats only the explicitly superseded EffectCraft upgrade log as
|
||||
obsolete; all current runtime logs must be free of 2835/2836. The original 1946 sharing warning
|
||||
is preserved in the first run's evidence and was not suppressed. The final run and actual
|
||||
Launcher repair did not reproduce it.
|
||||
|
||||
Only Launcher received a fresh native Rust payload build in this run; the other app payloads
|
||||
remain synthetic installer fixtures. Native ARM64 execution, Windows 10, release signing,
|
||||
optimized release performance, non-default DPI/accessibility and general locked-file/reboot
|
||||
scenarios are outside this run's qualification. No packages were published, no repositories
|
||||
pushed, and no PRs opened.
|
||||
|
||||
|
||||
The final audit passed: 42 matrix entries, 44 shared UI endings, 14 registered x64 and
|
||||
14 registered x86 uninstall endings, 66 options endings, and four corrected EffectCraft
|
||||
endings. Current accepted logs contain zero 2835/2836 diagnostics and zero 1946 warnings.
|
||||
No related fixture/actual Launcher products or visible Settings aliases remain, and the
|
||||
actual app is closed. Native targets, source, unsigned packages and all evidence are retained.
|
||||
|
||||
|
||||
## Remaining 13 real app payloads — 2026-10-12
|
||||
|
||||
All 13 remaining x64 Windows installers now pass with freshly compiled native Rust GUI and
|
||||
CLI binaries. This extends the earlier real Launcher qualification to the entire 14-app suite.
|
||||
The existing WiX implementation and shared `craft-installer` UI remain in use. Nothing was
|
||||
published, pushed or made into a PR.
|
||||
|
||||
This run uses the independent baseline-verified overlay clone
|
||||
`/home/codex/work/VMs/craft-windows/clones/win-installer-real-apps-20261012-03`, with separate disk,
|
||||
UEFI, TPM, desktop, source and native targets. It runs Windows 11 Pro build 26200, four vCPUs,
|
||||
16 GiB RAM, Rust MSVC and PowerShell 5.1. The native environment verifier compiled, linked and
|
||||
ran before app builds. The primary VM and read-only baseline were preserved.
|
||||
|
||||
The initial source export contains 9,497 files from the 13 app worktrees and craft-libs.
|
||||
`source-identity.json` records repository HEADs and every file's SHA-256; the transferred ZIP
|
||||
and guest files were checked. Two subsequent code/test deltas are recorded separately below.
|
||||
Sources and outputs live under the task-specific Windows worktree and target directories,
|
||||
without reusing baseline app binaries or Cargo targets. Each native build records its full
|
||||
arguments, stdout/stderr, exit codes, unfiltered test counts, profile and GUI/CLI hashes.
|
||||
|
||||
| App | Version | Engine tests passed | Ignored | Installer workflows |
|
||||
|---|---|---:|---:|---|
|
||||
| CADCraft | 0.4.0 | 188 | 0 | Passed |
|
||||
| DeckCraft | 0.4.0 | 42 | 0 | Passed |
|
||||
| DesignCraft | 0.5.0 | 276 | 0 | Passed |
|
||||
| EffectCraft | 0.7.0 | 485 | 2 | Passed |
|
||||
| FigCraft | 0.3.1 | 444 | 2 | Passed |
|
||||
| FilmCraft | 0.5.0 | 512 | 3 | Passed |
|
||||
| GridCraft | 0.4.0 | 97 | 0 | Passed |
|
||||
| LightCraft | 0.5.0 | 636 | 5 | Passed |
|
||||
| PdfCraft | 0.4.0 | 96 | 1 | Passed |
|
||||
| PhotoCraft | 0.6.0 | 1,074 | 11 | Passed |
|
||||
| SoundCraft | 0.4.0 | 108 | 0 | Passed |
|
||||
| VectorCraft | 0.8.0 | 1,646 | 0 | Passed |
|
||||
| WordCraft | 0.4.0 | 164 | 0 | Passed |
|
||||
| **Total** | | **5,768** | **24** | **13/13** |
|
||||
|
||||
For every app, the actual packaging script linked its MSI and passed full ICE validation.
|
||||
GUI/CLI PE architecture/subsystem checks and packaged, installed and repaired binary hashes
|
||||
match the qualified native payloads. LightCraft and PhotoCraft include their required HEIF
|
||||
features. Existing ICE61 same-version upgrade warnings and the standalone-product error-icon
|
||||
identifier warning remain visible; validation errors were not suppressed. Packages are unsigned.
|
||||
|
||||
The 39 successful full UI operations cover installation, missing-executable repair and actual
|
||||
Windows Settings removal for each app. Each completion displays the appropriate install,
|
||||
repair or uninstall text, remains open until Finish and exits 0. Repair and removal hide launch.
|
||||
Actual app windows were inspected on Explorer's active console, and installed CLI `--version`
|
||||
commands returned nonempty output with exit 0. Five destination profiles accepted folders with
|
||||
spaces and non-ASCII characters; CADCraft, GridCraft and PdfCraft retained desktop opt-out through
|
||||
repair. The five launch profiles launched their installed executable from Finish; the others
|
||||
launched through their installed Start menu shortcut. WordCraft retains its license acceptance.
|
||||
|
||||
All 13 Settings removals used the real Installed apps row, menu and confirmation. Recorded
|
||||
process command lines match the registered `/x {ProductCode} /qf /norestart` command on the
|
||||
console session. Windows Installer UAC prompts were inspected before consent. Every app also
|
||||
passed `/qn`, `/qb` and `/passive` installation and removal: 78 operations with no desktop input,
|
||||
Finish interaction or app launch. Actual saved configuration files remained byte-identical
|
||||
through repair, Settings removal and every unattended cycle. FilmCraft and LightCraft preferences
|
||||
were created through their normal app commands; no synthetic configuration fixture substitutes
|
||||
for saved settings. The external document sentinel is an additional retention check.
|
||||
|
||||
The final audit passed on 2026-10-12 at 14:24:19 UTC. It ties each MSI's SHA-256/ProductCode to
|
||||
its native build attempt, source HEAD, exact payload hashes and UI/unattended evidence; checks
|
||||
nonempty saved settings; and confirms no app process, related installed product, visible Settings
|
||||
alias or installed executable remains. Earlier phase records without an MSI hash additionally
|
||||
must follow package validation time and match the recorded installed GUI hash and ProductCode.
|
||||
|
||||
### Corrections and retained failed attempts
|
||||
|
||||
- FigCraft's signing call now explicitly binds `-Files` to the GUI/CLI path array. PowerShell 5.1
|
||||
previously combined the remaining arguments into one invalid path, even for unsigned packaging.
|
||||
The corrected actual package passed native linking, ICE and all runtime workflows.
|
||||
- LightCraft's test-only scratch-path helper rebuilds paths from native components, matching the
|
||||
backslash paths returned by Windows directory scans. Production code and assertions are unchanged;
|
||||
the complete engine suite passed after the fix.
|
||||
- EffectCraft's performance-sensitive tracking/raster crates, LightCraft's faces/raster/codecs/scenes,
|
||||
PdfCraft's engine/JavaScript/XFA and Boa crates, and PhotoCraft's engine/document/composition/
|
||||
operations/paint/raster crates use external opt-level 3 overrides. Other acceptance crates use
|
||||
opt-level 0, debug 0 and no LTO, with static CRT and two build jobs. LightCraft, PdfCraft and
|
||||
PhotoCraft retries run the complete suites serially. Original performance deadlines, XFA budgets,
|
||||
PhotoCraft nesting depth/stack limits and test assertions were retained. Original failures and
|
||||
complete successful retries remain in the evidence.
|
||||
- Harness corrections cover CLI version invocation, alias removal timing, nonempty settings,
|
||||
PowerShell 5.1 nested arrays and final audit object aggregation. Settings automation reacquires
|
||||
the confirmation after settling, checks foreground state and allows two minutes for observed
|
||||
interactive UAC consent. Final acceptance requires the actual removal dispatch and exit; a
|
||||
helper status alone is insufficient. EffectCraft's direct screenshot-guided Settings fallback
|
||||
and PdfCraft's consent observation timeout are retained with their subsequent verified removals.
|
||||
- PhotoCraft displayed its CPU compositor fallback notice on the VM's Microsoft Basic Render Driver.
|
||||
The notice was observed and accepted, and the installed app continued successfully.
|
||||
|
||||
Astra's source and harness follow-ups accepted these fixes without remaining actionable findings.
|
||||
The final evidence review is recorded in the task's `astra-review.md`.
|
||||
|
||||
### Evidence and qualification limits
|
||||
|
||||
Durable host evidence is under `/home/codex/work/evidence/installer-real-apps-20261012-03`.
|
||||
Guest evidence is under `C:\CraftApps\evidence\installer-real-apps-20261012-03`, with desktop
|
||||
screenshots in the clone's `state/`. It includes native logs and failed attempts, source identities
|
||||
and deltas, unsigned MSIs/portable archives, validation results, actual Settings dispatch records,
|
||||
completion screenshots, settings hashes and the final acceptance JSON. The final source delta
|
||||
archive also records subsequent host documentation changes separately from compiled inputs.
|
||||
|
||||
This qualifies x64 installer behavior, real GUI startup, CLI execution and configuration retention.
|
||||
Document editing/save/reopen workflows, full workspace CI/WASM, external fonts/corpora, optimized
|
||||
release performance, signing, native ARM64/x86 app execution, Windows 10, physical GPU/audio,
|
||||
accessibility/DPI and locked-file/reboot outcomes remain outside this run. Earlier x86/ARM64
|
||||
installer fixture qualification remains recorded above.
|
||||
|
||||
Final session cleanup: Settings and all apps are closed, only this task's temporary scheduled-task registrations were removed, and the clone was gracefully stopped with `running=false` confirmed. No guest files were deleted. All VM state, native targets, sources, unsigned deliverables and evidence are retained. This task created no host Cargo build directory, so there were no host build outputs to remove; pre-existing tools and other sessions' artifacts were left intact. `cleanup.json` records retained paths and host free space before/after.
|
||||
Reference in New Issue
Block a user