Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Install-Store.cmd
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ if %_build% lss 19041 (

rem Packages: local ones next to this script if present (-Source Auto), otherwise downloaded from Microsoft.
rem Options (-Source, -WorkDir, -DownloadDir, -DownloadOnly, -NoProvision, -SkipWinget, ...) pass through.
rem Match -Source only as a whole option (" -Source " or " -Source:"), not inside a path such as D:\my-source.
set "_src=-Source Auto"
echo( %_args% | findstr /i /c:"-Source" 1>nul && set "_src="
echo( %_args% _| findstr /i /r /c:" -Source[ :]" 1>nul && set "_src="
"%_psexe%" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%_ps1%" %_src% %_args%
set "_rc=%ERRORLEVEL%"
if defined _pause (
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ In online mode, the script looks up the apps in Microsoft's Store catalog and re

For each download, the script checks the file size and the hash supplied by Microsoft over HTTPS. It uses SHA-256 when available and falls back to SHA-1. It also requires a valid Microsoft Corporation code signature before replacing the destination file. Failed attempts remove their temporary files, and the script retries before reporting a failure.

For local packages, the script reads identities and dependencies from package manifests. It filters unsupported architectures and Windows versions before selecting apps. Missing dependencies and framework dependency cycles stop the run before installation.
For local packages, the script reads identities and dependencies from package manifests. It filters unsupported architectures and Windows versions before selecting apps. Missing Microsoft Store dependencies and framework dependency cycles stop the run before installation. A companion app with missing dependencies is skipped and reported as a partial failure.

The script installs frameworks in dependency order, followed by Microsoft Store and the companion apps. By default, it provisions packages in the Windows image and registers them for the current user. `-NoProvision` skips provisioning. Missing requested companion apps produce a partial-failure result, including in download-only mode.

Expand Down
6 changes: 4 additions & 2 deletions docs/installer-reliability.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ Legacy folders without a JSON manifest remain supported. They do not receive the

Local selection skips packages with an unsupported architecture or Windows version before choosing the newest usable app. Equal versions prefer the native architecture. Bundles use their highest-ranked compatible architecture payload for dependency and Windows-version checks.

Frameworks install in dependency order. Before deployment, the script checks required names, publishers, versions, and architectures against the supplied frameworks and applicable installed or provisioned packages. Missing dependencies and cycles stop the run before installation. Keep the complete framework set in an offline folder.
Frameworks install in dependency order. Before deployment, the script checks required names, publishers, versions, and architectures against the supplied frameworks and applicable installed or provisioned packages. Missing Microsoft Store dependencies and framework cycles stop the run before installation. A companion or additional app with missing dependencies is skipped, and the run reports a partial failure, so it cannot block the Store. Keep the complete framework set in an offline folder.

Relative `-WorkDir`, `-LogDir`, and `-DownloadDir` paths resolve against the current PowerShell location before any relaunch. `-Source Local` without `-WorkDir` uses the current PowerShell location. The elevated or 64-bit child receives the resolved paths.

## Exit codes

Expand All @@ -41,6 +43,6 @@ powershell.exe -NoProfile -NonInteractive -File .\tests\Run-Tests.ps1
pwsh.exe -NoProfile -NonInteractive -File .\tests\Run-Tests.ps1
```

The tests use the installer's actual functions and main orchestration with fixture packages and mocked deployment calls. They cover caller scope, relaunch snapshots and arguments, cleanup ownership, package selection, dependencies, offline manifests, incomplete requested sets, and cancellation of a stalled loopback HTTP transfer. GitHub Actions runs the suite under both PowerShell engines.
The tests use the installer's actual functions and main orchestration with fixture packages and mocked deployment calls. They cover caller scope, relaunch snapshots and arguments, relative path resolution, the command launcher's `-Source` default, cleanup ownership, package selection, dependencies, offline manifests, incomplete requested sets, and cancellation of a stalled loopback HTTP transfer. GitHub Actions runs the suite under both PowerShell engines.

These tests do not install packages or exercise UAC. They do not prove that Microsoft's live delivery service offers an installable package set for a particular LTSC image. Before release, test online and offline installation on disposable LTSC 2021 and LTSC 2024 systems, including SYSTEM provisioning and a new user's first sign-in. Check that Microsoft Store opens, winget runs, and the expected packages register for the intended users.
89 changes: 67 additions & 22 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,10 @@ function Invoke-Relaunch {
$file = Join-Path $tempDir 'install.ps1'
if (-not $script:ScriptText) { throw 'The executing script content is unavailable for relaunch.' }
[IO.File]::WriteAllText($file, $script:ScriptText, [Text.UTF8Encoding]::new($true))
if (-not $bound.ContainsKey('WorkDir')) { $bound['WorkDir'] = $WorkDir }
# Pass resolved paths: the child can start elsewhere (elevated processes start in System32).
$bound['WorkDir'] = $WorkDir
if ($bound.ContainsKey('LogDir')) { $bound['LogDir'] = $LogDir }
if ($bound.ContainsKey('DownloadDir')) { $bound['DownloadDir'] = $DownloadDir }
if (-not $bound.ContainsKey('LogDir')) { $bound['LogDir'] = Join-Path ([IO.Path]::GetTempPath()) 'StoreInstall-Logs' }

foreach ($key in @($bound.Keys)) {
Expand Down Expand Up @@ -242,6 +245,13 @@ function Invoke-Relaunch {
}
}

function Resolve-UserPath {
# Resolve relative paths against the PowerShell location, not the process directory .NET uses.
param([AllowNull()][AllowEmptyString()][string] $Path)
if (-not $Path) { return $Path }
return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
}

function Get-OsArch {
# The machine-wide value reflects the native arch even from WOW64/x64-emulated processes.
$native = $null
Expand Down Expand Up @@ -375,6 +385,42 @@ function Get-PackageMetadata {
finally { $zip.Dispose() }
}

function Get-PackageDependencyState {
# Splits a package's dependencies into supplied frameworks it needs and dependencies nothing satisfies.
param([Parameter(Mandatory)] $Package, [object[]] $Frameworks)
$required = @()
$missing = @()
foreach ($dep in $Package.Identity.Dependencies) {
$supplied = @($Frameworks | Where-Object { $_.Identity.Name -eq $dep.Name -and $_.Identity.Version -ge $dep.MinVersion -and (-not $dep.Publisher -or $_.Identity.Publisher -eq $dep.Publisher) -and ($_.Identity.Arch -eq 'neutral' -or $_.Identity.Arch -eq $dep.Arch) })
if ($supplied.Count -gt 0) { $required += $supplied; continue }
if ($script:DoProvision) {
$expectedFamily = if ($dep.Publisher) { $dep.Name + '_' + (Get-PublisherId $dep.Publisher) } else { $null }
$installed = @(Get-AppxProvisionedPackage -Online -ErrorAction Stop | Where-Object {
$provisionedIdentity = Get-PackageIdentity -FileName ($_.PackageName + '.appx')
$provisionedIdentity.Name -eq $dep.Name -and $provisionedIdentity.Version -ge $dep.MinVersion -and (-not $expectedFamily -or $provisionedIdentity.Family -eq $expectedFamily) -and ($provisionedIdentity.Arch -eq 'neutral' -or $provisionedIdentity.Arch -eq $dep.Arch)
})
}
else { $installed = @(Get-AppxPackage -Name $dep.Name -ErrorAction SilentlyContinue | Where-Object { [version]$_.Version -ge $dep.MinVersion -and (-not $dep.Publisher -or $_.Publisher -eq $dep.Publisher) -and ($_.Architecture.ToString() -eq 'Neutral' -or $_.Architecture.ToString() -eq $dep.Arch) }) }
if ($installed.Count -eq 0) { $missing += $dep }
}
return [pscustomobject]@{ Required = $required; Missing = $missing }
}

function Format-MissingDependency {
param([Parameter(Mandatory)] $Dependency, [Parameter(Mandatory)] $Package)
return "Missing dependency '$($Dependency.Name)' version $($Dependency.MinVersion) or newer for '$($Package.Identity.Name)' ($($Dependency.Arch))."
}

function Test-OptionalAppDependency {
# Returns $true when an optional app's dependencies are satisfied; otherwise logs them and counts a partial failure.
param([Parameter(Mandatory)] $Package, [object[]] $Frameworks)
$missing = @((Get-PackageDependencyState -Package $Package -Frameworks $Frameworks).Missing)
if ($missing.Count -eq 0) { return $true }
foreach ($dep in $missing) { Write-Log "Skipping $($Package.File.Name): $(Format-MissingDependency $dep $Package)" 'WARN' }
$script:FailCount++
return $false
}

function Get-DependencyInstallOrder {
param([object[]] $Frameworks, [object[]] $Applications)
$ordered = New-Object System.Collections.Generic.List[object]
Expand All @@ -383,21 +429,9 @@ function Get-DependencyInstallOrder {
$allPackages = @($Frameworks) + @($Applications)
$edges = @{}
foreach ($pkg in $allPackages) {
$required = @()
foreach ($dep in $pkg.Identity.Dependencies) {
$matches = @($Frameworks | Where-Object { $_.Identity.Name -eq $dep.Name -and $_.Identity.Version -ge $dep.MinVersion -and (-not $dep.Publisher -or $_.Identity.Publisher -eq $dep.Publisher) -and ($_.Identity.Arch -eq 'neutral' -or $_.Identity.Arch -eq $dep.Arch) })
if ($matches.Count -gt 0) { $required += $matches; continue }
if ($script:DoProvision) {
$expectedFamily = if ($dep.Publisher) { $dep.Name + '_' + (Get-PublisherId $dep.Publisher) } else { $null }
$installed = @(Get-AppxProvisionedPackage -Online -ErrorAction Stop | Where-Object {
$provisionedIdentity = Get-PackageIdentity -FileName ($_.PackageName + '.appx')
$provisionedIdentity.Name -eq $dep.Name -and $provisionedIdentity.Version -ge $dep.MinVersion -and (-not $expectedFamily -or $provisionedIdentity.Family -eq $expectedFamily) -and ($provisionedIdentity.Arch -eq 'neutral' -or $provisionedIdentity.Arch -eq $dep.Arch)
})
}
else { $installed = @(Get-AppxPackage -Name $dep.Name -ErrorAction SilentlyContinue | Where-Object { [version]$_.Version -ge $dep.MinVersion -and (-not $dep.Publisher -or $_.Publisher -eq $dep.Publisher) -and ($_.Architecture.ToString() -eq 'Neutral' -or $_.Architecture.ToString() -eq $dep.Arch) }) }
if ($installed.Count -eq 0) { throw "Missing dependency '$($dep.Name)' version $($dep.MinVersion) or newer for '$($pkg.Identity.Name)' ($($dep.Arch)). Add the matching framework package to the package directory." }
}
$edges[$pkg.File.FullName] = $required
$state = Get-PackageDependencyState -Package $pkg -Frameworks $Frameworks
if (@($state.Missing).Count -gt 0) { throw "$(Format-MissingDependency @($state.Missing)[0] $pkg) Add the matching framework package to the package directory." }
$edges[$pkg.File.FullName] = @($state.Required)
}
while ($pending.Count -gt 0) {
$next = @($pending | Where-Object { $blocked = $false; foreach ($dependency in $edges[$_.File.FullName]) { if ($pending.Contains($dependency)) { $blocked = $true } }; -not $blocked } | Sort-Object { $_.File.Name } | Select-Object -First 1)
Expand Down Expand Up @@ -912,6 +946,14 @@ try {
Write-Host 'ERROR: -DownloadOnly cannot be combined with -Source Local.'
return 7
}

# Resolve paths before any relaunch, which may start in another directory.
# WorkDir is empty when run via irm | iex; local packages then come from the current folder only with -Source Local.
if (-not $WorkDir -and $Source -eq 'Local') { $WorkDir = (Get-Location).ProviderPath }
$WorkDir = Resolve-UserPath $WorkDir
$LogDir = Resolve-UserPath $LogDir
$DownloadDir = Resolve-UserPath $DownloadDir

if (-not $DownloadOnly -and -not (Test-IsElevated)) {
if (-not (Test-CanPromptForElevation)) {
Write-Host 'ERROR: Run this script as Administrator.'
Expand All @@ -938,10 +980,7 @@ try {
return 2
}

# WorkDir is empty when run via irm | iex; local packages then come from the current folder only with -Source Local.
if (-not $WorkDir -and $Source -eq 'Local') { $WorkDir = (Get-Location).ProviderPath }
if ($WorkDir) {
$WorkDir = [IO.Path]::GetFullPath($WorkDir)
if (-not (Test-Path -LiteralPath $WorkDir -PathType Container)) {
Write-Host "ERROR: WorkDir not found: $WorkDir"
return 7
Expand All @@ -959,7 +998,7 @@ try {
$tempLogDir = Join-Path ([IO.Path]::GetTempPath()) 'StoreInstall-Logs'
$logName = 'StoreInstall-{0:yyyyMMdd-HHmmss}-{1}.log' -f (Get-Date), ([guid]::NewGuid().ToString('N'))
$logCandidates = @(
$(if ($LogDir) { [IO.Path]::GetFullPath($LogDir) } elseif ($WorkDir) { Join-Path $WorkDir 'Logs' } else { $tempLogDir }),
$(if ($LogDir) { $LogDir } elseif ($WorkDir) { Join-Path $WorkDir 'Logs' } else { $tempLogDir }),
$tempLogDir
) | Select-Object -Unique
foreach ($dir in $logCandidates) {
Expand Down Expand Up @@ -1014,7 +1053,7 @@ try {

if ($online) {
if ($DownloadDir) {
$pkgDir = [IO.Path]::GetFullPath($DownloadDir)
$pkgDir = $DownloadDir
}
else {
$runToken = [guid]::NewGuid().ToString('N')
Expand Down Expand Up @@ -1141,8 +1180,14 @@ try {
$uniqueFw[$key] = Select-NewerPackage $uniqueFw[$key] $fw
}
try {
# Optional apps with unmet dependencies are skipped (partial failure) so they cannot block the Store.
$fwCandidates = @($uniqueFw.Values)
if ($purchase -and -not (Test-OptionalAppDependency $purchase $fwCandidates)) { $purchase = $null }
if ($xboxId -and -not (Test-OptionalAppDependency $xboxId $fwCandidates)) { $xboxId = $null }
if ($winget -and -not (Test-OptionalAppDependency $winget $fwCandidates)) { $winget = $null }
$other = @($other | Where-Object { Test-OptionalAppDependency $_ $fwCandidates })
$apps = @(@($store, $purchase, $xboxId, $winget) | Where-Object { $null -ne $_ })
$sortedFw = @(Get-DependencyInstallOrder -Frameworks @($uniqueFw.Values) -Applications @($apps + @($other.ToArray())))
$sortedFw = @(Get-DependencyInstallOrder -Frameworks $fwCandidates -Applications @($apps + $other))
}
catch { Write-Log $_.Exception.Message 'ERROR'; return (Complete-Run 4) }

Expand Down
27 changes: 27 additions & 0 deletions tests/Test-CmdLauncher.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#Requires -Version 5.1
$ErrorActionPreference = 'Stop'
function Assert-True($Value, $Message) { if (-not $Value) { throw $Message } }

# Run the launcher's actual -Source detection line in an isolated batch file.
$launcher = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\Install-Store.cmd')
$detection = @($launcher | Where-Object { $_ -match '^echo\( %_args% .*findstr' })
Assert-True ($detection.Count -eq 1) 'Launcher -Source detection line not found.'
$temp = Join-Path ([IO.Path]::GetTempPath()) ('store-cmd-test-' + [guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory -Path $temp
try {
$probe = Join-Path $temp 'probe.cmd'
# Arguments arrive through the environment, avoiding engine-specific native quoting of embedded quotes.
[IO.File]::WriteAllText($probe, ((@('@echo off', 'set "_args=%STORE_LTSC_TEST_ARGS%"', 'set "_src=-Source Auto"') + $detection + 'echo [%_src%]') -join "`r`n") + "`r`n")
function Get-SourceDefault([string] $Arguments) {
$env:STORE_LTSC_TEST_ARGS = $Arguments
try { & cmd.exe /d /c $probe | Select-Object -Last 1 } finally { Remove-Item Env:STORE_LTSC_TEST_ARGS -ErrorAction SilentlyContinue }
}
Assert-True ((Get-SourceDefault '') -eq '[-Source Auto]') 'No options must default to -Source Auto.'
Assert-True ((Get-SourceDefault '-WorkDir D:\my-source-pkgs') -eq '[-Source Auto]') 'A path containing "-source" must not suppress -Source Auto.'
Assert-True ((Get-SourceDefault '-SkipWinget -Sourcery') -eq '[-Source Auto]') 'A longer option name must not suppress -Source Auto.'
Assert-True ((Get-SourceDefault '-Source Online') -eq '[]') 'An explicit -Source must replace the default.'
Assert-True ((Get-SourceDefault '-SkipWinget -source:Local') -eq '[]') 'An explicit -Source:value must replace the default.'
Assert-True ((Get-SourceDefault '-WorkDir "D:\Store Pkgs" -Source Local') -eq '[]') 'An explicit -Source after a quoted path must replace the default.'
Write-Host 'PASS: launcher applies -Source Auto unless -Source is given as an option.'
}
finally { Remove-Item -LiteralPath $temp -Recurse -Force }
Loading
Loading