diff --git a/Cargo.lock b/Cargo.lock index 222647d4..669d56ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -529,6 +529,14 @@ dependencies = [ "rayon", ] +[[package]] +name = "craft-installer" +version = "0.1.0" +dependencies = [ + "roxmltree", +] + + [[package]] name = "craft-print" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8664090b..dccac86d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/NOTICE b/NOTICE index bf247173..6cd47e08 100644 --- a/NOTICE +++ b/NOTICE @@ -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 diff --git a/README.md b/README.md index 3e7ad3e1..dd903709 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,11 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "` and pass the generated `installer-ui.wxs` +to `wix build` alongside their package source. Their main feature must also reference +``, 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.` 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 ` 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. diff --git a/crates/installer/src/bin/craft-installer.rs b/crates/installer/src/bin/craft-installer.rs new file mode 100644 index 00000000..bf910f88 --- /dev/null +++ b/crates/installer/src/bin/craft-installer.rs @@ -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 ".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 + } + } +} diff --git a/crates/installer/src/lib.rs b/crates/installer/src/lib.rs new file mode 100644 index 00000000..71e2b8fa --- /dev/null +++ b/crates/installer/src/lib.rs @@ -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::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#" + "#, + 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#"