mirror of
https://github.com/rbalsleyMSFT/FFU.git
synced 2026-06-14 02:09:35 -06:00
298809686b
Enhances the application download logic to automatically extract zip archives. This allows for handling packages that are delivered in a compressed format. Improves the reliability of identifying UWP applications by iterating through all downloaded files instead of checking only the first one. This prevents errors when multiple files are present after download or extraction.
453 lines
20 KiB
PowerShell
453 lines
20 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Provides functions for interacting with WinGet and the Microsoft Store to find, download, and configure applications.
|
|
|
|
.DESCRIPTION
|
|
This module contains a set of functions designed to automate application management using the WinGet package manager and the Microsoft Store.
|
|
It supports checking for and installing WinGet, downloading applications, handling different application types (Win32 and UWP), and generating silent installation commands for Win32 applications.
|
|
This module is used by both the build script (BuildFFUVM.ps1) and the UI (BuildFFUVM_UI.ps1) to manage application downloads and configuration.
|
|
#>
|
|
function Get-Application {
|
|
[CmdletBinding()]
|
|
param (
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$AppName,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$AppId,
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateSet('winget', 'msstore')]
|
|
[string]$Source,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$AppsPath,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$WindowsArch,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$OrchestrationPath
|
|
)
|
|
|
|
# Determine base folder path for checking existence
|
|
$appIsWin32ForCheck = ($Source -eq 'msstore' -and $AppId.StartsWith("XP"))
|
|
$appBaseFolderPathForCheck = ""
|
|
if ($Source -eq 'winget' -or $appIsWin32ForCheck) {
|
|
$appBaseFolderPathForCheck = Join-Path -Path "$AppsPath\Win32" -ChildPath $AppName
|
|
}
|
|
else {
|
|
$appBaseFolderPathForCheck = Join-Path -Path "$AppsPath\MSStore" -ChildPath $AppName
|
|
}
|
|
|
|
# Check if the app (any architecture) has already been downloaded by checking for its content folder.
|
|
# This prevents re-downloading if BuildFFUVM.ps1 is run after downloading via the UI.
|
|
if (Test-Path -Path $appBaseFolderPathForCheck -PathType Container) {
|
|
# Check if the folder is not empty.
|
|
if (Get-ChildItem -Path $appBaseFolderPathForCheck -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1) {
|
|
WriteLog "Application '$AppName' appears to be already downloaded as content exists in '$appBaseFolderPathForCheck'. Skipping download."
|
|
return 0 # Success, already present
|
|
}
|
|
}
|
|
|
|
# Validate app exists in repository
|
|
$wingetSearchResult = Find-WinGetPackage -id $AppId -MatchOption Equals -Source $Source
|
|
if (-not $wingetSearchResult) {
|
|
if ($VerbosePreference -ne 'Continue') {
|
|
Write-Error "$AppName not found in $Source repository."
|
|
Write-Error "Check the AppList.json file and make sure the AppID is correct."
|
|
}
|
|
WriteLog "$AppName not found in $Source repository."
|
|
WriteLog "Check the AppList.json file and make sure the AppID is correct."
|
|
return 1 # Return error code
|
|
}
|
|
|
|
# Determine architectures to download
|
|
$architecturesToDownload = if ($WindowsArch -eq 'x86 x64') { @('x86', 'x64') } else { @($WindowsArch) }
|
|
$overallResult = 0
|
|
|
|
foreach ($arch in $architecturesToDownload) {
|
|
WriteLog "Processing '$AppName' for architecture '$arch'."
|
|
|
|
# Determine app type and folder path
|
|
$appIsWin32 = ($Source -eq 'msstore' -and $AppId.StartsWith("XP"))
|
|
if ($Source -eq 'winget' -or $appIsWin32) {
|
|
$appBaseFolderPath = Join-Path -Path "$AppsPath\Win32" -ChildPath $AppName
|
|
}
|
|
else {
|
|
$appBaseFolderPath = Join-Path -Path "$AppsPath\MSStore" -ChildPath $AppName
|
|
}
|
|
|
|
# If downloading multiple archs for a Win32 app, create a subfolder
|
|
$appFolderPath = $appBaseFolderPath
|
|
$subFolderForCommand = $null
|
|
if ($architecturesToDownload.Count -gt 1 -and ($Source -eq 'winget' -or $appIsWin32)) {
|
|
$appFolderPath = Join-Path -Path $appBaseFolderPath -ChildPath $arch
|
|
$subFolderForCommand = $arch
|
|
}
|
|
|
|
# Create app folder
|
|
New-Item -Path $appFolderPath -ItemType Directory -Force | Out-Null
|
|
|
|
# Log download information
|
|
WriteLog "Downloading $AppName for $arch architecture..."
|
|
if ($Source -eq 'msstore') {
|
|
WriteLog 'MSStore app downloads require authentication with an Entra ID account. You may be prompted twice for credentials, once for the app and another for the license file.'
|
|
}
|
|
WriteLog "WinGet command: Export-WinGetPackage -id $AppId -DownloadDirectory `"$appFolderPath`" -Architecture $arch -Source $Source"
|
|
|
|
# Download the app
|
|
$wingetDownloadResult = Export-WinGetPackage -id $AppId -DownloadDirectory $appFolderPath -Architecture $arch -Source $Source
|
|
|
|
# Handle download status
|
|
if ($wingetDownloadResult.status -ne 'Ok') {
|
|
# Try downloading without architecture if no applicable installer found
|
|
if ($wingetDownloadResult.status -eq 'NoApplicableInstallers' -or $wingetDownloadResult.status -eq 'NoApplicableInstallerFound') {
|
|
WriteLog "No installer found for $arch architecture. Attempting to download without specifying architecture..."
|
|
$wingetDownloadResult = Export-WinGetPackage -id $AppId -DownloadDirectory $appFolderPath -Source $Source
|
|
if ($wingetDownloadResult.status -eq 'Ok') {
|
|
WriteLog "Downloaded $AppName without specifying architecture."
|
|
}
|
|
else {
|
|
WriteLog "ERROR: No installer found for $AppName. Exiting"
|
|
Remove-Item -Path $appFolderPath -Recurse -Force
|
|
return 1 # Return error code
|
|
}
|
|
}
|
|
# Handle Store-specific errors
|
|
elseif ($Source -eq 'msstore') {
|
|
# If download not supported by publisher
|
|
if ($wingetDownloadResult.ExtendedErrorCode -match '0x8A150084') {
|
|
$errorMessage = "ERROR: The Microsoft Store app $AppName does not support downloads by the publisher. Please remove it from the AppList.json. If there's a winget source version of the application, try using that instead. Exiting."
|
|
WriteLog $errorMessage
|
|
Remove-Item -Path $appFolderPath -Recurse -Force
|
|
Write-Error $errorMessage
|
|
return 1 # Return error code
|
|
}
|
|
}
|
|
else {
|
|
$errormsg = "ERROR: Download failed for $AppName with status: $($wingetDownloadResult.status) $($wingetDownloadResult.ExtendedErrorCode)"
|
|
WriteLog $errormsg
|
|
Remove-Item -Path $appFolderPath -Recurse -Force
|
|
Write-Error $errormsg
|
|
return 1 # Return error code
|
|
}
|
|
}
|
|
|
|
WriteLog "$AppName ($arch) downloaded to $appFolderPath"
|
|
|
|
# Handle zip files
|
|
$zipFile = Get-ChildItem -Path $appFolderPath -Filter "*.zip" -File -ErrorAction SilentlyContinue
|
|
if ($zipFile) {
|
|
WriteLog "Found zip file: $($zipFile.FullName). Extracting..."
|
|
Expand-Archive -Path $zipFile.FullName -DestinationPath $appFolderPath -Force
|
|
WriteLog "Extraction complete. Removing zip file."
|
|
Remove-Item -Path $zipFile.FullName -Force
|
|
WriteLog "Zip file removed."
|
|
}
|
|
|
|
# Handle winget source apps that have appx, appxbundle, msix, or msixbundle extensions but were downloaded to the Win32 folder
|
|
$installerFiles = Get-ChildItem -Path "$appFolderPath\*" -Exclude "*.yaml", "*.xml" -File -ErrorAction SilentlyContinue
|
|
$uwpExtensions = @(".appx", ".appxbundle", ".msix", ".msixbundle")
|
|
$isUwpApp = $false
|
|
if ($installerFiles) {
|
|
foreach ($file in $installerFiles) {
|
|
if ($uwpExtensions -contains $file.Extension) {
|
|
$isUwpApp = $true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($isUwpApp -and $appFolderPath -match 'Win32') {
|
|
# Handle UWP apps
|
|
$NewAppPath = "$AppsPath\MSStore\$AppName"
|
|
WriteLog "$AppName is a UWP app. Moving to $NewAppPath"
|
|
WriteLog "Creating $NewAppPath"
|
|
New-Item -Path "$AppsPath\MSStore\$AppName" -ItemType Directory -Force | Out-Null
|
|
WriteLog "Moving $AppName to $NewAppPath"
|
|
Move-Item -Path "$appFolderPath\*" -Destination "$AppsPath\MSStore\$AppName" -Force
|
|
WriteLog "Removing $appFolderPath"
|
|
Remove-Item -Path $appFolderPath -Force -Recurse
|
|
WriteLog "$AppName moved to $NewAppPath"
|
|
$result = 0 # Success for UWP app
|
|
}
|
|
# If app is in Win32 folder, add the silent install command to the WinGetWin32Apps.json file
|
|
elseif ($appFolderPath -match 'Win32') {
|
|
WriteLog "$AppName is a Win32 app. Adding silent install command to $OrchestrationPath\WinGetWin32Apps.json"
|
|
$result = Add-Win32SilentInstallCommand -AppFolder $AppName -AppFolderPath $appFolderPath -OrchestrationPath $OrchestrationPath -SubFolder $subFolderForCommand
|
|
}
|
|
else {
|
|
# For any other case, set result to 0 (success)
|
|
$result = 0
|
|
}
|
|
|
|
if ($result -ne 0) { $overallResult = $result }
|
|
|
|
# Handle MSStore specific post-processing
|
|
if ($Source -eq 'msstore' -and $appFolderPath -match 'MSStore') {
|
|
# Handle ARM64-specific dependencies
|
|
if ($arch -eq 'ARM64') {
|
|
WriteLog 'Windows architecture is ARM64. Removing dependencies that are not ARM64.'
|
|
$dependencies = Get-ChildItem -Path "$appFolderPath\Dependencies" -ErrorAction SilentlyContinue
|
|
if ($dependencies) {
|
|
foreach ($dependency in $dependencies) {
|
|
if ($dependency.Name -notmatch 'ARM64') {
|
|
WriteLog "Removing dependency file $($dependency.FullName)"
|
|
Remove-Item -Path $dependency.FullName -Recurse -Force
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# Clean up multiple versions (keep only the latest)
|
|
WriteLog "$AppName has completed downloading. Identifying the latest version of $AppName."
|
|
$packages = Get-ChildItem -Path "$appFolderPath\*" -Exclude "Dependencies\*", "*.xml", "*.yaml" -File -ErrorAction Stop
|
|
|
|
# Find latest version based on signature date
|
|
$latestPackage = $packages | Sort-Object { (Get-AuthenticodeSignature $_.FullName).SignerCertificate.NotBefore } -Descending | Select-Object -First 1
|
|
|
|
# Remove older versions
|
|
WriteLog "Latest version of $AppName has been identified as $latestPackage. Removing old versions of $AppName that may have downloaded."
|
|
foreach ($package in $packages) {
|
|
if ($package.FullName -ne $latestPackage.FullName) {
|
|
try {
|
|
WriteLog "Removing $($package.FullName)"
|
|
Remove-Item -Path $package.FullName -Force
|
|
}
|
|
catch {
|
|
WriteLog "Failed to delete: $($package.FullName) - $_"
|
|
throw $_
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} # End foreach ($arch in $architecturesToDownload)
|
|
|
|
return $overallResult
|
|
}
|
|
function Get-Apps {
|
|
[CmdletBinding()]
|
|
param (
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$AppList,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$AppsPath,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$WindowsArch,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$OrchestrationPath
|
|
)
|
|
|
|
# Load and validate app list
|
|
$apps = Get-Content -Path $AppList -Raw | ConvertFrom-Json
|
|
if (-not $apps) {
|
|
WriteLog "No apps were specified in AppList.json file."
|
|
return
|
|
}
|
|
|
|
# Process WinGet apps
|
|
$wingetApps = $apps.apps | Where-Object { $_.source -eq "winget" }
|
|
if ($wingetApps) {
|
|
WriteLog 'Winget apps to be installed:'
|
|
$wingetApps | ForEach-Object { WriteLog $_.Name }
|
|
}
|
|
|
|
# Process Store apps
|
|
$StoreApps = $apps.apps | Where-Object { $_.source -eq "msstore" }
|
|
if ($StoreApps) {
|
|
WriteLog 'Store apps to be installed:'
|
|
$StoreApps | ForEach-Object { WriteLog $_.Name }
|
|
}
|
|
|
|
# Ensure WinGet is available
|
|
Confirm-WinGetInstallation -WindowsArch $WindowsArch
|
|
|
|
# Create necessary folders
|
|
$win32Folder = Join-Path -Path $AppsPath -ChildPath "Win32"
|
|
$storeAppsFolder = Join-Path -Path $AppsPath -ChildPath "MSStore"
|
|
|
|
# Process WinGet apps
|
|
if ($wingetApps) {
|
|
if (-not (Test-Path -Path $win32Folder -PathType Container)) {
|
|
WriteLog "Creating folder for Winget Win32 apps: $win32Folder"
|
|
New-Item -Path $win32Folder -ItemType Directory -Force | Out-Null
|
|
WriteLog "Folder created successfully."
|
|
}
|
|
|
|
foreach ($wingetApp in $wingetApps) {
|
|
try {
|
|
$appArch = if ($wingetApp.PSObject.Properties['architecture']) { $wingetApp.architecture } else { $WindowsArch }
|
|
Get-Application -AppName $wingetApp.Name -AppId $wingetApp.Id -Source 'winget' -AppsPath $AppsPath -WindowsArch $appArch -OrchestrationPath $OrchestrationPath
|
|
}
|
|
catch {
|
|
WriteLog "Error occurred while processing $($wingetApp.Name): $_"
|
|
throw $_
|
|
}
|
|
}
|
|
}
|
|
|
|
# Process Store apps
|
|
if ($StoreApps) {
|
|
if (-not (Test-Path -Path $storeAppsFolder -PathType Container)) {
|
|
New-Item -Path $storeAppsFolder -ItemType Directory -Force | Out-Null
|
|
}
|
|
|
|
foreach ($storeApp in $StoreApps) {
|
|
try {
|
|
$appArch = if ($storeApp.PSObject.Properties['architecture']) { $storeApp.architecture } else { $WindowsArch }
|
|
Get-Application -AppName $storeApp.Name -AppId $storeApp.Id -Source 'msstore' -AppsPath $AppsPath -WindowsArch $appArch -OrchestrationPath $OrchestrationPath
|
|
}
|
|
catch {
|
|
WriteLog "Error occurred while processing $($storeApp.Name): $_"
|
|
throw $_
|
|
}
|
|
}
|
|
}
|
|
}
|
|
function Install-WinGet {
|
|
param (
|
|
[string]$Architecture
|
|
)
|
|
$packages = @(
|
|
@{Name = "VCLibs"; Url = "https://aka.ms/Microsoft.VCLibs.$Architecture.14.00.Desktop.appx"; File = "Microsoft.VCLibs.$Architecture.14.00.Desktop.appx" },
|
|
@{Name = "UIXaml"; Url = "https://github.com/microsoft/microsoft-ui-xaml/releases/download/v2.8.6/Microsoft.UI.Xaml.2.8.$Architecture.appx"; File = "Microsoft.UI.Xaml.2.8.$Architecture.appx" },
|
|
@{Name = "WinGet"; Url = "https://aka.ms/getwinget"; File = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle" }
|
|
)
|
|
foreach ($package in $packages) {
|
|
$destination = Join-Path -Path $env:TEMP -ChildPath $package.File
|
|
WriteLog "Downloading $($package.Name) from $($package.Url) to $destination"
|
|
Start-BitsTransferWithRetry -Source $package.Url -Destination $destination
|
|
WriteLog "Installing $($package.Name)..."
|
|
# Don't show progress bar for Add-AppxPackage - there's a weird issue where the progress stays on the screen after the apps are installed
|
|
$ProgressPreference = 'SilentlyContinue'
|
|
Add-AppxPackage -Path $destination -ErrorAction SilentlyContinue
|
|
# Set progress preference back to default
|
|
$ProgressPreference = 'Continue'
|
|
WriteLog "Removing $($package.Name)..."
|
|
Remove-Item -Path $destination -Force -ErrorAction SilentlyContinue
|
|
}
|
|
WriteLog "WinGet installation complete."
|
|
}
|
|
function Confirm-WinGetInstallation {
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$WindowsArch
|
|
)
|
|
|
|
WriteLog 'Checking if WinGet is installed...'
|
|
$minVersion = [version]"1.8.1911"
|
|
|
|
# Check WinGet PowerShell module
|
|
$wingetModule = Get-InstalledModule -Name Microsoft.Winget.Client -ErrorAction SilentlyContinue
|
|
$wingetModuleVersion = [version]$wingetModule.Version
|
|
if ($wingetModuleVersion -lt $minVersion -or -not $wingetModule) {
|
|
WriteLog 'Microsoft.Winget.Client module is not installed or is an older version. Installing the latest version...'
|
|
|
|
# Handle PSGallery trust settings
|
|
$PSGalleryTrust = (Get-PSRepository -Name 'PSGallery').InstallationPolicy
|
|
if ($PSGalleryTrust -eq 'Untrusted') {
|
|
WriteLog 'Temporarily setting PSGallery as a trusted repository...'
|
|
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted
|
|
}
|
|
|
|
Install-Module -Name Microsoft.Winget.Client -Force -Repository 'PSGallery'
|
|
|
|
if ($PSGalleryTrust -eq 'Untrusted') {
|
|
WriteLog 'Setting PSGallery back to untrusted repository...'
|
|
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Untrusted
|
|
WriteLog 'Done'
|
|
}
|
|
}
|
|
else {
|
|
WriteLog "Installed Microsoft.Winget.Client module version: $($wingetModule.Version)"
|
|
}
|
|
|
|
# Check WinGet CLI
|
|
$wingetVersion = Get-WinGetVersion
|
|
if (-not $wingetVersion) {
|
|
WriteLog "WinGet is not installed. Installing WinGet..."
|
|
Install-WinGet -Architecture $WindowsArch
|
|
}
|
|
elseif ($wingetVersion -match 'v?(\d+\.\d+\.\d+)' -and [version]$matches[1] -lt $minVersion) {
|
|
WriteLog "The installed version of WinGet $($matches[1]) does not support downloading MSStore apps. Installing the latest version of WinGet..."
|
|
Install-WinGet -Architecture $WindowsArch
|
|
}
|
|
else {
|
|
WriteLog "Installed WinGet version: $wingetVersion"
|
|
}
|
|
}
|
|
function Add-Win32SilentInstallCommand {
|
|
param (
|
|
[string]$AppFolder,
|
|
[string]$AppFolderPath,
|
|
[Parameter(Mandatory = $true)]
|
|
[string]$OrchestrationPath,
|
|
[string]$SubFolder
|
|
)
|
|
$appName = $AppFolder
|
|
$installerPath = Get-ChildItem -Path "$appFolderPath\*" -Include "*.exe", "*.msi" -File -ErrorAction Stop
|
|
if (-not $installerPath) {
|
|
WriteLog "No win32 app installers were found. Skipping the inclusion of $AppFolder"
|
|
Remove-Item -Path $AppFolderPath -Recurse -Force
|
|
return 1
|
|
}
|
|
$yamlFile = Get-ChildItem -Path "$appFolderPath\*" -Include "*.yaml" -File -ErrorAction Stop
|
|
$yamlContent = Get-Content -Path $yamlFile -Raw
|
|
$silentInstallSwitch = [regex]::Match($yamlContent, 'Silent:\s*(.+)').Groups[1].Value.Replace("'", "").Trim()
|
|
if (-not $silentInstallSwitch) {
|
|
WriteLog "Silent install switch for $appName could not be found. Skipping the inclusion of $appName."
|
|
Remove-Item -Path $appFolderPath -Recurse -Force
|
|
return 2
|
|
}
|
|
$installer = Split-Path -Path $installerPath -Leaf
|
|
|
|
$basePath = "D:\win32\$AppFolder"
|
|
if (-not [string]::IsNullOrEmpty($SubFolder)) {
|
|
$basePath = "$basePath\$SubFolder"
|
|
}
|
|
|
|
if ($installerPath.Extension -eq ".exe") {
|
|
$silentInstallCommand = "$basePath\$installer"
|
|
}
|
|
elseif ($installerPath.Extension -eq ".msi") {
|
|
$silentInstallCommand = "msiexec"
|
|
$silentInstallSwitch = "/i `"$basePath\$installer`" $silentInstallSwitch"
|
|
}
|
|
|
|
# Path to the JSON file
|
|
$wingetWin32AppsJson = "$OrchestrationPath\WinGetWin32Apps.json"
|
|
|
|
# Initialize or load existing JSON data
|
|
if (Test-Path -Path $wingetWin32AppsJson) {
|
|
[array]$appsData = Get-Content -Path $wingetWin32AppsJson -Raw | ConvertFrom-Json
|
|
|
|
# Get highest priority value
|
|
if ($appsData.Count -gt 0) {
|
|
$highestPriority = $appsData.Count + 1
|
|
}
|
|
}
|
|
else {
|
|
$appsData = @()
|
|
$highestPriority = 1
|
|
}
|
|
|
|
# Create new app entry
|
|
$newApp = [PSCustomObject]@{
|
|
Priority = $highestPriority
|
|
Name = if (-not [string]::IsNullOrEmpty($SubFolder)) { "$appName ($SubFolder)" } else { $appName }
|
|
CommandLine = $silentInstallCommand
|
|
Arguments = $silentInstallSwitch
|
|
}
|
|
|
|
$appsData += $newApp
|
|
$appsData | ConvertTo-Json -Depth 10 | Set-Content -Path $wingetWin32AppsJson
|
|
|
|
WriteLog "Added $($newApp.Name) to WinGetWin32Apps.json with priority $highestPriority"
|
|
|
|
# Return 0 for success
|
|
return 0
|
|
}
|
|
|
|
# --------------------------------------------------------------------------
|
|
# SECTION: Module Export
|
|
# --------------------------------------------------------------------------
|
|
|
|
# Export functions needed by both BuildFFUVM and the UI Core module
|
|
Export-ModuleMember -Function Get-Application, Get-Apps, Confirm-WinGetInstallation, Add-Win32SilentInstallCommand, Install-Winget |