Merge pull request #2493 from Jeremy8776/feat/install-scope-policy

Windows installation policy: EffectCraft
This commit is contained in:
aiongg
2026-10-27 17:16:27 -04:00
committed by GitHub
15 changed files with 685 additions and 72 deletions
+24
View File
@@ -22,3 +22,27 @@ jobs:
lint:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
windows-install-policy:
timeout-minutes: 10
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
shell: pwsh
run: |
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
dotnet tool install --tool-path $tools wix --version 5.0.2
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
$tools >> $env:GITHUB_PATH
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
foreach ($extension in @($policy.extensions)) {
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
}
- name: Build and check non-executable installer fixtures
shell: pwsh
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
+4 -2
View File
@@ -28,8 +28,10 @@ permissions:
jobs:
windows-arm64:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
with:
app-name: effectcraft
app-dir: EffectCraft
install-relative-path: Programs/ArtCraft/EffectCraft
install-scope: perUser
+6 -2
View File
@@ -275,9 +275,13 @@ send us right now.
| arm64 (Snapdragon and other ARM PCs) | `effectcraft-<ver>-windows-arm64.msi` | `effectcraft-<ver>-windows-arm64-portable.zip` |
| x86 (32-bit) | `effectcraft-<ver>-windows-x86.msi` | `effectcraft-<ver>-windows-x86-portable.zip` |
Installers and executables are code-signed. The installer asks where to install EffectCraft
(Program Files by default), and later versions install into the same folder. To install silently
Installers and executables are code-signed. The installer asks who to install EffectCraft for and where. By default it installs for your
account only, into `%LOCALAPPDATA%\Programs\ArtCraft\EffectCraft`, without an administrator prompt;
"Everyone on this computer" installs into `C:\Program Files\ArtCraft\EffectCraft` (`Program Files (x86)` for the 32-bit build). Later versions
install into the same folder. To install silently
into a folder of your choice: `msiexec /i effectcraft-<ver>-windows-x64.msi /qn INSTALLFOLDER="D:\Apps\EffectCraft"`.
A silent install is per-user by default. `INSTALLFOLDER` chooses the folder, not who it is installed for;
add `ALLUSERS=1`, from an elevated terminal or deployment tool, for an all-users install.
### macOS
+3 -3
View File
@@ -54,13 +54,13 @@ The release packages ship `effectcraft-cli` alongside the desktop app, so no bui
| Install | CLI |
|---|---|
| Windows (MSI) | `C:\Program Files\EffectCraft\effectcraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` |
| Windows (MSI) | `%LOCALAPPDATA%\Programs\ArtCraft\EffectCraft\effectcraft-cli.exe` by default (`C:\Program Files\ArtCraft\EffectCraft\effectcraft-cli.exe` for an all-users install, under `Program Files (x86)` instead for the 32-bit build, or wherever you installed it otherwise), not on `PATH` |
| Linux (deb, rpm) | `/usr/bin/effectcraft-cli` |
| macOS | the separate `effectcraft-cli-<version>-macos-<arch>.zip` release asset (the `.app` holds only the desktop app) |
```sh
# Windows, default install folder
claude mcp add effectcraft -- "C:\Program Files\EffectCraft\effectcraft-cli.exe" mcp
# Windows (PowerShell), default install folder
claude mcp add effectcraft -- "$env:LOCALAPPDATA\Programs\ArtCraft\EffectCraft\effectcraft-cli.exe" mcp
# Linux, or macOS with the CLI unzipped onto PATH
claude mcp add effectcraft -- effectcraft-cli mcp
```
+1 -1
View File
@@ -71,7 +71,7 @@ The MSI's setup wizard ([`installer-ui.wxs`](../packaging/windows/installer-ui.w
choose the install folder; upgrades and repairs reuse it, and `INSTALLFOLDER="..."` picks it for a
silent (`/qn`) install. [`packaging-lint.yml`](../.github/workflows/packaging-lint.yml) install-tests
that with stub binaries ([`test-msi.ps1`](../packaging/windows/test-msi.ps1)): a custom folder,
an upgrade that keeps it, a repair, uninstall, and the Program Files default. The wizard pages
an upgrade that keeps it, a repair, uninstall, and the all-users default folder (`Program Files\ArtCraft\EffectCraft`). The wizard pages
themselves are checked by hand.
The portable zip ships [`portable.txt`](../packaging/windows/portable.txt) beside `effectcraft.exe`.
+35
View File
@@ -0,0 +1,35 @@
# Windows installation policy checks
New installations default to the current account under `Programs\ArtCraft\<App>`.
The first setup page offers installation for everyone. Scope is selected before duplicate
checks and directory costing. An update uses the existing context's remembered exact folder;
`INSTALLFOLDER` supplied explicitly is never replaced. It is a secure MSI property so the
chosen folder reaches the elevated installer. Silent machine updates pass `ALLUSERS=1`.
A cross-context copy is refused; changing context means uninstalling the old copy first.
App registration follows the selected context. Existing optional shortcuts and file-handler
choices stay app-owned. No new cleanup operation or model/settings migration is introduced.
Packaged resources remain package-owned; separately downloaded shared models are not caches
and are outside this installer's ownership.
## Repeatable metadata tests
On Windows with WiX 5.0.2 and its matching UI/Util extensions:
```powershell
./packaging/windows/test-install-policy.ps1 -OutputDirectory ./target/install-policy-tests
```
The script builds x64, x86 and arm64 packages with deliberately non-executable payloads. It
uses the actual package source and compiled database. A safe Windows Installer session checks
24 scope/registration/maintenance cases, remembered and explicit custom folders, sequencing,
upgrade identity and the original registration inventory recorded in `install-policy.json`.
The release packaging script runs the compiled checker before signing. Packaging CI runs the
small fixture builds without compiling the Rust application.
Never install or distribute the `NOT-INSTALLABLE.msi` fixtures as an app. Passing these checks
does not prove real installation, elevation, legacy-version upgrades, registry writes, optional
shortcut/menu behaviour, cancellation, rollback or uninstall. Those still require the real
payload in an authorised clean Windows test environment. Cross-architecture metadata is not
execution evidence on those architectures. Existing open installer/association PRs must be
reconciled before publication. Nothing here changes macOS/Linux native packaging conventions.
+370
View File
@@ -0,0 +1,370 @@
<#
.SYNOPSIS
Check the compiled installation policy without installing any product.
.DESCRIPTION
Uses Windows Installer's safe session (OpenPackage option 1). Only property-setting custom
actions are exercised. No AppSearch, install, repair, removal or file-writing action runs.
#>
param([Parameter(Mandatory = $true)] [string] $MsiPath)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json
function Read-Rows($Database, [string] $Sql, [int] $Columns) {
$view = $Database.OpenView($Sql)
try {
[void]$view.Execute()
while ($null -ne ($record = $view.Fetch())) {
try {
$values = @(for ($i = 1; $i -le $Columns; $i++) {
$record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @($i))
})
,$values
} finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) }
}
} finally {
[void]$view.Close()
[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view)
}
}
function Set-SessionProperty($Session, [string] $Name, [string] $Value) {
[void]$Session.GetType().InvokeMember('Property', 'SetProperty', $null, $Session, @($Name, $Value))
}
function Get-SessionProperty($Session, [string] $Name) {
$Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name))
}
function Get-PackageTemplate($Database) {
# Summary information property 7 is the platform: "Intel;1033", "x64;1033" or "Arm64;1033".
$summary = $Database.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $Database, @(0))
try { [string]$summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7)) }
finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($summary) }
}
$installer = $null; $database = $null; $session = $null
try {
$installer = New-Object -ComObject WindowsInstaller.Installer
$file = (Resolve-Path -LiteralPath $MsiPath).Path
$database = $installer.OpenDatabase($file, 0)
$session = $installer.OpenPackage($file, 1)
$properties = @{}
foreach ($row in (Read-Rows $database 'SELECT `Property`, `Value` FROM `Property`' 2)) { $properties[$row[0]] = $row[1] }
$secureProperties = @($properties['SecureCustomProperties'] -split ';')
if ('INSTALLFOLDER' -notin $secureProperties) {
throw 'The selected installation folder must survive elevation (secure INSTALLFOLDER).'
}
if ($policy.mode -eq 'perUser') {
if ($properties.ContainsKey('ALLUSERS')) { throw 'Launcher must remain per-user; machine self-update is not part of this change.' }
if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'Launcher upgrade identity changed.' }
$dirs = @{}
foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { $dirs[$row[0]] = @($row[1],$row[2]) }
if ($dirs['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or $dirs['CraftSuiteFolder'][1] -ne 'ArtCraft' -or
$dirs['CraftSuiteFolder'][0] -ne 'LocalProgramsFolder' -or $dirs['LocalProgramsFolder'][0] -ne 'LocalAppDataFolder') {
throw 'Launcher new-install folder must be under the per-user ArtCraft suite root.'
}
foreach ($explicit in @('', 'F:\Chosen exact launcher')) {
Set-SessionProperty $session 'PREVIOUSINSTALLFOLDER' 'E:\Existing exact launcher'
Set-SessionProperty $session 'INSTALLFOLDER' $explicit
$row = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftRestoreLauncherFolder''' 1)[0]
if ($session.EvaluateCondition($row[0]) -eq 1) {
if ($session.DoAction('CraftRestoreLauncherFolder') -ne 1) { throw 'Launcher restore action failed.' }
}
$expected = if ($explicit) { $explicit } else { 'E:\Existing exact launcher' }
if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Launcher custom or existing path was lost.' }
}
$rows = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4)
if (@($rows | Where-Object { $_[0] -ne '1' }).Count) { throw 'Launcher registration must stay under the current account.' }
foreach ($entry in $policy.registry) {
if (@($rows | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }).Count -ne 1) {
throw 'Launcher registration or shortcut preference changed unexpectedly.'
}
}
Write-Output 'PASS ArtCraft Launcher: per-user scope, suite folder, remembered/custom paths and existing registration. No installation performed.'
return
}
if ($properties['ALLUSERS'] -ne '2' -or $properties['MSIINSTALLPERUSER'] -ne '1') {
throw 'Policy regression: new packages must default to the per-user dual-scope context.'
}
if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'The existing upgrade identity must not change.' }
$directories = @{}
foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) {
$directories[$row[0]] = @($row[1], $row[2])
}
if ($directories['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or
$directories['CraftSuiteFolder'][1] -ne 'ArtCraft') { throw 'Expected the app under the ArtCraft suite folder.' }
$programs = $directories['CraftSuiteFolder'][0]
if ($programs -eq 'ProgramFiles6432Folder') { $programs = $directories[$programs][0] }
if ($programs -notin 'ProgramFilesFolder', 'ProgramFiles64Folder') {
throw 'Use Windows Installer native folder redirection, not a hard-coded profile path.'
}
$guards = @(Read-Rows $database 'SELECT `Condition`, `Description` FROM `LaunchCondition`' 2 |
Where-Object { $_[0] -match 'CRAFT_(USER|MACHINE)_DIR' })
if ($guards.Count -ne 2) { throw 'Expected both duplicate-installation guards.' }
$cases = 0
foreach ($scope in @('', '2', '1')) {
foreach ($user in @('', 'D:\Existing user app')) {
foreach ($machine in @('', 'E:\Existing machine app')) {
foreach ($installed in @('', '1')) {
Set-SessionProperty $session 'ALLUSERS' $scope
Set-SessionProperty $session 'Installed' $installed
Set-SessionProperty $session 'CRAFT_USER_DIR' $user
Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $machine
$allowed = $true
foreach ($guard in $guards) {
$value = $session.EvaluateCondition($guard[0])
if ($value -notin 0, 1) { throw 'Invalid duplicate guard.' }
if ($value -eq 0) { $allowed = $false }
}
$opposite = if ($scope -eq '1') { $user } else { $machine }
$expected = [bool]$installed -or -not [bool]$opposite
if ($allowed -ne $expected) { throw "Scope regression: ALLUSERS='$scope', user=$([bool]$user), machine=$([bool]$machine), maintenance=$([bool]$installed)." }
$cases++
}
}
}
}
# Execute the actual authored type-51 actions in a safe session, not copied resolver logic.
foreach ($scope in @('', '2', '1')) {
foreach ($explicit in @('', 'F:\Chosen exact app')) {
Set-SessionProperty $session 'ALLUSERS' $scope
Set-SessionProperty $session 'INSTALLFOLDER' $explicit
Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app'
Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app'
$action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' }
# Evaluate the compiled condition, so the test exercises it instead of calling the action directly.
$restore = @(Read-Rows $database ('SELECT `Condition` FROM `InstallExecuteSequence` WHERE `Action` = ''{0}''' -f $action) 1)[0]
$selected = $session.EvaluateCondition($restore[0]) -eq 1
if ($selected -eq [bool]$explicit) { throw "$action must run only when no folder was supplied (condition: $($restore[0]))." }
if ($selected -and $session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' }
$expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' }
if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' }
}
}
$ui = @{}
foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] }
if (-not ($ui['AppSearch'] -lt $ui['CraftPreferMachineScope'] -and
$ui['CraftPreferMachineScope'] -lt $ui['CraftScopeDlg'] -and
$ui['CraftScopeDlg'] -lt $ui['FindRelatedProducts'] -and
$ui['FindRelatedProducts'] -lt $ui['LaunchConditions'] -and
$ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'] -and
$ui['CraftScopeDlg'] -lt $ui['CraftRestoreMachineFolder'] -and $ui['CraftRestoreMachineFolder'] -lt $ui['CostInitialize'])) {
throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.'
}
# Windows Installer resolves Program Files for the context it started in, before the wizard's scope choice.
# The wizard therefore sets the new-install folder itself, and only in the wizard.
$defaults = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 | Where-Object { $_[0] -like 'CraftDefault*' })
if ($defaults.Count -lt 2) { throw 'The wizard must set the new-install folder after the scope page; Program Files is resolved before the choice.' }
foreach ($row in $defaults) {
if (-not ($ui['CraftScopeDlg'] -lt [int]$row[1] -and [int]$row[1] -lt $ui['CostInitialize'])) { throw "$($row[0]) must run after the scope page and before costing." }
}
if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) {
throw 'Silent installs must not use the wizard-only default folder actions.'
}
# The machine default comes from the environment of the package architecture, not from a folder name.
# x86 packages use Program Files (x86) on 64-bit Windows; x64 and Arm64 packages use the 64-bit root.
$machineRoot = if ((Get-PackageTemplate $database) -match '^Intel') {
if (Get-SessionProperty $session 'VersionNT64') { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles }
} elseif ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles }
$machineFolder = (Join-Path $machineRoot "ArtCraft\$($policy.name)") + '\'
foreach ($scope in @('1', '')) {
Set-SessionProperty $session 'ALLUSERS' $scope
Set-SessionProperty $session 'INSTALLFOLDER' ''
Set-SessionProperty $session 'CRAFT_MACHINE_DIR' ''
Set-SessionProperty $session 'CRAFT_USER_DIR' ''
foreach ($row in @($defaults | Sort-Object { [int]$_[1] })) {
if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." }
}
$folder = Get-SessionProperty $session 'INSTALLFOLDER'
$wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' }
if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." }
}
# Replay the wizard's compiled folder actions in sequence order, as the full UI runs them. A remembered
# folder must win over the new-install default, and a folder the user typed must survive both.
$uiFolderActions = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 |
Where-Object { $_[0] -match '^Craft(Restore|Default)' } | Sort-Object { [int]$_[1] })
$userDefault = (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\'
foreach ($case in @(
@('1', 'D:\Remembered user app', 'E:\Remembered machine app', '', 'E:\Remembered machine app'),
@('', 'D:\Remembered user app', 'E:\Remembered machine app', '', 'D:\Remembered user app'),
@('1', '', '', '', $machineFolder),
@('', '', '', '', $userDefault),
@('1', 'D:\Remembered user app', 'E:\Remembered machine app', 'F:\Chosen exact app', 'F:\Chosen exact app'))) {
Set-SessionProperty $session 'Installed' ''
Set-SessionProperty $session 'ALLUSERS' $case[0]
Set-SessionProperty $session 'CRAFT_USER_DIR' $case[1]
Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $case[2]
Set-SessionProperty $session 'INSTALLFOLDER' $case[3]
foreach ($row in $uiFolderActions) {
if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." }
}
$folder = Get-SessionProperty $session 'INSTALLFOLDER'
if ($folder -ine $case[4]) { throw "Full-UI folder for ALLUSERS='$($case[0])' is '$folder', expected '$($case[4])'." }
}
$execute = @{}
foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] }
foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) {
if (-not ($execute['AppSearch'] -lt $execute[$action] -and $execute[$action] -lt $execute['CostInitialize'])) {
throw 'Silent updates must restore the folder before directory costing.'
}
}
foreach ($resolver in @('CraftUFromLegacyExe','CraftMFromLegacyExe')) {
if (-not ($execute['AppSearch'] -lt $execute[$resolver] -and $execute[$resolver] -lt $execute['LaunchConditions'])) {
throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.'
}
}
# AppSearch itself never runs in this safe session, so check the compiled searches and then run the
# property actions that turn the search results into the remembered folder, once per scope.
$appSearch = @{}
foreach ($row in (Read-Rows $database 'SELECT `Property`, `Signature_` FROM `AppSearch`' 2)) { $appSearch[$row[0]] = $row[1] }
$locators = @{}
foreach ($row in (Read-Rows $database 'SELECT `Signature_`, `Root`, `Key`, `Name`, `Type` FROM `RegLocator`' 5)) { $locators[$row[0]] = $row }
$appPaths = '^Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\[^\\]+\.exe$'
# Suffix, how to match the key, key, value name, locator type (2 = raw value, 1 = file; the 64-bit flag is masked off).
$searchContract = @(
@('DIR', 'exact', "Software\$($policy.name)", 'InstallDir', '2'),
@('FOLDER', 'exact', "Software\$($policy.name)", 'InstallFolder', '2'),
@('PATH', 'regex', $appPaths, 'Path', '2'),
@('EXEFOLDER', 'regex', $appPaths, '', '1'),
@('EXE', 'regex', $appPaths, '', '1'))
$resolverCases = @(
@{ name = 'every record, DIR wins'; dir = 'D:\Dir\'; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Dir\' },
@{ name = 'FOLDER wins without DIR'; dir = ''; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Folder\' },
@{ name = 'PATH wins without DIR, FOLDER'; dir = ''; folder = ''; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Path\' },
@{ name = 'legacy exe folder is last'; dir = ''; folder = ''; path = ''; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Exe\' },
@{ name = 'legacy folder needs the exe'; dir = ''; folder = ''; path = ''; exeFolder = 'D:\Exe\'; exe = ''; existing = ''; expected = '' },
@{ name = 'a preset folder is kept'; dir = 'D:\Dir\'; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = 'P:\Preset\'; expected = 'P:\Preset\' })
foreach ($scopeSpec in @(@('U', '1', 'CRAFT_USER_DIR'), @('M', '2', 'CRAFT_MACHINE_DIR'))) {
$letter = $scopeSpec[0]; $target = $scopeSpec[2]
foreach ($item in $searchContract) {
$property = "CRAFT_${letter}_$($item[0])"
$locator = if ($appSearch.ContainsKey($property)) { $locators[$appSearch[$property]] } else { $null }
$keyOk = $null -ne $locator -and $(if ($item[1] -eq 'exact') { $locator[2] -eq $item[2] } else { $locator[2] -match $item[2] })
if (-not $keyOk -or $locator[1] -ne $scopeSpec[1] -or $locator[3] -ne $item[3] -or ([int]$locator[4] -band 3) -ne [int]$item[4]) {
throw "Compiled search for $property is missing or points at the wrong registry location."
}
}
$chain = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallExecuteSequence`' 3 |
Where-Object { $_[0] -match "^Craft${letter}From" } | Sort-Object { [int]$_[1] })
$order = @($chain | ForEach-Object { $_[0] }) -join ','
if ($order -ne "Craft${letter}FromDIR,Craft${letter}FromFOLDER,Craft${letter}FromPATH,Craft${letter}FromLegacyExe") {
throw "Remembered-folder resolvers for scope $letter run in the wrong order: $order"
}
foreach ($case in $resolverCases) {
Set-SessionProperty $session $target $case.existing
Set-SessionProperty $session "CRAFT_${letter}_DIR" $case.dir
Set-SessionProperty $session "CRAFT_${letter}_FOLDER" $case.folder
Set-SessionProperty $session "CRAFT_${letter}_PATH" $case.path
Set-SessionProperty $session "CRAFT_${letter}_EXEFOLDER" $case.exeFolder
Set-SessionProperty $session "CRAFT_${letter}_EXE" $case.exe
foreach ($row in $chain) {
if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." }
}
$got = Get-SessionProperty $session $target
if ($got -ne $case.expected) { throw "Resolver chain for scope $letter, case '$($case.name)': got '$got', expected '$($case.expected)'." }
}
}
# Switching the first-page choice must reset BOTH properties, including a back-and-forth change.
$events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4)
# Model the full-UI upgrade entry with an existing machine-wide copy. Run
# the actual preference action, then the compiled Next events before discovery.
Set-SessionProperty $session 'ALLUSERS' '2'
Set-SessionProperty $session 'CRAFT_SCOPE' 'user'
Set-SessionProperty $session 'CRAFT_USER_DIR' ''
Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app'
$preference = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftPreferMachineScope''' 1)[0]
if ($session.EvaluateCondition($preference[0]) -ne 1 -or $session.DoAction('CraftPreferMachineScope') -ne 1 -or
(Get-SessionProperty $session 'CRAFT_SCOPE') -ne 'machine') {
throw 'Full-UI machine upgrades must initially select the existing machine context.'
}
foreach ($choice in @('machine','user','machine','user')) {
Set-SessionProperty $session 'CRAFT_SCOPE' $choice
foreach ($event in $events) {
if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue }
$property = $Matches[1]
if ($session.EvaluateCondition($event[2]) -eq 1) {
Set-SessionProperty $session $property $(if ($event[1] -eq '{}') { '' } else { $event[1] })
}
}
$allUsers = Get-SessionProperty $session 'ALLUSERS'
$perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER'
if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or
($choice -eq 'user' -and ($allUsers -or $perUser -ne '1'))) {
throw 'Scope selector does not reset the requested installation context.'
}
}
$actions = @(Read-Rows $database 'SELECT `Action`, `Source`, `Target`, `Type` FROM `CustomAction`' 4)
$savedOptions = @($actions | Where-Object { $_[0] -match '^CraftLoadOption[0-9]+M$' })
foreach ($option in $savedOptions) {
if ([int]$option[3] -ne 51) { throw 'Saved-option test may run only property-setting actions.' }
$number = [regex]::Match($option[0], '[0-9]+').Value
foreach ($scope in @('2','1')) {
Set-SessionProperty $session 'ALLUSERS' $scope
Set-SessionProperty $session "CRAFT_OPTION_${number}_M" 'saved-machine-choice'
Set-SessionProperty $session "CRAFT_OPTION_${number}_U" 'saved-user-choice'
Set-SessionProperty $session $option[1] ''
$action = "CraftLoadOption$number" + $(if ($scope -eq '1') { 'M' } else { 'U' })
if ($session.DoAction($action) -ne 1) { throw 'Saved-option property action failed.' }
$expected = if ($scope -eq '1') { 'saved-machine-choice' } else { 'saved-user-choice' }
if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' }
}
}
# Follow the compiled control conditions, not the source's intended appearance.
$controls = @(Read-Rows $database 'SELECT `Dialog_`, `Control`, `Type`, `Attributes`, `Text` FROM `Control`' 5)
$conditions = @()
if (@(Read-Rows $database 'SELECT `Name` FROM `_Tables` WHERE `Name` = ''ControlCondition''' 1).Count) {
$conditions = @(Read-Rows $database 'SELECT `Dialog_`, `Control_`, `Action`, `Condition` FROM `ControlCondition`' 4)
}
function Test-ControlVisible($Control) {
$visible = ([int]$Control[3] -band 1) -ne 0
foreach ($condition in $conditions) {
if ($condition[0] -eq $Control[0] -and $condition[1] -eq $Control[1] -and $session.EvaluateCondition($condition[3]) -eq 1) {
if ($condition[2] -eq 'Show') { $visible = $true }
if ($condition[2] -eq 'Hide') { $visible = $false }
}
}
return $visible
}
foreach ($button in @($controls | Where-Object { $_[0] -in 'InstallWelcome','InstallMaintenance','DesktopShortcutDlg' -and ([int]$_[3] -band 8388608) -ne 0 })) {
$plain = @($controls | Where-Object { $_[0] -eq $button[0] -and $_[1] -eq ($button[1] + 'User') })
if ($plain.Count -ne 1 -or ([int]$plain[0][3] -band 8388608)) { throw 'Scope-aware buttons need an unshielded per-user counterpart.' }
foreach ($scope in @('', '2', '1')) {
Set-SessionProperty $session 'ALLUSERS' $scope
if ((Test-ControlVisible $button) -ne ($scope -eq '1') -or (Test-ControlVisible $plain[0]) -ne ($scope -ne '1')) {
throw 'The elevation shield must follow the selected scope.'
}
}
$dialog = $button[0]; $shieldId = $button[1]; $plainId = $plain[0][1]
$shieldEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$shieldId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' })
$plainEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$plainId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' })
if (($shieldEvents -join "`n") -ne ($plainEvents -join "`n")) { throw 'Per-user and machine buttons must perform the same operation.' }
}
foreach ($scope in @('', '2', '1')) {
Set-SessionProperty $session 'ALLUSERS' $scope
foreach ($text in @($controls | Where-Object { $_[4] -like '*for all users*' })) {
if ((Test-ControlVisible $text) -ne ($scope -eq '1')) { throw 'The welcome-page scope text must follow the selected scope.' }
}
}
if ($policy.id -in 'deckcraft','filmcraft','soundcraft') {
foreach ($route in @(@('WelcomeDlg','Next','InstallDirDlg'),@('InstallDirDlg','Back','WelcomeDlg'))) {
$dialog = $route[0]; $control = $route[1]
$routes = @(Read-Rows $database "SELECT ``Argument``, ``Condition`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$control' AND ``Event`` = 'NewDialog' ORDER BY ``Ordering``" 2)
Set-SessionProperty $session 'Installed' ''
$destination = ''
foreach ($event in $routes) { if ($session.EvaluateCondition($event[1]) -eq 1) { $destination = $event[0] } }
if ($destination -ne $route[2]) { throw 'Skipping the licence page must work in both navigation directions.' }
}
}
$registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4)
if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' }
if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) {
throw 'HKCR keys must be relative to Classes.'
}
foreach ($entry in $policy.registry) {
$found = @($registry | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] })
if ($found.Count -ne 1) { throw "Lost or changed registration: $($entry[1]) / $($entry[2])" }
}
$installDir = @($registry | Where-Object { $_[0] -eq '-1' -and $_[1] -eq "Software\$($policy.name)" -and $_[2] -eq 'InstallDir' -and $_[3] -eq '[INSTALLFOLDER]' })
if ($installDir.Count -ne 1) { throw 'A context-sensitive installation-folder record is required.' }
Write-Output "PASS $($policy.name): $cases scope cases, folder preservation, action sequencing and registry contract. No installation performed."
} finally {
foreach ($com in @($session, $database, $installer)) {
if ($null -ne $com) { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($com) }
}
}
+20 -43
View File
@@ -1,26 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
EffectCraft MSI (WiX Toolset v5: `dotnet tool install -g wix`). Built by package.ps1:
wix build effectcraft.wxs installer-ui.wxs -arch x64 -d Version=1.2.3 -d BinDir=... -d IconPath=... -o out.msi
Per-machine install into Program Files (or a folder the user picks), a Start Menu shortcut and,
unless DESKTOPSHORTCUT=0 (the wizard's "Create a desktop shortcut" box), a desktop shortcut.
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package
Name="EffectCraft"
Manufacturer="Learning Machines LLC"
Version="$(var.Version)"
UpgradeCode="AACA8593-C6ED-40A8-BA6C-CB325D8529F9"
Scope="perMachine"
Compressed="yes"
Language="1033">
<Package Name="EffectCraft" Manufacturer="Learning Machines LLC" Version="$(var.Version)" UpgradeCode="AACA8593-C6ED-40A8-BA6C-CB325D8529F9" Scope="perUserOrMachine" Compressed="yes" Language="1033">
<SummaryInformation Description="EffectCraft motion graphics and visual effects" Manufacturer="Learning Machines LLC" />
<?include $(sys.SOURCEFILEDIR)install-policy.wxi?>
<!-- Same-version upgrades let successive pre-releases of one X.Y.Z replace each other
(MSI versions are numeric, so 1.2.3-rc.1 and 1.2.3-rc.2 are both 1.2.3). -->
<MajorUpgrade AllowSameVersionUpgrades="yes"
DowngradeErrorMessage="A newer version of EffectCraft is already installed." />
<MajorUpgrade AllowSameVersionUpgrades="yes" DowngradeErrorMessage="A newer version of EffectCraft is already installed." />
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
<!-- The setup wizard (installer-ui.wxs): pick the folder on a fresh install. -->
<UIRef Id="EffectcraftUI" />
@@ -36,32 +21,24 @@
could be put elsewhere with INSTALLFOLDER=...) the folder of the effectcraft.exe that App Paths names. A
"file" search with no FileSearch inside yields that file's folder. A folder passed on the
command line (INSTALLFOLDER=...) wins. -->
<Property Id="INSTALLFOLDER" Secure="yes" />
<Property Id="PREVIOUSINSTALLFOLDER" Secure="yes">
<RegistrySearch Id="PreviousInstallFolder" Root="HKLM" Type="directory"
Key="Software\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe" Name="Path" />
<RegistrySearch Id="PreviousInstallFolder" Root="HKLM" Type="directory" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe" Name="Path" />
</Property>
<Property Id="PREVIOUSEXEFOLDER" Secure="yes">
<RegistrySearch Id="PreviousExeFolder" Root="HKLM" Type="file"
Key="Software\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe" />
<RegistrySearch Id="PreviousExeFolder" Root="HKLM" Type="file" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe" />
</Property>
<SetProperty Id="INSTALLFOLDER" Value="[PREVIOUSINSTALLFOLDER]" After="AppSearch" Sequence="both"
Condition="PREVIOUSINSTALLFOLDER AND NOT INSTALLFOLDER" />
<SetProperty Id="INSTALLFOLDER" Action="SetINSTALLFOLDERFromExe" Value="[PREVIOUSEXEFOLDER]"
After="SetINSTALLFOLDER" Sequence="both" Condition="PREVIOUSEXEFOLDER AND NOT INSTALLFOLDER" />
<!-- The desktop shortcut: on by default for a fresh install (a box in the wizard, or
DESKTOPSHORTCUT=0 silently); an upgrade keeps what the installed copy chose, and adds none
to an install from before this option. -->
<Property Id="DESKTOPSHORTCUT" Value="1" Secure="yes" />
<Property Id="PREVIOUSDESKTOPSHORTCUT" Secure="yes">
<RegistrySearch Id="PreviousDesktopShortcut" Root="HKLM" Type="raw" Key="Software\EffectCraft" Name="DesktopShortcut" />
</Property>
<SetProperty Id="DESKTOPSHORTCUT" Value="[PREVIOUSDESKTOPSHORTCUT]" After="SetINSTALLFOLDERFromExe" Sequence="both"
Condition="WIX_UPGRADE_DETECTED" />
</Property>
<SetProperty Id="DESKTOPSHORTCUT" Value="[PREVIOUSDESKTOPSHORTCUT]" After="CraftLoadOption1U" Sequence="both" Condition="WIX_UPGRADE_DETECTED" />
<StandardDirectory Id="ProgramFiles6432Folder">
<Directory Id="INSTALLFOLDER" Name="EffectCraft" />
<Directory Id="CraftSuiteFolder" Name="ArtCraft">
<Directory Id="INSTALLFOLDER" Name="EffectCraft" />
</Directory>
</StandardDirectory>
<StandardDirectory Id="ProgramMenuFolder" />
<StandardDirectory Id="DesktopFolder" />
@@ -69,7 +46,9 @@
<Feature Id="Main" Title="EffectCraft" Level="1">
<ComponentGroupRef Id="EffectcraftFiles" />
</Feature>
</Package>
<Property Id="CRAFT_OPTION_1_M" Secure="yes"><RegistrySearch Id="CRAFT_OPTION_1_MSearch" Root="HKLM" Type="raw" Key="Software\EffectCraft" Name="DesktopShortcut" />
</Property><SetProperty Id="PREVIOUSDESKTOPSHORTCUT" Action="CraftLoadOption1M" Value="[CRAFT_OPTION_1_M]" After="LaunchConditions" Sequence="both" Condition="ALLUSERS = 1" /><Property Id="CRAFT_OPTION_1_U" Secure="yes"><RegistrySearch Id="CRAFT_OPTION_1_USearch" Root="HKCU" Type="raw" Key="Software\EffectCraft" Name="DesktopShortcut" />
</Property><SetProperty Id="PREVIOUSDESKTOPSHORTCUT" Action="CraftLoadOption1U" Value="[CRAFT_OPTION_1_U]" After="CraftLoadOption1M" Sequence="both" Condition="ALLUSERS &lt;&gt; 1" /></Package>
<Fragment>
<ComponentGroup Id="EffectcraftFiles" Directory="INSTALLFOLDER">
@@ -78,15 +57,14 @@
</Component>
<Component Id="EffectcraftApp" Guid="BA719860-95FE-4154-B265-47006B8E9083">
<File Id="EffectcraftExe" Source="$(var.BinDir)\effectcraft.exe" KeyPath="yes">
<Shortcut Id="StartMenuShortcut" Directory="ProgramMenuFolder" Name="EffectCraft"
Description="EffectCraft motion graphics and visual effects" WorkingDirectory="INSTALLFOLDER" Advertise="yes"
Icon="EffectcraftIcon.ico" IconIndex="0" />
<Shortcut Id="StartMenuShortcut" Directory="ProgramMenuFolder" Name="EffectCraft" Description="EffectCraft motion graphics and visual effects" WorkingDirectory="INSTALLFOLDER" Advertise="yes" Icon="EffectcraftIcon.ico" IconIndex="0" />
</File>
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe" Value="[#EffectcraftExe]" Type="string" />
<RegistryValue Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe" Name="Path" Value="[INSTALLFOLDER]" Type="string" />
<RegistryValue Root="HKMU" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe" Value="[#EffectcraftExe]" Type="string" />
<RegistryValue Root="HKMU" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe" Name="Path" Value="[INSTALLFOLDER]" Type="string" />
<!-- The desktop shortcut choice, for the next upgrade (empty: none). -->
<RegistryValue Root="HKLM" Key="Software\EffectCraft" Name="DesktopShortcut" Value="[DESKTOPSHORTCUT]" Type="string" />
<RegistryValue Root="HKMU" Key="Software\EffectCraft" Name="DesktopShortcut" Value="[DESKTOPSHORTCUT]" Type="string" />
<RegistryValue Root="HKMU" Key="Software\EffectCraft" Name="InstallDir" Value="[INSTALLFOLDER]" Type="string" />
</Component>
<Component Id="EffectcraftCli">
@@ -95,9 +73,8 @@
<!-- A component under the user's profile folders needs an HKCU key path (ICE38, ICE43). -->
<Component Id="DesktopShortcut" Directory="DesktopFolder" Condition="DESKTOPSHORTCUT = &quot;1&quot;">
<Shortcut Id="DesktopShortcut" Name="EffectCraft" Description="EffectCraft motion graphics and visual effects"
Target="[#EffectcraftExe]" WorkingDirectory="INSTALLFOLDER" Icon="EffectcraftIcon.ico" IconIndex="0" />
<RegistryValue Root="HKCU" Key="Software\EffectCraft" Name="DesktopShortcut" Value="1" Type="string" KeyPath="yes" />
<Shortcut Id="DesktopShortcut" Name="EffectCraft" Description="EffectCraft motion graphics and visual effects" Target="[#EffectcraftExe]" WorkingDirectory="INSTALLFOLDER" Icon="EffectcraftIcon.ico" IconIndex="0" />
<RegistryValue Root="HKMU" Key="Software\EffectCraft" Name="DesktopShortcut" Value="1" Type="string" KeyPath="yes" />
</Component>
</ComponentGroup>
</Fragment>
+44
View File
@@ -0,0 +1,44 @@
{
"id": "effectcraft",
"name": "EffectCraft",
"upgradeCode": "AACA8593-C6ED-40A8-BA6C-CB325D8529F9",
"sources": [
"effectcraft.wxs",
"installer-ui.wxs"
],
"icon": "assets/app-icon/effectcraft.ico",
"localization": [],
"extensions": [],
"payloads": [
"NOTICE",
"effectcraft-cli.exe",
"effectcraft.exe"
],
"registry": [
[
"-1",
"Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\effectcraft.exe",
"",
"[#EffectcraftExe]"
],
[
"-1",
"Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\effectcraft.exe",
"Path",
"[INSTALLFOLDER]"
],
[
"-1",
"Software\\EffectCraft",
"DesktopShortcut",
"[DESKTOPSHORTCUT]"
],
[
"-1",
"Software\\EffectCraft",
"DesktopShortcut",
"1"
]
],
"mode": "dualScope"
}
+71
View File
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<Include xmlns="http://wixtoolset.org/schemas/v4/wxs">
<!-- Choose scope before launch checks and directory costing. Never move an existing copy. -->
<!-- Only for me leaves ALLUSERS unset. Publishing ALLUSERS=2 here makes FindRelatedProducts treat the run as
per-machine and skip the existing per-user copy, so an interactive upgrade installs beside it
(two registered products, and uninstall leaves files behind). Checked with real msiexec runs. -->
<Property Id="INSTALLFOLDER" Secure="yes" />
<Property Id="CRAFT_SCOPE" Value="user" Secure="yes" />
<Property Id="CRAFT_U_DIR" Secure="yes"><RegistrySearch Id="CRAFT_U_DIRSearch" Root="HKCU" Key="Software\EffectCraft" Name="InstallDir" Type="raw" /></Property>
<SetProperty Id="CRAFT_USER_DIR" Action="CraftUFromDIR" Value="[CRAFT_U_DIR]" After="AppSearch" Sequence="both" Condition="NOT CRAFT_USER_DIR AND CRAFT_U_DIR" />
<Property Id="CRAFT_U_FOLDER" Secure="yes"><RegistrySearch Id="CRAFT_U_FOLDERSearch" Root="HKCU" Key="Software\EffectCraft" Name="InstallFolder" Type="raw" /></Property>
<SetProperty Id="CRAFT_USER_DIR" Action="CraftUFromFOLDER" Value="[CRAFT_U_FOLDER]" After="CraftUFromDIR" Sequence="both" Condition="NOT CRAFT_USER_DIR AND CRAFT_U_FOLDER" />
<Property Id="CRAFT_U_PATH" Secure="yes"><RegistrySearch Id="CRAFT_U_PATHSearch" Root="HKCU" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe" Name="Path" Type="raw" /></Property>
<SetProperty Id="CRAFT_USER_DIR" Action="CraftUFromPATH" Value="[CRAFT_U_PATH]" After="CraftUFromFOLDER" Sequence="both" Condition="NOT CRAFT_USER_DIR AND CRAFT_U_PATH" />
<Property Id="CRAFT_U_EXEFOLDER" Secure="yes"><RegistrySearch Id="CRAFT_ULegacyFolder" Root="HKCU" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe" Type="file" /></Property>
<Property Id="CRAFT_U_EXE" Secure="yes"><RegistrySearch Id="CRAFT_ULegacyExe" Root="HKCU" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe" Type="file"><FileSearch Id="CRAFT_ULegacyFile" Name="effectcraft.exe" /></RegistrySearch></Property>
<SetProperty Id="CRAFT_USER_DIR" Action="CraftUFromLegacyExe" Value="[CRAFT_U_EXEFOLDER]" After="CraftUFromPATH" Sequence="both" Condition="NOT CRAFT_USER_DIR AND CRAFT_U_EXEFOLDER AND CRAFT_U_EXE" />
<Property Id="CRAFT_USER_DIR" Secure="yes" />
<Property Id="CRAFT_M_DIR" Secure="yes"><RegistrySearch Id="CRAFT_M_DIRSearch" Root="HKLM" Key="Software\EffectCraft" Name="InstallDir" Type="raw" /></Property>
<SetProperty Id="CRAFT_MACHINE_DIR" Action="CraftMFromDIR" Value="[CRAFT_M_DIR]" After="CraftUFromLegacyExe" Sequence="both" Condition="NOT CRAFT_MACHINE_DIR AND CRAFT_M_DIR" />
<Property Id="CRAFT_M_FOLDER" Secure="yes"><RegistrySearch Id="CRAFT_M_FOLDERSearch" Root="HKLM" Key="Software\EffectCraft" Name="InstallFolder" Type="raw" /></Property>
<SetProperty Id="CRAFT_MACHINE_DIR" Action="CraftMFromFOLDER" Value="[CRAFT_M_FOLDER]" After="CraftMFromDIR" Sequence="both" Condition="NOT CRAFT_MACHINE_DIR AND CRAFT_M_FOLDER" />
<Property Id="CRAFT_M_PATH" Secure="yes"><RegistrySearch Id="CRAFT_M_PATHSearch" Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe" Name="Path" Type="raw" /></Property>
<SetProperty Id="CRAFT_MACHINE_DIR" Action="CraftMFromPATH" Value="[CRAFT_M_PATH]" After="CraftMFromFOLDER" Sequence="both" Condition="NOT CRAFT_MACHINE_DIR AND CRAFT_M_PATH" />
<Property Id="CRAFT_M_EXEFOLDER" Secure="yes"><RegistrySearch Id="CRAFT_MLegacyFolder" Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe" Type="file" /></Property>
<Property Id="CRAFT_M_EXE" Secure="yes"><RegistrySearch Id="CRAFT_MLegacyExe" Root="HKLM" Key="Software\Microsoft\Windows\CurrentVersion\App Paths\effectcraft.exe" Type="file"><FileSearch Id="CRAFT_MLegacyFile" Name="effectcraft.exe" /></RegistrySearch></Property>
<SetProperty Id="CRAFT_MACHINE_DIR" Action="CraftMFromLegacyExe" Value="[CRAFT_M_EXEFOLDER]" After="CraftMFromPATH" Sequence="both" Condition="NOT CRAFT_MACHINE_DIR AND CRAFT_M_EXEFOLDER AND CRAFT_M_EXE" />
<Property Id="CRAFT_MACHINE_DIR" Secure="yes" />
<SetProperty Id="CRAFT_SCOPE" Action="CraftPreferMachineScope" Value="machine" After="CraftMFromLegacyExe" Sequence="ui" Condition="ALLUSERS = 1 OR (CRAFT_MACHINE_DIR AND NOT CRAFT_USER_DIR)" />
<SetProperty Id="INSTALLFOLDER" Action="CraftRestoreUserFolder" Value="[CRAFT_USER_DIR]" Before="CostInitialize" Sequence="both" Condition="NOT INSTALLFOLDER AND ALLUSERS &lt;&gt; 1 AND CRAFT_USER_DIR" />
<!-- The wizard can change the context after Windows Installer has already resolved Program Files for the first one.
Give the new-install folder explicitly so Everyone never lands in the profile and Only for me never lands in Program Files. -->
<?if $(sys.BUILDARCH) = x86 ?>
<SetProperty Id="INSTALLFOLDER" Action="CraftDefaultMachineFolder" Value="[%ProgramFiles(x86)]\ArtCraft\EffectCraft\" After="CraftRestoreUserFolder" Sequence="ui" Condition="NOT INSTALLFOLDER AND ALLUSERS = 1 AND VersionNT64" />
<SetProperty Id="INSTALLFOLDER" Action="CraftDefaultMachineFolder32" Value="[%ProgramFiles]\ArtCraft\EffectCraft\" After="CraftDefaultMachineFolder" Sequence="ui" Condition="NOT INSTALLFOLDER AND ALLUSERS = 1 AND NOT VersionNT64" />
<SetProperty Id="INSTALLFOLDER" Action="CraftDefaultUserFolder" Value="[LocalAppDataFolder]Programs\ArtCraft\EffectCraft\" After="CraftDefaultMachineFolder32" Sequence="ui" Condition="NOT INSTALLFOLDER AND ALLUSERS &lt;&gt; 1" />
<?else?>
<SetProperty Id="INSTALLFOLDER" Action="CraftDefaultMachineFolder" Value="[%ProgramW6432]\ArtCraft\EffectCraft\" After="CraftRestoreUserFolder" Sequence="ui" Condition="NOT INSTALLFOLDER AND ALLUSERS = 1" />
<SetProperty Id="INSTALLFOLDER" Action="CraftDefaultUserFolder" Value="[LocalAppDataFolder]Programs\ArtCraft\EffectCraft\" After="CraftDefaultMachineFolder" Sequence="ui" Condition="NOT INSTALLFOLDER AND ALLUSERS &lt;&gt; 1" />
<?endif?>
<SetProperty Id="INSTALLFOLDER" Action="CraftRestoreMachineFolder" Value="[CRAFT_MACHINE_DIR]" Before="CraftRestoreUserFolder" Sequence="both" Condition="NOT INSTALLFOLDER AND ALLUSERS = 1 AND CRAFT_MACHINE_DIR" />
<Launch Condition="Installed OR ALLUSERS &lt;&gt; 1 OR NOT CRAFT_USER_DIR" Message="This app is already installed for your account. Choose Only for me to update it, or uninstall that copy before changing who it is installed for." />
<Launch Condition="Installed OR ALLUSERS = 1 OR NOT CRAFT_MACHINE_DIR" Message="This app is already installed for everyone. Choose Everyone to update it, or pass ALLUSERS=1 to msiexec. Uninstall that copy before changing who it is installed for." />
<UI>
<TextStyle Id="CraftScopeText" FaceName="Segoe UI" Size="9" />
<TextStyle Id="CraftScopeTitle" FaceName="Segoe UI" Size="12" Bold="yes" />
<Dialog Id="CraftScopeDlg" Width="370" Height="220" Title="[ProductName] Setup" NoMinimize="yes">
<Control Id="Title" Type="Text" X="20" Y="15" Width="330" Height="30" Text="{\CraftScopeTitle}Who should use [ProductName]?" />
<Control Id="Info" Type="Text" X="20" Y="50" Width="330" Height="30" Text="{\CraftScopeText}An update keeps the existing folder. New copies use the ArtCraft folder under Programs." />
<Control Id="Scope" Type="RadioButtonGroup" X="20" Y="88" Width="330" Height="60" Property="CRAFT_SCOPE">
<RadioButtonGroup Property="CRAFT_SCOPE">
<RadioButton Value="user" X="0" Y="0" Width="330" Height="20" Text="Only for me (no administrator permission needed)" />
<RadioButton Value="machine" X="0" Y="30" Width="330" Height="20" Text="Everyone on this computer (asks an administrator)" />
</RadioButtonGroup>
</Control>
<Control Id="Next" Type="PushButton" X="214" Y="182" Width="66" Height="20" Default="yes" Text="Next">
<Publish Property="ALLUSERS" Value="1" Order="1" Condition="CRAFT_SCOPE = &quot;machine&quot;" />
<Publish Property="ALLUSERS" Value="{}" Order="2" Condition="CRAFT_SCOPE = &quot;user&quot;" />
<Publish Property="MSIINSTALLPERUSER" Value="{}" Order="3" Condition="CRAFT_SCOPE = &quot;machine&quot;" />
<Publish Property="MSIINSTALLPERUSER" Value="1" Order="4" Condition="CRAFT_SCOPE = &quot;user&quot;" />
<Publish Event="EndDialog" Value="Return" Order="5" />
</Control>
<Control Id="Cancel" Type="PushButton" X="290" Y="182" Width="60" Height="20" Cancel="yes" Text="Cancel"><Publish Event="EndDialog" Value="Exit" /></Control>
</Dialog>
</UI>
<InstallUISequence>
<Show Dialog="CraftScopeDlg" After="CraftPreferMachineScope" Condition="NOT Installed AND NOT REMOVE AND UILevel = 5" />
<!-- Related-product discovery runs once, so full UI must choose its context first. -->
<FindRelatedProducts After="CraftScopeDlg" />
</InstallUISequence>
</Include>
+18 -15
View File
@@ -1,16 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
EffectCraft installer wizard: original dialogs built from Windows Installer's own controls and
events (no WixUI library), included by effectcraft.wxs with <UIRef Id="EffectcraftUI" />.
fresh install: DestDlg (folder, Change... opens BrowseDlg, desktop shortcut) -> ReadyDlg -> ProgressDlg -> ExitDlg
upgrade: ReadyDlg (the installed copy's folder, not editable) -> ProgressDlg -> ExitDlg
already there: MaintDlg (Repair or Remove) -> ProgressDlg -> ExitDlg
Silent installs (/qn) skip all of this; pass INSTALLFOLDER="..." to choose the folder there.
Folder controls edit INSTALLFOLDER indirectly through _BrowseProperty; SetTargetPath checks the
path and applies it, and Windows Installer reports a bad path itself.
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Fragment>
<UI Id="EffectcraftUI">
@@ -78,7 +66,10 @@
<Control Id="Back" Type="PushButton" X="180" Y="243" Width="56" Height="17" Text="&amp;Back" DisableCondition="WIX_UPGRADE_DETECTED">
<Publish Event="NewDialog" Value="DestDlg" />
</Control>
<Control Id="Install" Type="PushButton" X="236" Y="243" Width="56" Height="17" Default="yes" ElevationShield="yes" Text="&amp;Install">
<Control Id="Install" Type="PushButton" X="236" Y="243" Width="56" Height="17" Default="yes" DefaultCondition="ALLUSERS = 1" ElevationShield="yes" HideCondition="ALLUSERS &lt;&gt; 1" ShowCondition="ALLUSERS = 1" Text="&amp;Install">
<Publish Event="EndDialog" Value="Return" />
</Control>
<Control Id="InstallUser" Type="PushButton" DefaultCondition="ALLUSERS &lt;&gt; 1" HideCondition="ALLUSERS = 1" ShowCondition="ALLUSERS &lt;&gt; 1" X="236" Y="243" Width="56" Height="17" Text="&amp;Install">
<Publish Event="EndDialog" Value="Return" />
</Control>
<Control Id="Cancel" Type="PushButton" X="304" Y="243" Width="56" Height="17" Cancel="yes" Text="Cancel">
@@ -90,7 +81,7 @@
<Dialog Id="MaintDlg" 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="{\EcTitle}[ProductName] is already installed" />
<Control Id="Description" Type="Text" X="20" Y="45" Width="330" Height="24" NoPrefix="yes" Text="Repair puts back missing or damaged files in its folder. Remove uninstalls it; your projects are not touched." />
<Control Id="Repair" Type="PushButton" X="20" Y="80" Width="80" Height="20" Default="yes" ElevationShield="yes" Text="&amp;Repair">
<Control Id="Repair" Type="PushButton" X="20" Y="80" Width="80" Height="20" Default="yes" DefaultCondition="ALLUSERS = 1" ElevationShield="yes" HideCondition="ALLUSERS &lt;&gt; 1" ShowCondition="ALLUSERS = 1" Text="&amp;Repair">
<!-- "a": put back every file, whatever its version or date. With "e"/"o" a changed
unversioned file (effectcraft-cli.exe has no version resource) counts as a user edit
and is kept, and "c" only checks files that carry an MSI checksum. -->
@@ -98,7 +89,19 @@
<Publish Event="Reinstall" Value="ALL" Order="2" />
<Publish Event="EndDialog" Value="Return" Order="3" />
</Control>
<Control Id="Remove" Type="PushButton" X="20" Y="108" Width="80" Height="20" ElevationShield="yes" Text="Re&amp;move">
<Control Id="RepairUser" Type="PushButton" DefaultCondition="ALLUSERS &lt;&gt; 1" HideCondition="ALLUSERS = 1" ShowCondition="ALLUSERS &lt;&gt; 1" X="20" Y="80" Width="80" Height="20" Text="&amp;Repair">
<!-- "a": put back every file, whatever its version or date. With "e"/"o" a changed
unversioned file (effectcraft-cli.exe has no version resource) counts as a user edit
and is kept, and "c" only checks files that carry an MSI checksum. -->
<Publish Event="ReinstallMode" Value="amus" Order="1" />
<Publish Event="Reinstall" Value="ALL" Order="2" />
<Publish Event="EndDialog" Value="Return" Order="3" />
</Control>
<Control Id="Remove" Type="PushButton" X="20" Y="108" Width="80" Height="20" ElevationShield="yes" HideCondition="ALLUSERS &lt;&gt; 1" ShowCondition="ALLUSERS = 1" Text="Re&amp;move">
<Publish Event="Remove" Value="ALL" Order="1" />
<Publish Event="EndDialog" Value="Return" Order="2" />
</Control>
<Control Id="RemoveUser" Type="PushButton" HideCondition="ALLUSERS = 1" ShowCondition="ALLUSERS &lt;&gt; 1" X="20" Y="108" Width="80" Height="20" Text="Re&amp;move">
<Publish Event="Remove" Value="ALL" Order="1" />
<Publish Event="EndDialog" Value="Return" Order="2" />
</Control>
+2
View File
@@ -100,6 +100,8 @@ Invoke-Native 'wix build' {
-d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\effectcraft.ico')" `
-o $Msi
}
# Check the compiled scope, path and registration contract before signing.
& (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Msi
# wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets.
Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb'))
& (Join-Path $PSScriptRoot 'sign.ps1') $Msi
+55
View File
@@ -0,0 +1,55 @@
<#
.SYNOPSIS
Build non-executable installer metadata fixtures; never install them.
#>
param(
[Parameter(Mandatory = $true)] [string] $OutputDirectory,
[ValidateSet('x64','x86','arm64')] [string[]] $Architectures = @('x64','x86','arm64')
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json
[void](New-Item -ItemType Directory -Force -Path $OutputDirectory)
$output = (Resolve-Path -LiteralPath $OutputDirectory).Path
& (Join-Path $PSScriptRoot 'test-legacy-fixture.ps1') -Work (Join-Path $output 'legacy-source')
Add-Type -AssemblyName System.Drawing
foreach ($arch in $Architectures) {
$stage = Join-Path $output $arch
[void](New-Item -ItemType Directory -Force -Path $stage)
foreach ($name in $policy.payloads) {
$destination = Join-Path $stage $name
[void](New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($destination)))
Set-Content -LiteralPath $destination -Encoding Ascii -Value 'NON-EXECUTABLE MSI METADATA TEST FIXTURE'
}
foreach ($folder in @('docs','models','notices')) {
$path = Join-Path $stage $folder
[void](New-Item -ItemType Directory -Force -Path $path)
Set-Content -LiteralPath (Join-Path $path "$folder-fixture.txt") -Value 'Metadata test fixture, not a user file or model.'
}
foreach ($model in @('metadata-one.rten','metadata-two.rten')) {
Set-Content -LiteralPath (Join-Path (Join-Path $stage 'models') $model) -Encoding Ascii -Value 'NON-EXECUTABLE MODEL METADATA FIXTURE'
}
foreach ($spec in @(@('banner.bmp',493,58),@('dialog.bmp',493,312))) {
$bitmap = [Drawing.Bitmap]::new($spec[1],$spec[2],[Drawing.Imaging.PixelFormat]::Format24bppRgb)
try { $bitmap.Save((Join-Path $stage $spec[0]),[Drawing.Imaging.ImageFormat]::Bmp) }
finally { $bitmap.Dispose() }
}
Set-Content -LiteralPath (Join-Path $stage 'licence.rtf') -Encoding Ascii -Value '{\rtf1 Metadata test fixture.}'
$sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ })
$msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi"
$extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" })
& wix build @sources -arch $arch @extensions `
-culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' `
-d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" `
-d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" `
-d "AppNoticesDir=$(Join-Path $stage 'notices')" -d 'HasCraftFontLicences=1' -d 'HasCli=1' `
-d "BannerBmp=$(Join-Path $stage 'banner.bmp')" -d "DialogBmp=$(Join-Path $stage 'dialog.bmp')" `
-d "UiBannerBmp=$(Join-Path $stage 'banner.bmp')" -d "UiDialogBmp=$(Join-Path $stage 'dialog.bmp')" `
-d "LicenseRtf=$(Join-Path $stage 'licence.rtf')" -o $msi @($policy.localization | ForEach-Object { '-loc'; Join-Path $PSScriptRoot $_ })
if ($LASTEXITCODE -ne 0) { throw "WiX validation failed for $arch (exit $LASTEXITCODE)." }
$shell = (Get-Process -Id $PID).Path
& $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $msi
if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $arch (exit $LASTEXITCODE)." }
Write-Output "PASS $arch metadata-only package. This is not an executable installer."
}
+21
View File
@@ -0,0 +1,21 @@
<# Exercise the legacy source transformation without building or installing. #>
param([Parameter(Mandatory = $true)] [string] $Work)
$ErrorActionPreference = 'Stop'
[void](New-Item -ItemType Directory -Force -Path $Work)
$source = Get-Content -Raw (Join-Path $PSScriptRoot 'test-msi.ps1')
$start = $source.IndexOf('$Wxs =')
$end = $source.IndexOf('$Msi = @{}', $start)
if ($start -lt 0 -or $end -le $start) { throw 'Could not locate the legacy source transformation.' }
# Run the test harness's exact source-producing block, with only scratch outputs.
$block = $source.Substring($start, $end - $start).Replace('$PSScriptRoot', ("'" + $PSScriptRoot.Replace("'", "''") + "'"))
Invoke-Expression $block
[xml]$legacy = Get-Content -Raw $LegacyWxs
$ns = New-Object Xml.XmlNamespaceManager($legacy.NameTable)
$ns.AddNamespace('w', 'http://wixtoolset.org/schemas/v4/wxs')
if ($legacy.SelectNodes('//w:RegistryValue[@Name="Path" or @Name="InstallDir" or @Name="InstallFolder"]', $ns).Count) {
throw 'Legacy fixture must omit every modern folder record so discovery uses the executable fallback.'
}
if (-not $legacy.SelectNodes('//w:RegistryValue[contains(@Value,"EffectcraftExe")]', $ns).Count) {
throw 'Legacy fixture must retain its executable registration.'
}
Write-Output 'PASS legacy fixture exposes only the executable-path fallback.'
+11 -6
View File
@@ -11,7 +11,7 @@
3. upgrades to 0.0.3 the same way (found from the Path value 0.0.2 wrote),
4. repairs a damaged (unversioned) CLI there with the wizard's Repair mode,
5. uninstalls: files, the App Paths registration and the desktop shortcut go,
6. installs 0.0.3 with no folder and DESKTOPSHORTCUT=0: it must land in Program Files with no
6. installs 0.0.3 with no folder and DESKTOPSHORTCUT=0: it must land in Program Files\ArtCraft\EffectCraft (ALLUSERS=1) with no
desktop shortcut, then uninstalls.
The installs before step 6 take the default desktop shortcut, which upgrades keep.
The wizard itself needs a person (or the snapshot steps in the PR); this covers what msiexec /qn
@@ -36,7 +36,9 @@ function Invoke-Native([string] $What, [scriptblock] $Block) {
}
function Invoke-Msiexec([string] $What, [string[]] $Arguments) {
$log = Join-Path $Work "$($What -replace '\W', '-').log"
$p = Start-Process msiexec.exe -ArgumentList ($Arguments + '/qn', '/l*v', "`"$log`"") -Wait -PassThru
# This existing VM regression deliberately covers the all-users upgrade route.
$p = Start-Process msiexec.exe -ArgumentList ($Arguments + 'ALLUSERS=1', '/qn', '/norestart', '/l*v', "`"$log`"") -PassThru
if (-not $p.WaitForExit(300000)) { $p.Kill(); throw "msiexec ($What) exceeded five minutes; see $log" }
if ($p.ExitCode -ne 0) { Get-Content $log -Tail 60; throw "msiexec ($What) exited $($p.ExitCode)" }
Write-Output "ok $What"
}
@@ -65,17 +67,20 @@ New-Item -ItemType Directory -Force -Path $Bin | Out-Null
# The MSI only copies these. The app is versioned (it embeds VERSIONINFO); the real CLI has no
# version resource, so its stand-in is an unversioned file too: Windows Installer keeps a changed
# unversioned file on repair unless the repair mode forces it.
Copy-Item (Join-Path $env:SystemRoot 'System32\notepad.exe') (Join-Path $Bin 'effectcraft.exe')
Copy-Item (Join-Path $env:SystemRoot 'System32\cmd.exe') (Join-Path $Bin 'effectcraft.exe')
[IO.File]::WriteAllText((Join-Path $Bin 'effectcraft-cli.exe'), 'effectcraft-cli stand-in')
[IO.File]::WriteAllText((Join-Path $Bin 'NOTICE'), 'Test fixture notice')
# 0.0.1 stands in for releases before this installer change: the same package without the App
# Paths "Path" value.
# Paths "Path" value or modern InstallDir/InstallFolder records.
$Wxs = Join-Path $PSScriptRoot 'effectcraft.wxs'
$LegacyWxs = Join-Path $Work 'effectcraft-legacy.wxs'
$pathValue = '(?m)^.*<RegistryValue [^>]*Name="Path"[^>]*/>\r?\n'
$pathValue = '(?m)^.*<RegistryValue [^>]*Name="(?:Path|InstallDir|InstallFolder)"[^>]*/>\r?\n'
$text = Get-Content -Raw $Wxs
if ($text -notmatch $pathValue) { throw "no App Paths Path value in $Wxs to leave out" }
[IO.File]::WriteAllText($LegacyWxs, ($text -replace $pathValue, ''))
# The legacy source lives in the scratch folder, so its local include must travel with it.
Copy-Item (Join-Path $PSScriptRoot 'install-policy.wxi') (Join-Path $Work 'install-policy.wxi')
$Msi = @{}
foreach ($v in '0.0.1', '0.0.2', '0.0.3') {
@@ -90,7 +95,7 @@ foreach ($v in '0.0.1', '0.0.2', '0.0.3') {
# Spaces and a non-ASCII letter (written as a char so the script's own encoding doesn't matter).
$Custom = Join-Path $env:SystemDrive "EffectCraft Test $([char]0x00DC)nicode\My Apps"
$Default = Join-Path $env:ProgramFiles 'EffectCraft'
$Default = Join-Path $env:ProgramFiles 'ArtCraft\EffectCraft'
if (Test-Path -LiteralPath $AppPaths) { throw 'EffectCraft is already installed here; run this on a clean machine' }
Invoke-Msiexec 'install the legacy package to a custom folder' @('/i', "`"$($Msi['0.0.1'])`"", "INSTALLFOLDER=`"$Custom`"")