Overhauls Store app installation with dependency resolution

Refactors the Store app installation script to be significantly more robust and intelligent. The new implementation automatically resolves application dependencies instead of relying on a simple folder structure.

Key improvements include:
- Pre-scans all application folders to create a central catalog of available dependencies.
- Parses the `AppxManifest.xml` from each main app package (including from within bundles) to determine its true dependencies.
- Resolves the required dependencies by finding the best available package from the catalog that meets version and OS architecture requirements.
- Adds support for extracting zipped dependency packages.
- Improves temporary file management and logging.
This commit is contained in:
rbalsleyMSFT
2025-07-25 14:31:03 -07:00
parent 93f6eeac87
commit fc79251f66
3 changed files with 264 additions and 59 deletions
@@ -1,27 +1,235 @@
#Requires -RunAsAdministrator
# --- CONFIGURATION ---
# Base path where application folders are located. Each subfolder represents one application.
$basePath = "D:\MSStore" $basePath = "D:\MSStore"
# Path for temporary files (e.g., for extracting archives). This will be created and cleaned up automatically.
$tempBasePath = Join-Path -Path $env:TEMP -ChildPath "StoreAppInstall"
# --- SCRIPT ---
# Helper function to clean up temporary files on exit or error
function Remove-TemporaryFiles {
if (Test-Path -Path $tempBasePath) {
Write-Host "Cleaning up temporary directory: $tempBasePath"
Remove-Item -Path $tempBasePath -Recurse -Force
}
}
# Ensure temp directory is clean before starting
Remove-TemporaryFiles
New-Item -Path $tempBasePath -ItemType Directory -Force | Out-Null
# 1. Determine applicable dependency architectures based on the OS architecture
$osArchitecture = $env:PROCESSOR_ARCHITECTURE
$applicableArchitectures = switch ($osArchitecture) {
"AMD64" { 'x64', 'x86' }
"x86" { 'x86' }
"ARM64" { 'arm64', 'arm' }
default { $osArchitecture.ToLower() }
}
Write-Host "Installing Store Apps: Detected OS Architecture: $osArchitecture."
Write-Host "Applicable dependency architectures: $($applicableArchitectures -join ', ')"
# Check if the base path exists # Check if the base path exists
Write-Host "Installing Store Apps: Checking for $basePath"
if (-not (Test-Path -Path $basePath)) { if (-not (Test-Path -Path $basePath)) {
Write-Host "Installing Store Apps: $basePath does not exist." Write-Host "Installing Store Apps: Base path '$basePath' does not exist. Exiting."
exit exit
} }
Write-Host "Installing Store Apps: $basePath exists, installing apps." Write-Host "Installing Store Apps: Base path '$basePath' exists."
# Process each app folder in the base path # 2. Pre-scan and catalog all available dependencies from all app folders
Write-Host "Scanning for all available application dependencies..."
$allAvailableDependencies = @{}
$dependencyFoldersToScan = [System.Collections.Generic.List[string]]::new()
# Find all 'Dependencies' subfolders
Get-ChildItem -Path $basePath -Directory | ForEach-Object {
$dependenciesPath = Join-Path -Path $_.FullName -ChildPath "Dependencies"
if (Test-Path -Path $dependenciesPath) {
$dependencyFoldersToScan.Add($dependenciesPath)
}
}
# Handle zipped dependencies by extracting them to a temp location
$dependencyFoldersToScan.ToArray() | ForEach-Object {
$folder = $_
Get-ChildItem -Path $folder -Filter "*.zip" -File | ForEach-Object {
$zipFile = $_
$extractPath = Join-Path -Path $tempBasePath -ChildPath $zipFile.BaseName
Write-Host "Extracting zipped dependencies from '$($zipFile.FullName)' to '$extractPath'..."
try {
Expand-Archive -Path $zipFile.FullName -DestinationPath $extractPath -Force
$script:dependencyFoldersToScan.Add($extractPath)
}
catch {
Write-Error "Failed to extract '$($zipFile.FullName)'. Error: $($_.Exception.Message)"
}
}
}
# Regex to parse package filenames: Name_Version_Architecture__PublisherID (PublisherID and other tags are optional)
$packageFileRegex = '^(?<Name>.+?)_(?<Version>(?:\d+\.){2,3}\d+)_(?:[^_]+_)*(?<Arch>x64|x86|arm|arm64|neutral)(?:__.*)?$'
# Catalog all package files found in the dependency folders
foreach ($folder in $dependencyFoldersToScan.ToArray() | Select-Object -Unique) {
Get-ChildItem -Path $folder -Recurse -File | Where-Object { $_.Extension -in '.appx', '.msix', '.appxbundle' } | ForEach-Object {
$file = $_
$match = $file.BaseName -imatch $packageFileRegex
if ($match) {
$dependencyName = $matches.Name
try {
$dependencyVersion = [System.Version]$matches.Version
$dependencyArch = $matches.Arch
if (-not $allAvailableDependencies.ContainsKey($dependencyName)) {
$allAvailableDependencies[$dependencyName] = [System.Collections.Generic.List[object]]::new()
}
$allAvailableDependencies[$dependencyName].Add([pscustomobject]@{
Name = $dependencyName
Version = $dependencyVersion
Arch = $dependencyArch
Path = $file.FullName
})
}
catch {
Write-Warning "Could not parse version for file '$($file.Name)'. Skipping."
}
}
}
}
Write-Host "Dependency scan complete. Found $($allAvailableDependencies.Keys.Count) unique dependency packages."
Write-Output ""
# 3. Process and install each main application
Write-Host "Starting main application installation process..."
foreach ($appFolder in Get-ChildItem -Path $basePath -Directory) { foreach ($appFolder in Get-ChildItem -Path $basePath -Directory) {
$folderPath = $appFolder.FullName Write-Host "--- Processing application in folder: $($appFolder.Name) ---"
$dependenciesFolder = Join-Path -Path $folderPath -ChildPath "Dependencies"
# Find main package - exclude Dependencies folder items and xml/yaml files # Find the main application package (.appx/.msix/.appxbundle) in the app's root folder
$mainPackage = Get-ChildItem -Path $folderPath -File | $mainPackage = Get-ChildItem -Path $appFolder.FullName -File |
Where-Object { Where-Object { $_.Extension -in '.appx', '.msix', '.appxbundle', '.msixbundle' } |
$_.DirectoryName -ne $dependenciesFolder -and Select-Object -First 1
$_.Extension -ne ".xml" -and
$_.Extension -ne ".yaml"
} | Select-Object -First 1
if ($mainPackage) { if (-not $mainPackage) {
# Build DISM command with main package Write-Warning "No main application package found in '$($appFolder.Name)'. Skipping."
Write-Output ""
continue
}
Write-Host "Found main package: $($mainPackage.Name)"
# Extract and parse AppxManifest.xml from the main package
$manifestTempPath = Join-Path -Path $tempBasePath -ChildPath "AppxManifest.xml"
if (Test-Path $manifestTempPath) { Remove-Item $manifestTempPath -Force }
$requiredDependencies = $null
try {
[System.Reflection.Assembly]::LoadWithPartialName("System.IO.Compression.FileSystem") | Out-Null
# Logic for handling bundles vs. single packages
if ($mainPackage.Extension -in '.appxbundle', '.msixbundle') {
Write-Host "Processing bundle. Searching for architecture-specific package..."
$bundleArchive = [System.IO.Compression.ZipFile]::OpenRead($mainPackage.FullName)
try {
# Find the best matching .appx/.msix package inside the bundle
$primaryArch = if ($osArchitecture -eq 'AMD64') { 'x64' } else { $osArchitecture.ToLower() }
$packageEntries = $bundleArchive.Entries | Where-Object { ($_.Name.EndsWith('.appx') -or $_.Name.EndsWith('.msix')) -and $_.Name -notlike "*_language-*" }
# Prioritize the primary architecture, then x86 (on x64), then neutral
$bestPackageEntry = $packageEntries | Where-Object { $_.Name -imatch "[._]${primaryArch}\.(appx|msix)$" } | Select-Object -First 1
if (-not $bestPackageEntry -and $primaryArch -eq 'x64') {
$bestPackageEntry = $packageEntries | Where-Object { $_.Name -imatch "[._]x86\.(appx|msix)$" } | Select-Object -First 1
}
if (-not $bestPackageEntry) {
$bestPackageEntry = $packageEntries | Where-Object { $_.Name -imatch "[._]neutral\.(appx|msix)$" } | Select-Object -First 1
}
if ($bestPackageEntry) {
Write-Host "Found inner package: $($bestPackageEntry.Name). Extracting to read its manifest."
$innerPackageTempPath = Join-Path -Path $tempBasePath -ChildPath $bestPackageEntry.Name
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($bestPackageEntry, $innerPackageTempPath, $true)
$innerPackageArchive = [System.IO.Compression.ZipFile]::OpenRead($innerPackageTempPath)
try {
$manifestEntry = $innerPackageArchive.Entries | Where-Object { $_.Name -eq 'AppxManifest.xml' } | Select-Object -First 1
if ($manifestEntry) {
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($manifestEntry, $manifestTempPath, $true)
}
} finally {
$innerPackageArchive.Dispose()
}
} else {
Write-Error "Could not find a suitable architecture-specific package inside '$($mainPackage.Name)'."
}
} finally {
$bundleArchive.Dispose()
}
} else { # It's a regular .appx or .msix
$zipArchive = [System.IO.Compression.ZipFile]::OpenRead($mainPackage.FullName)
try {
$manifestEntry = $zipArchive.Entries | Where-Object { $_.Name -eq 'AppxManifest.xml' } | Select-Object -First 1
if ($manifestEntry) {
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($manifestEntry, $manifestTempPath, $true)
}
} finally {
$zipArchive.Dispose()
}
}
# Common manifest parsing logic
if (Test-Path $manifestTempPath) {
[xml]$manifest = Get-Content -Path $manifestTempPath
$nsm = [System.Xml.XmlNamespaceManager]::new($manifest.NameTable)
$nsm.AddNamespace("def", "http://schemas.microsoft.com/appx/manifest/foundation/windows10")
$dependenciesNode = $manifest.SelectSingleNode("//def:Dependencies", $nsm)
if ($dependenciesNode) {
$requiredDependencies = $dependenciesNode.SelectNodes("def:PackageDependency", $nsm)
}
} else {
Write-Error "Could not find or extract AppxManifest.xml from '$($mainPackage.FullName)'."
}
} catch {
Write-Error "Failed to read or parse manifest from '$($mainPackage.FullName)'. Error: $($_.Exception.Message)"
}
if (-not $requiredDependencies) {
Write-Warning "Could not read dependencies from manifest for '$($mainPackage.Name)'. Proceeding without explicit dependencies."
}
# Resolve all required dependencies
$resolvedDependencyPaths = [System.Collections.Generic.List[string]]::new()
foreach ($req in $requiredDependencies) {
$reqName = $req.Name
$reqMinVersion = [System.Version]$req.MinVersion
Write-Host "Resolving dependency: $reqName (MinVersion: $reqMinVersion)"
if ($allAvailableDependencies.ContainsKey($reqName)) {
# Find all available packages that meet the minimum version and architecture requirements
$candidates = $allAvailableDependencies[$reqName] | Where-Object {
$_.Version -ge $reqMinVersion -and
$_.Arch -in ($applicableArchitectures + 'neutral')
}
if ($candidates) {
# Group by architecture and find the single latest version for each applicable arch
$bestCandidates = $candidates | Group-Object -Property Arch | ForEach-Object {
$_.Group | Sort-Object -Property Version -Descending | Select-Object -First 1
}
foreach($best in $bestCandidates) {
Write-Host " - Found best match: $($best.Path.Replace($basePath, '...'))"
$resolvedDependencyPaths.Add($best.Path)
}
} else {
Write-Warning " - No suitable package found for dependency '$reqName' with MinVersion '$reqMinVersion' for applicable architectures."
}
} else {
Write-Warning " - Dependency '$reqName' not found in any scanned dependency folders."
}
}
# Build the DISM command
$dismParams = @( $dismParams = @(
"/Online" "/Online"
"/Add-ProvisionedAppxPackage" "/Add-ProvisionedAppxPackage"
@@ -30,28 +238,34 @@ foreach ($appFolder in Get-ChildItem -Path $basePath -Directory) {
"/StubPackageOption:installfull" "/StubPackageOption:installfull"
) )
# Add dependency packages if they exist # Add resolved dependencies, ensuring no duplicates
if (Test-Path -Path $dependenciesFolder) { $resolvedDependencyPaths.ToArray() | Select-Object -Unique | ForEach-Object {
$dependencies = Get-ChildItem -Path $dependenciesFolder -File $dismParams += "/DependencyPackagePath:`"$_`""
foreach ($dependency in $dependencies) {
$dismParams += "/DependencyPackagePath:`"$($dependency.FullName)`""
}
} }
# Look for license file and add appropriate parameter # Find and add the license file, or skip if not found
$licenseFile = Get-ChildItem -Path $folderPath -Filter "*.xml" -File | Select-Object -First 1 $licenseFile = Get-ChildItem -Path $appFolder.FullName -Filter "*.xml" -File | Select-Object -First 1
if ($licenseFile) { if ($licenseFile) {
$dismParams += "/LicensePath:`"$($licenseFile.FullName)`"" $dismParams += "/LicensePath:`"$($licenseFile.FullName)`""
} else { } else {
$dismParams += "/SkipLicense" $dismParams += "/SkipLicense"
} }
# Construct final command # Execute the DISM command
$dismCommand = "DISM " + ($dismParams -join " ") $dismCommand = "DISM.exe " + ($dismParams -join " ")
Write-Host "Constructed DISM command:"
# Output and execute the command
Write-Output $dismCommand Write-Output $dismCommand
Invoke-Expression -Command $dismCommand
Write-Output "" try {
Invoke-Expression -Command $dismCommand -ErrorAction Stop
Write-Host "Successfully installed $($mainPackage.Name)."
} catch {
Write-Error "DISM command failed for $($mainPackage.Name). Error: $($_.Exception.Message)"
} }
Write-Output ""
} }
# Final cleanup
Write-Host "Installation process finished."
pause
Remove-TemporaryFiles
@@ -73,7 +73,7 @@ $allApps = @()
# Read the WinGetWin32Apps.json file if it exists # Read the WinGetWin32Apps.json file if it exists
if (Test-Path -Path $wingetAppsJsonFile) { if (Test-Path -Path $wingetAppsJsonFile) {
Write-Host "Processing WinGetWin32Apps.json..." Write-Host "Processing WinGetWin32Apps.json first..."
try { try {
$wingetApps = Get-Content -Path $wingetAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json $wingetApps = Get-Content -Path $wingetAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json
if ($wingetApps -is [array]) { if ($wingetApps -is [array]) {
@@ -87,8 +87,6 @@ if (Test-Path -Path $wingetAppsJsonFile) {
} }
} catch { } catch {
Write-Error "Failed to read or parse WinGetWin32Apps.json file: $_" Write-Error "Failed to read or parse WinGetWin32Apps.json file: $_"
# Decide if execution should stop or continue
# exit 1
} }
} else { } else {
Write-Host "WinGetWin32Apps.json file not found. Skipping." Write-Host "WinGetWin32Apps.json file not found. Skipping."
@@ -96,7 +94,7 @@ if (Test-Path -Path $wingetAppsJsonFile) {
# Read the UserAppList.json file if it exists # Read the UserAppList.json file if it exists
if (Test-Path -Path $userAppsJsonFile) { if (Test-Path -Path $userAppsJsonFile) {
Write-Host "Processing UserAppList.json..." Write-Host "Processing UserAppList.json next..."
try { try {
$userApps = Get-Content -Path $userAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json $userApps = Get-Content -Path $userAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json
if ($userApps -is [array]) { if ($userApps -is [array]) {
@@ -110,8 +108,6 @@ if (Test-Path -Path $userAppsJsonFile) {
} }
} catch { } catch {
Write-Error "Failed to read or parse UserAppList.json file: $_" Write-Error "Failed to read or parse UserAppList.json file: $_"
# Decide if execution should stop or continue
# exit 1
} }
} else { } else {
Write-Host "UserAppList.json file not found. Skipping." Write-Host "UserAppList.json file not found. Skipping."
@@ -168,8 +164,6 @@ foreach ($app in $sortedApps) {
Write-Host "$($app.Name) exited with exit code: $($result.ExitCode)`r`n" Write-Host "$($app.Name) exited with exit code: $($result.ExitCode)`r`n"
} catch { } catch {
Write-Error "Error occurred while installing $($app.Name): $_" Write-Error "Error occurred while installing $($app.Name): $_"
# Decide if execution should stop or continue after an error
# exit 1
} }
} }
@@ -345,9 +345,6 @@ function Save-HPDriversTask {
New-Item -Path $extractFolder -ItemType Directory -Force -ErrorAction Stop | Out-Null New-Item -Path $extractFolder -ItemType Directory -Force -ErrorAction Stop | Out-Null
$arguments = "/s /e /f `"$extractFolder`"" $arguments = "/s /e /f `"$extractFolder`""
WriteLog "Extracting driver $driverFilePath with args: $arguments" WriteLog "Extracting driver $driverFilePath with args: $arguments"
#DEBUG
# wrap $driverFilePath in quotes to handle spaces
# $driverFilePath = "`"$driverFilePath`""
WriteLog "Running HP Driver Extraction Command: $driverFilePath $arguments" WriteLog "Running HP Driver Extraction Command: $driverFilePath $arguments"
Invoke-Process -FilePath $driverFilePath -ArgumentList $arguments -ErrorAction Stop | Out-Null Invoke-Process -FilePath $driverFilePath -ArgumentList $arguments -ErrorAction Stop | Out-Null
# Start-Process -FilePath $driverFilePath -ArgumentList $arguments -Wait -NoNewWindow -ErrorAction Stop | Out-Null # Start-Process -FilePath $driverFilePath -ArgumentList $arguments -Wait -NoNewWindow -ErrorAction Stop | Out-Null