mirror of
https://github.com/rbalsleyMSFT/FFU.git
synced 2026-06-14 02:09:35 -06:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf27da5c66 | |||
| 1010b9fce7 | |||
| 3e34bd6bff | |||
| 3f892493c0 | |||
| 7d4567efbe | |||
| 9aed707a77 | |||
| 0c373e6b2c | |||
| a501b32a03 | |||
| 8ab6603999 | |||
| 85383f989a | |||
| 0423ac31d9 | |||
| 35f37f3a36 | |||
| 78d7bb9262 | |||
| 3c545be5c5 | |||
| c1983f75e6 | |||
| 7c3de6d77f | |||
| 17dc80f11b | |||
| 846d449aac | |||
| db9b7335f2 | |||
| 6f98473009 | |||
| 357261ec73 | |||
| 5bef901295 | |||
| 59e247c012 | |||
| a87c4796b5 | |||
| 4d289ee14a | |||
| 08feb7c9dd | |||
| 9cb06cb71e | |||
| 5ec607d94a | |||
| ac7ef119e0 | |||
| 03c8127bd3 | |||
| eb001e59b3 | |||
| 3e46d4b280 |
@@ -7,47 +7,81 @@ function Invoke-Process {
|
||||
[string]$FilePath,
|
||||
|
||||
[Parameter()]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string[]]$ArgumentList,
|
||||
|
||||
[Parameter()]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[bool]$Wait = $true
|
||||
[bool]$Wait = $true,
|
||||
|
||||
[Parameter()]
|
||||
[string[]]$AdditionalSuccessCodes,
|
||||
|
||||
[Parameter()]
|
||||
[bool]$IgnoreNonZeroExitCodes = $false
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
try {
|
||||
$stdOutTempFile = "$env:TEMP\$((New-Guid).Guid)"
|
||||
$stdErrTempFile = "$env:TEMP\$((New-Guid).Guid)"
|
||||
|
||||
$startProcessParams = @{
|
||||
FilePath = $FilePath
|
||||
ArgumentList = $ArgumentList
|
||||
RedirectStandardError = $stdErrTempFile
|
||||
RedirectStandardOutput = $stdOutTempFile
|
||||
Wait = $($Wait);
|
||||
PassThru = $true;
|
||||
NoNewWindow = $true;
|
||||
}
|
||||
if ($PSCmdlet.ShouldProcess("Process [$($FilePath)]", "Run with args: [$($ArgumentList)]")) {
|
||||
$cmd = Start-Process @startProcessParams
|
||||
$cmdOutput = Get-Content -Path $stdOutTempFile -Raw
|
||||
$cmdError = Get-Content -Path $stdErrTempFile -Raw
|
||||
if ($cmd.ExitCode -ne 0 -and $wait -eq $true) {
|
||||
# Use .NET Process class for proper stream handling
|
||||
$pinfo = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$pinfo.FileName = $FilePath
|
||||
if ($ArgumentList) {
|
||||
$pinfo.Arguments = $ArgumentList -join ' '
|
||||
}
|
||||
$pinfo.RedirectStandardOutput = $true
|
||||
$pinfo.RedirectStandardError = $true
|
||||
$pinfo.UseShellExecute = $false
|
||||
$pinfo.CreateNoWindow = $true
|
||||
|
||||
$p = New-Object System.Diagnostics.Process
|
||||
$p.StartInfo = $pinfo
|
||||
|
||||
# Start the process
|
||||
$p.Start() | Out-Null
|
||||
|
||||
# Read output and error streams
|
||||
$cmdOutput = $p.StandardOutput.ReadToEnd()
|
||||
$cmdError = $p.StandardError.ReadToEnd()
|
||||
|
||||
if ($Wait) {
|
||||
$p.WaitForExit()
|
||||
}
|
||||
|
||||
$exitCode = $p.ExitCode
|
||||
# An exit code of 0 is always a success
|
||||
if ($exitCode -ne 0) {
|
||||
# If IgnoreNonZeroExitCodes is true, treat any non-zero exit code as a success
|
||||
if ($IgnoreNonZeroExitCodes) {
|
||||
Write-Host "Ignoring non-zero exit code $exitCode because IgnoreNonZeroExitCodes is set to true."
|
||||
}
|
||||
# Check if the non-zero exit code is in the list of additional success codes
|
||||
elseif ($null -eq $AdditionalSuccessCodes -or $exitCode -notin $AdditionalSuccessCodes) {
|
||||
if ($cmdError) {
|
||||
throw $cmdError.Trim()
|
||||
}
|
||||
if ($cmdOutput) {
|
||||
throw $cmdOutput.Trim()
|
||||
}
|
||||
# If there's no output, throw a generic error with the exit code
|
||||
if (-not $cmdError -and -not $cmdOutput) {
|
||||
throw "Process exited with non-zero code: $exitCode"
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) {
|
||||
# WriteLog $cmdOutput
|
||||
Write-Host $cmdOutput
|
||||
}
|
||||
}
|
||||
|
||||
# Create a simple object with exit code for compatibility
|
||||
$result = [PSCustomObject]@{
|
||||
ExitCode = $exitCode
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
}
|
||||
catch {
|
||||
@@ -55,12 +89,7 @@ function Invoke-Process {
|
||||
# WriteLog $_
|
||||
# Write-Host "Script failed - $Logfile for more info"
|
||||
throw $_
|
||||
|
||||
}
|
||||
finally {
|
||||
Remove-Item -Path $stdOutTempFile, $stdErrTempFile -Force -ErrorAction Ignore
|
||||
}
|
||||
return $cmd
|
||||
}
|
||||
|
||||
function Install-Applications {
|
||||
@@ -110,15 +139,58 @@ function Install-Applications {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
# Construct the argument list properly, handling potential array vs string
|
||||
$argumentsToPass = if ($app.Arguments -is [array]) { $app.Arguments } else { @($app.Arguments) }
|
||||
# Check for 'PAUSE' command
|
||||
if ($app.CommandLine -eq 'PAUSE') {
|
||||
Write-Host "Pausing script as requested by '$($app.Name)'. Press Enter to continue..."
|
||||
$null = Read-Host
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
# Normalize arguments: treat null/empty/whitespace as no arguments
|
||||
$argumentsToPass = $null
|
||||
if ($null -ne $app.Arguments) {
|
||||
if ($app.Arguments -is [array]) {
|
||||
$trimmed = $app.Arguments | ForEach-Object { ($_ | ForEach-Object { if ($_ -ne $null) { $_.ToString().Trim() } else { $_ } }) } | Where-Object { $_ -and (-not [string]::IsNullOrWhiteSpace($_)) }
|
||||
if ($trimmed.Count -gt 0) {
|
||||
$argumentsToPass = $trimmed
|
||||
}
|
||||
}
|
||||
else {
|
||||
$single = $app.Arguments.ToString().Trim()
|
||||
if (-not [string]::IsNullOrWhiteSpace($single)) {
|
||||
$argumentsToPass = @($single)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Check for and parse AdditionalExitCodes
|
||||
$additionalSuccessCodes = @()
|
||||
if ($app.PSObject.Properties['AdditionalExitCodes'] -and -not [string]::IsNullOrWhiteSpace($app.AdditionalExitCodes)) {
|
||||
$additionalSuccessCodes = $app.AdditionalExitCodes -split ',' | ForEach-Object { $_.Trim() }
|
||||
Write-Host "Additional success exit codes for $($app.Name): $($additionalSuccessCodes -join ', ')"
|
||||
}
|
||||
|
||||
# Check for IgnoreNonZeroExitCodes
|
||||
$ignoreNonZeroExitCodes = $false
|
||||
if ($app.PSObject.Properties['IgnoreNonZeroExitCodes'] -and $app.IgnoreNonZeroExitCodes -is [bool]) {
|
||||
$ignoreNonZeroExitCodes = $app.IgnoreNonZeroExitCodes
|
||||
}
|
||||
|
||||
if ($null -eq $argumentsToPass -or $argumentsToPass.Count -eq 0) {
|
||||
Write-Host "Running command: $($app.CommandLine) (no arguments)"
|
||||
$result = Invoke-Process -FilePath $app.CommandLine -AdditionalSuccessCodes $additionalSuccessCodes -IgnoreNonZeroExitCodes $ignoreNonZeroExitCodes
|
||||
}
|
||||
else {
|
||||
Write-Host "Running command: $($app.CommandLine) $($argumentsToPass -join ' ')"
|
||||
$result = Invoke-Process -FilePath $($app.CommandLine) -ArgumentList $argumentsToPass
|
||||
$result = Invoke-Process -FilePath $app.CommandLine -ArgumentList $argumentsToPass -AdditionalSuccessCodes $additionalSuccessCodes -IgnoreNonZeroExitCodes $ignoreNonZeroExitCodes
|
||||
}
|
||||
Write-Host "$($app.Name) exited with exit code: $($result.ExitCode)`r`n"
|
||||
} catch {
|
||||
}
|
||||
catch {
|
||||
Write-Error "Error occurred while installing $($app.Name): $_"
|
||||
Read-Host "An error occurred, and the script cannot continue. Press Enter to exit."
|
||||
throw $_
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,16 +212,20 @@ if (Test-Path -Path $wingetAppsJsonFile) {
|
||||
if ($wingetContent -is [array]) {
|
||||
$wingetApps = $wingetContent
|
||||
Write-Host "Found $(($wingetApps | Measure-Object).Count) WinGet Win32 apps."
|
||||
} elseif ($wingetContent) {
|
||||
}
|
||||
elseif ($wingetContent) {
|
||||
$wingetApps = @($wingetContent) # Ensure it's an array
|
||||
Write-Host "Found 1 WinGet Win32 app."
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
Write-Host "WinGetWin32Apps.json is empty or invalid."
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to read or parse WinGetWin32Apps.json file: $_"
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
Write-Host "WinGetWin32Apps.json file not found. Skipping."
|
||||
}
|
||||
|
||||
@@ -166,16 +242,20 @@ if (Test-Path -Path $userAppsJsonFile) {
|
||||
if ($userContent -is [array]) {
|
||||
$userApps = $userContent
|
||||
Write-Host "Found $(($userApps | Measure-Object).Count) user-defined apps."
|
||||
} elseif ($userContent) {
|
||||
}
|
||||
elseif ($userContent) {
|
||||
$userApps = @($userContent) # Ensure it's an array
|
||||
Write-Host "Found 1 user-defined app."
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
Write-Host "UserAppList.json is empty or invalid."
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to read or parse UserAppList.json file: $_"
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
Write-Host "UserAppList.json file not found. Skipping."
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ else {
|
||||
|
||||
# Example of how to use the AppsScriptVariables hashtable to control script execution
|
||||
|
||||
# Note: The UI saves the values as strings, so if you type true for a value, it'll save to the config file as a string, not boolean
|
||||
|
||||
# Example: Check if a variable named 'foo' is set to string 'bar' and run a script accordingly
|
||||
# if ($AppsScriptVariables['foo'] -eq 'bar') {
|
||||
# Write-Host "Foo would have installed"
|
||||
@@ -46,8 +48,8 @@ else {
|
||||
# Write-Host "Foo would not have installed"
|
||||
# }
|
||||
|
||||
# Example: Check if a variable named 'foo' is set to boolean $true and run a script accordingly
|
||||
# if ($AppsScriptVariables[Teams] -eq $true) {
|
||||
# Example: Check if a variable named 'Teams' is set to string 'true' and run a script accordingly
|
||||
# if ($AppsScriptVariables['Teams'] -eq 'true') {
|
||||
# Write-Host "Teams would have been installed"
|
||||
# }
|
||||
# else {
|
||||
@@ -55,5 +57,4 @@ else {
|
||||
# }
|
||||
|
||||
# Your code below here
|
||||
|
||||
Write-Host 'Invoke-AppsScript.ps1 finished'
|
||||
@@ -1,14 +1,90 @@
|
||||
#The below lines will remove the unattend.xml that gets the machine into audit mode. If not removed, the OS will get stuck booting to audit mode each time.
|
||||
#Also kills the sysprep process in order to automate sysprep generalize
|
||||
# Convert these commands to native powershell
|
||||
# del c:\windows\panther\unattend\unattend.xml /F /Q
|
||||
# del c:\windows\panther\unattend.xml /F /Q
|
||||
# taskkill /IM sysprep.exe
|
||||
# timeout /t 10
|
||||
# & c:\windows\system32\sysprep\sysprep.exe /quiet /generalize /oobe
|
||||
|
||||
Write-Host "Removing existing unattend.xml files and stopping sysprep process if running..."
|
||||
Remove-Item -Path "C:\windows\panther\unattend\unattend.xml" -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path "C:\windows\panther\unattend.xml" -Force -ErrorAction SilentlyContinue
|
||||
Stop-Process -Name "sysprep" -Force -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 10
|
||||
|
||||
# Detect and remediate per-user, non-provisioned Appx packages that would block Sysprep.
|
||||
Write-Host "Checking for per-user Appx packages not provisioned for all users (potential Sysprep blockers)..."
|
||||
|
||||
# Build hash set of provisioned package families (DisplayName_PublisherId).
|
||||
$provFamilies = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
|
||||
Get-AppxProvisionedPackage -Online | ForEach-Object {
|
||||
$family = '{0}_{1}' -f $_.DisplayName, $_.PublisherId
|
||||
[void]$provFamilies.Add($family)
|
||||
}
|
||||
|
||||
# Collect current user Appx packages excluding frameworks, resource packs, and non-removable packages.
|
||||
$userApps = Get-AppxPackage -User $env:USERNAME | Where-Object {
|
||||
$_.Status -eq 'Ok' -and
|
||||
-not $_.IsFramework -and
|
||||
-not $_.IsResourcePackage -and
|
||||
-not $_.NonRemovable
|
||||
}
|
||||
|
||||
# Identify packages not provisioned (per-user only).
|
||||
$notProvisioned = foreach ($pkg in $userApps) {
|
||||
if (-not $provFamilies.Contains($pkg.PackageFamilyName)) {
|
||||
[PSCustomObject]@{
|
||||
Name = $pkg.Name
|
||||
PackageFamilyName = $pkg.PackageFamilyName
|
||||
Version = $pkg.Version
|
||||
SignatureKind = $pkg.SignatureKind
|
||||
PackageFullName = $pkg.PackageFullName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($notProvisioned) {
|
||||
Write-Host "Found $($notProvisioned.Count) per-user Appx package(s) not provisioned for all users:"
|
||||
$notProvisioned | Sort-Object PackageFamilyName | Format-Table -AutoSize -Property Name,PackageFamilyName,Version
|
||||
Write-Host "Attempting removal of per-user, non-provisioned Appx packages..."
|
||||
foreach ($pkg in $notProvisioned) {
|
||||
try {
|
||||
Write-Host "Removing $($pkg.PackageFullName)..."
|
||||
Remove-AppxPackage -Package $pkg.PackageFullName -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to remove $($pkg.PackageFullName): $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Re-check after attempted removals.
|
||||
$remaining = @()
|
||||
$currentUserApps = Get-AppxPackage -User $env:USERNAME | Where-Object {
|
||||
$_.Status -eq 'Ok' -and
|
||||
-not $_.IsFramework -and
|
||||
-not $_.IsResourcePackage -and
|
||||
-not $_.NonRemovable
|
||||
}
|
||||
foreach ($pkg in $currentUserApps) {
|
||||
if (-not $provFamilies.Contains($pkg.PackageFamilyName)) {
|
||||
$remaining += $pkg
|
||||
}
|
||||
}
|
||||
|
||||
if ($remaining.Count -gt 0) {
|
||||
Write-Error "Unable to remove all per-user, non-provisioned Appx packages. Sysprep cannot continue."
|
||||
$remaining | Sort-Object PackageFamilyName | Format-Table -AutoSize -Property Name,PackageFamilyName,Version
|
||||
throw "Sysprep aborted due to unresolved per-user Appx packages. Resolve manually and re-run."
|
||||
}
|
||||
else {
|
||||
Write-Host "All per-user, non-provisioned Appx packages were successfully removed."
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "No per-user, non-provisioned Appx packages detected."
|
||||
}
|
||||
|
||||
# If an Unattend.xml has been provided on the mounted Apps ISO (D:\Unattend\Unattend.xml),
|
||||
# pass it to sysprep; otherwise, run without /unattend.
|
||||
$unattendOnAppsIso = "D:\Unattend\Unattend.xml"
|
||||
if (Test-Path -Path $unattendOnAppsIso) {
|
||||
Write-Host "Using $unattendOnAppsIso from Apps ISO..."
|
||||
& "C:\windows\system32\sysprep\sysprep.exe" /quiet /generalize /oobe /unattend:$unattendOnAppsIso
|
||||
}
|
||||
else {
|
||||
& "C:\windows\system32\sysprep\sysprep.exe" /quiet /generalize /oobe
|
||||
}
|
||||
|
||||
+739
-33
@@ -93,6 +93,9 @@ Prefix for the generated FFU file. Default is _FFU.
|
||||
.PARAMETER Headers
|
||||
Headers to use when downloading files. Not recommended to modify.
|
||||
|
||||
.PARAMETER InjectUnattend
|
||||
When set to $true and InstallApps is also $true, copies unattend_[arch].xml from $FFUDevelopmentPath\unattend to $FFUDevelopmentPath\Apps\Unattend\Unattend.xml so sysprep can use it inside the VM. Default is $false.
|
||||
|
||||
.PARAMETER InstallApps
|
||||
When set to $true, the script will create an Apps.iso file from the $FFUDevelopmentPath\Apps folder. It will also create a VM, mount the Apps.iso, install the apps, sysprep, and capture the VM. When set to $false, the FFU is created from a VHDX file, and no VM is created.
|
||||
|
||||
@@ -193,6 +196,9 @@ A hashtable containing USB drives from win32_diskdrive where:
|
||||
|
||||
Example: @{ "SanDisk Ultra" = "1234567890"; "Kingston DataTraveler" = "0987654321" }
|
||||
|
||||
.PARAMETER MaxUSBDrives
|
||||
Maximum number of USB drives to build in parallel. Default is 5. Set to 0 to process all discovered drives (or all selected drives when USBDriveList or selection is used). Actual throttle will never exceed the number of drives discovered.
|
||||
|
||||
.PARAMETER UserAgent
|
||||
User agent string to use when downloading files.
|
||||
|
||||
@@ -344,6 +350,7 @@ param(
|
||||
[string]$ProductKey,
|
||||
[bool]$BuildUSBDrive,
|
||||
[hashtable]$USBDriveList,
|
||||
[int]$MaxUSBDrives = 5,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[ValidateSet(10, 11, 2016, 2019, 2021, 2022, 2024, 2025)]
|
||||
[int]$WindowsRelease = 11,
|
||||
@@ -416,11 +423,14 @@ param(
|
||||
[string]$ConfigFile,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ExportConfigFile,
|
||||
[bool]$InjectUnattend = $false,
|
||||
[string]$orchestrationPath,
|
||||
[bool]$UpdateADK = $true
|
||||
[bool]$UpdateADK = $true,
|
||||
[bool]$CleanupCurrentRunDownloads = $false,
|
||||
[switch]$Cleanup
|
||||
)
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
$version = '2507.1'
|
||||
$version = '2508.1Preview'
|
||||
|
||||
# Remove any existing modules to avoid conflicts
|
||||
if (Get-Module -Name 'FFU.Common.Core' -ErrorAction SilentlyContinue) {
|
||||
@@ -880,7 +890,9 @@ function Get-MicrosoftDrivers {
|
||||
### DOWNLOAD THE FILE
|
||||
$filePath = Join-Path -Path $surfaceDriversPath -ChildPath ($fileName)
|
||||
WriteLog "Downloading $Model driver file to $filePath"
|
||||
Mark-DownloadInProgress -FFUDevelopmentPath $FFUDevelopmentPath -TargetPath $filePath
|
||||
Start-BitsTransferWithRetry -Source $downloadLink -Destination $filePath
|
||||
Clear-DownloadInProgress -FFUDevelopmentPath $FFUDevelopmentPath -TargetPath $filePath
|
||||
WriteLog "Download complete"
|
||||
|
||||
# Determine file extension
|
||||
@@ -1122,7 +1134,9 @@ function Get-HPDrivers {
|
||||
|
||||
# Download the driver with retry
|
||||
WriteLog "Downloading driver to: $DriverFilePath"
|
||||
Mark-DownloadInProgress -FFUDevelopmentPath $FFUDevelopmentPath -TargetPath $DriverFilePath
|
||||
Start-BitsTransferWithRetry -Source $DriverUrl -Destination $DriverFilePath
|
||||
Clear-DownloadInProgress -FFUDevelopmentPath $FFUDevelopmentPath -TargetPath $DriverFilePath
|
||||
WriteLog 'Driver downloaded'
|
||||
|
||||
# Make folder for extraction
|
||||
@@ -1347,7 +1361,9 @@ function Get-LenovoDrivers {
|
||||
|
||||
# Download the driver with retry
|
||||
WriteLog "Downloading driver: $driverUrl to $driverFilePath"
|
||||
Mark-DownloadInProgress -FFUDevelopmentPath $FFUDevelopmentPath -TargetPath $driverFilePath
|
||||
Start-BitsTransferWithRetry -Source $driverUrl -Destination $driverFilePath
|
||||
Clear-DownloadInProgress -FFUDevelopmentPath $FFUDevelopmentPath -TargetPath $driverFilePath
|
||||
WriteLog "Driver downloaded"
|
||||
|
||||
# Make folder for extraction
|
||||
@@ -1530,7 +1546,9 @@ function Get-DellDrivers {
|
||||
|
||||
WriteLog "Downloading driver: $($driver.DownloadUrl) to $driverFilePath"
|
||||
try {
|
||||
Mark-DownloadInProgress -FFUDevelopmentPath $FFUDevelopmentPath -TargetPath $driverFilePath
|
||||
Start-BitsTransferWithRetry -Source $driver.DownloadUrl -Destination $driverFilePath
|
||||
Clear-DownloadInProgress -FFUDevelopmentPath $FFUDevelopmentPath -TargetPath $driverFilePath
|
||||
WriteLog "Driver downloaded"
|
||||
}
|
||||
catch {
|
||||
@@ -1947,7 +1965,9 @@ function Get-WindowsESD {
|
||||
WriteLog "Downloading $($file.filePath) to $esdFIlePath"
|
||||
$OriginalVerbosePreference = $VerbosePreference
|
||||
$VerbosePreference = 'SilentlyContinue'
|
||||
Mark-DownloadInProgress -FFUDevelopmentPath $FFUDevelopmentPath -TargetPath $esdFilePath
|
||||
Invoke-WebRequest -Uri $file.FilePath -OutFile $esdFilePath -Headers $Headers -UserAgent $UserAgent
|
||||
Clear-DownloadInProgress -FFUDevelopmentPath $FFUDevelopmentPath -TargetPath $esdFilePath
|
||||
$VerbosePreference = $OriginalVerbosePreference
|
||||
WriteLog "Download succeeded"
|
||||
#Set back to show progress
|
||||
@@ -2017,8 +2037,10 @@ function Get-Office {
|
||||
$xmlContent = [xml](Get-Content $OfficeDownloadXML)
|
||||
$xmlContent.Configuration.Add.SourcePath = $OfficePath
|
||||
$xmlContent.Save($OfficeDownloadXML)
|
||||
Mark-DownloadInProgress -FFUDevelopmentPath $FFUDevelopmentPath -TargetPath $OfficePath
|
||||
WriteLog "Downloading M365 Apps/Office to $OfficePath"
|
||||
Invoke-Process $OfficePath\setup.exe "/download $OfficeDownloadXML" | Out-Null
|
||||
Clear-DownloadInProgress -FFUDevelopmentPath $FFUDevelopmentPath -TargetPath $OfficePath
|
||||
|
||||
WriteLog "Cleaning up ODT default config files"
|
||||
#Clean up default configuration files
|
||||
@@ -2051,7 +2073,7 @@ function Get-KBLink {
|
||||
# Extract the first KB article ID from the HTML content and store it globally
|
||||
# Edge and Defender do not have KB article IDs
|
||||
if ($Name -notmatch 'Defender|Edge') {
|
||||
if ($results.Content -match '>\s*([^\(<]+)\(KB(\d+)\)\s*<') {
|
||||
if ($results.Content -match '>\s*([^\(<]+)\(KB(\d+)\)(?:\s*\([^)]+\))*\s*<') {
|
||||
$kbArticleID = "KB$($matches[2])"
|
||||
$global:LastKBArticleID = $kbArticleID
|
||||
WriteLog "Found KB article ID: $kbArticleID"
|
||||
@@ -2542,6 +2564,14 @@ function New-FFUVM {
|
||||
Function Set-CaptureFFU {
|
||||
$CaptureFFUScriptPath = "$FFUDevelopmentPath\WinPECaptureFFUFiles\CaptureFFU.ps1"
|
||||
|
||||
# Workaround for PowerShell 7 issue on Windows 11 23H2 and earlier
|
||||
# https://github.com/PowerShell/PowerShell/issues/21645
|
||||
$osBuild = (Get-CimInstance -ClassName Win32_OperatingSystem).BuildNumber
|
||||
if ($osBuild -le 22631) {
|
||||
WriteLog "Applying workaround for PowerShell 7 LocalAccounts module issue on Windows 11 build $osBuild"
|
||||
Import-Module Microsoft.PowerShell.LocalAccounts -UseWindowsPowerShell
|
||||
}
|
||||
|
||||
If (-not (Test-Path -Path $FFUCaptureLocation)) {
|
||||
WriteLog "Creating FFU capture location at $FFUCaptureLocation"
|
||||
New-Item -Path $FFUCaptureLocation -ItemType Directory -Force
|
||||
@@ -3302,6 +3332,9 @@ Function New-DeploymentUSB {
|
||||
# 2. Partition and format USB drives in parallel
|
||||
WriteLog "Starting parallel creation for $USBDrivesCount USB drive(s)."
|
||||
|
||||
$resolvedUSBThrottle = if ($MaxUSBDrives -gt 0) { [math]::Min($MaxUSBDrives, $USBDrivesCount) } else { $USBDrivesCount }
|
||||
WriteLog "Using USB drive throttle limit: $resolvedUSBThrottle (MaxUSBDrives param: $MaxUSBDrives; Drives to process: $USBDrivesCount)"
|
||||
|
||||
$USBDrives | ForEach-Object -Parallel {
|
||||
$USBDrive = $_
|
||||
|
||||
@@ -3367,8 +3400,19 @@ Function New-DeploymentUSB {
|
||||
|
||||
if ($using:CopyUnattend) {
|
||||
$UnattendPathOnUSB = Join-Path $DeployPartitionDriveLetter "Unattend"
|
||||
WriteLog "Copying Unattend files to $UnattendPathOnUSB"
|
||||
robocopy $using:UnattendFolder $UnattendPathOnUSB /E /COPYALL /R:5 /W:5 /J /NFL /NDL /NJH /NJS /nc /ns /np | Out-Null
|
||||
WriteLog "Copying unattend file to $UnattendPathOnUSB"
|
||||
New-Item -Path $UnattendPathOnUSB -ItemType Directory -ErrorAction SilentlyContinue | Out-Null
|
||||
if ($using:WindowsArch -eq 'x64') {
|
||||
Copy-Item -Path (Join-Path $using:UnattendFolder 'unattend_x64.xml') -Destination (Join-Path $UnattendPathOnUSB 'Unattend.xml') -Force | Out-Null
|
||||
}
|
||||
elseif ($using:WindowsArch -eq 'arm64') {
|
||||
Copy-Item -Path (Join-Path $using:UnattendFolder 'unattend_arm64.xml') -Destination (Join-Path $UnattendPathOnUSB 'Unattend.xml') -Force | Out-Null
|
||||
}
|
||||
if (Test-Path (Join-Path $using:UnattendFolder 'prefixes.txt')) {
|
||||
WriteLog "Copying prefixes.txt file to $UnattendPathOnUSB"
|
||||
Copy-Item -Path (Join-Path $using:UnattendFolder 'prefixes.txt') -Destination (Join-Path $UnattendPathOnUSB 'prefixes.txt') -Force | Out-Null
|
||||
}
|
||||
WriteLog 'Copy completed'
|
||||
}
|
||||
|
||||
if ($using:CopyAutopilot) {
|
||||
@@ -3383,7 +3427,7 @@ Function New-DeploymentUSB {
|
||||
Set-Volume -DriveLetter $DeployPartition.DriveLetter -NewFileSystemLabel "Deploy"
|
||||
WriteLog "Finished processing disk $DiskNumber"
|
||||
|
||||
} -ThrottleLimit 2
|
||||
} -ThrottleLimit $resolvedUSBThrottle
|
||||
|
||||
# Dismount ISO after all parallel jobs are complete
|
||||
WriteLog "Dismounting deployment ISO."
|
||||
@@ -3395,6 +3439,26 @@ Function New-DeploymentUSB {
|
||||
|
||||
function Get-FFUEnvironment {
|
||||
WriteLog 'Dirty.txt file detected. Last run did not complete succesfully. Will clean environment'
|
||||
try {
|
||||
Remove-InProgressItems -FFUDevelopmentPath $FFUDevelopmentPath
|
||||
}
|
||||
catch {
|
||||
WriteLog "Remove-InProgressItems failed: $($_.Exception.Message)"
|
||||
}
|
||||
if ($CleanupCurrentRunDownloads) {
|
||||
try {
|
||||
Cleanup-CurrentRunDownloads -FFUDevelopmentPath $FFUDevelopmentPath
|
||||
}
|
||||
catch {
|
||||
WriteLog "Cleanup-CurrentRunDownloads failed: $($_.Exception.Message)"
|
||||
}
|
||||
try {
|
||||
Restore-RunJsonBackups -FFUDevelopmentPath $FFUDevelopmentPath
|
||||
}
|
||||
catch {
|
||||
WriteLog "Restore-RunJsonBackups failed: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
# Check for running VMs that start with '_FFU-' and are in the 'Off' state
|
||||
$vms = Get-VM
|
||||
|
||||
@@ -3437,6 +3501,11 @@ function Get-FFUEnvironment {
|
||||
WriteLog 'Removal complete'
|
||||
|
||||
# Check for content in the VM folder and delete any folders that start with _FFU-
|
||||
if ([string]::IsNullOrWhiteSpace($VMLocation)) {
|
||||
$VMLocation = Join-Path $FFUDevelopmentPath 'VM'
|
||||
WriteLog "VMLocation not set; defaulting to $VMLocation"
|
||||
}
|
||||
if (Test-Path -Path $VMLocation) {
|
||||
$folders = Get-ChildItem -Path $VMLocation -Directory
|
||||
foreach ($folder in $folders) {
|
||||
if ($folder.Name -like '_FFU-*') {
|
||||
@@ -3444,6 +3513,10 @@ function Get-FFUEnvironment {
|
||||
Remove-Item -Path $folder.FullName -Recurse -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "VMLocation path $VMLocation not found; skipping VM folder cleanup"
|
||||
}
|
||||
|
||||
# Remove orphaned mounted images
|
||||
$mountedImages = Get-WindowsImage -Mounted
|
||||
@@ -3501,7 +3574,20 @@ function Get-FFUEnvironment {
|
||||
Remove-Item -Path $KBPath -Recurse -Force -ErrorAction SilentlyContinue
|
||||
WriteLog 'Removal complete'
|
||||
}
|
||||
Writelog 'Removing dirty.txt file'
|
||||
# Remove existing Apps.iso
|
||||
if (Test-Path -Path $AppsISO) {
|
||||
WriteLog "Removing $AppsISO"
|
||||
Remove-Item -Path $AppsISO -Force -ErrorAction SilentlyContinue
|
||||
WriteLog 'Removal complete'
|
||||
}
|
||||
# Remove per-run session folder if present (Cancel/-Cleanup scenario)
|
||||
$sessionDir = Join-Path $FFUDevelopmentPath '.session'
|
||||
if (Test-Path -Path $sessionDir) {
|
||||
WriteLog 'Removing .session folder'
|
||||
Remove-Item -Path $sessionDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
WriteLog 'Removal complete'
|
||||
}
|
||||
WriteLog 'Removing dirty.txt file'
|
||||
Remove-Item -Path "$FFUDevelopmentPath\dirty.txt" -Force
|
||||
WriteLog "Cleanup complete"
|
||||
}
|
||||
@@ -3511,6 +3597,71 @@ function Remove-FFU {
|
||||
Remove-Item -Path $FFUCaptureLocation\*.ffu -Force
|
||||
WriteLog "Removal complete"
|
||||
}
|
||||
Function Remove-DisabledUpdates {
|
||||
# Remove Defender artifacts if Defender update is disabled
|
||||
if (-not $UpdateLatestDefender) {
|
||||
$removed = $false
|
||||
if (Test-Path -Path $installDefenderPath) {
|
||||
WriteLog "Update Defender disabled - removing $installDefenderPath"
|
||||
Remove-Item -Path $installDefenderPath -Force -ErrorAction SilentlyContinue
|
||||
$removed = $true
|
||||
}
|
||||
if (Test-Path -Path $DefenderPath) {
|
||||
WriteLog "Update Defender disabled - removing $DefenderPath"
|
||||
Remove-Item -Path $DefenderPath -Recurse -Force -ErrorAction SilentlyContinue
|
||||
$removed = $true
|
||||
}
|
||||
if ($removed) { WriteLog 'Removal complete' }
|
||||
}
|
||||
|
||||
# Remove MSRT artifacts if MSRT update is disabled
|
||||
if (-not $UpdateLatestMSRT) {
|
||||
$removed = $false
|
||||
if (Test-Path -Path $installMSRTPath) {
|
||||
WriteLog "Update MSRT disabled - removing $installMSRTPath"
|
||||
Remove-Item -Path $installMSRTPath -Force -ErrorAction SilentlyContinue
|
||||
$removed = $true
|
||||
}
|
||||
if (Test-Path -Path $MSRTPath) {
|
||||
WriteLog "Update MSRT disabled - removing $MSRTPath"
|
||||
Remove-Item -Path $MSRTPath -Recurse -Force -ErrorAction SilentlyContinue
|
||||
$removed = $true
|
||||
}
|
||||
if ($removed) { WriteLog 'Removal complete' }
|
||||
}
|
||||
|
||||
# Remove OneDrive artifacts if OneDrive update is disabled
|
||||
if (-not $UpdateOneDrive) {
|
||||
$removed = $false
|
||||
if (Test-Path -Path $installODPath) {
|
||||
WriteLog "Update OneDrive disabled - removing $installODPath"
|
||||
Remove-Item -Path $installODPath -Force -ErrorAction SilentlyContinue
|
||||
$removed = $true
|
||||
}
|
||||
if (Test-Path -Path $OneDrivePath) {
|
||||
WriteLog "Update OneDrive disabled - removing $OneDrivePath"
|
||||
Remove-Item -Path $OneDrivePath -Recurse -Force -ErrorAction SilentlyContinue
|
||||
$removed = $true
|
||||
}
|
||||
if ($removed) { WriteLog 'Removal complete' }
|
||||
}
|
||||
|
||||
# Remove Edge artifacts if Edge update is disabled
|
||||
if (-not $UpdateEdge) {
|
||||
$removed = $false
|
||||
if (Test-Path -Path $installEdgePath) {
|
||||
WriteLog "Update Edge disabled - removing $installEdgePath"
|
||||
Remove-Item -Path $installEdgePath -Force -ErrorAction SilentlyContinue
|
||||
$removed = $true
|
||||
}
|
||||
if (Test-Path -Path $EdgePath) {
|
||||
WriteLog "Update Edge disabled - removing $EdgePath"
|
||||
Remove-Item -Path $EdgePath -Recurse -Force -ErrorAction SilentlyContinue
|
||||
$removed = $true
|
||||
}
|
||||
if ($removed) { WriteLog 'Removal complete' }
|
||||
}
|
||||
}
|
||||
|
||||
Function Remove-Updates {
|
||||
if ($UpdateLatestDefender) {
|
||||
@@ -3662,9 +3813,512 @@ function Get-PEArchitecture {
|
||||
}
|
||||
}
|
||||
|
||||
function New-RunSession {
|
||||
param(
|
||||
[string]$FFUDevelopmentPath,
|
||||
[string]$DriversFolder,
|
||||
[string]$OrchestrationPath
|
||||
)
|
||||
try {
|
||||
$sessionDir = Join-Path $FFUDevelopmentPath '.session'
|
||||
$backupDir = Join-Path $sessionDir 'backups'
|
||||
$inprogDir = Join-Path $sessionDir 'inprogress'
|
||||
if (-not (Test-Path $sessionDir)) { New-Item -ItemType Directory -Path $sessionDir -Force | Out-Null }
|
||||
if (-not (Test-Path $backupDir)) { New-Item -ItemType Directory -Path $backupDir -Force | Out-Null }
|
||||
if (-not (Test-Path $inprogDir)) { New-Item -ItemType Directory -Path $inprogDir -Force | Out-Null }
|
||||
|
||||
$manifest = [ordered]@{
|
||||
RunStartUtc = (Get-Date).ToUniversalTime().ToString('o')
|
||||
JsonBackups = @()
|
||||
OfficeXmlBackups = @()
|
||||
}
|
||||
|
||||
if ($DriversFolder) {
|
||||
$driverMapPath = Join-Path $DriversFolder 'DriverMapping.json'
|
||||
if (Test-Path $driverMapPath) {
|
||||
$backup = Join-Path $backupDir 'DriverMapping.json'
|
||||
Copy-Item -Path $driverMapPath -Destination $backup -Force
|
||||
$manifest.JsonBackups += @{ Path = $driverMapPath; Backup = $backup }
|
||||
WriteLog "Backed up DriverMapping.json to $backup"
|
||||
}
|
||||
}
|
||||
if ($OrchestrationPath) {
|
||||
$wgPath = Join-Path $OrchestrationPath 'WinGetWin32Apps.json'
|
||||
if (Test-Path $wgPath) {
|
||||
$backup2 = Join-Path $backupDir 'WinGetWin32Apps.json'
|
||||
Copy-Item -Path $wgPath -Destination $backup2 -Force
|
||||
$manifest.JsonBackups += @{ Path = $wgPath; Backup = $backup2 }
|
||||
WriteLog "Backed up WinGetWin32Apps.json to $backup2"
|
||||
}
|
||||
}
|
||||
# Backup Office XMLs (DeployFFU.xml, DownloadFFU.xml) if present so we can restore them after cleanup
|
||||
if ($OfficePath) {
|
||||
foreach ($n in @('DeployFFU.xml', 'DownloadFFU.xml')) {
|
||||
$src = Join-Path $OfficePath $n
|
||||
if (Test-Path $src) {
|
||||
$dst = Join-Path $backupDir $n
|
||||
try {
|
||||
Copy-Item -Path $src -Destination $dst -Force
|
||||
$manifest.OfficeXmlBackups += @{ Path = $src; Backup = $dst }
|
||||
WriteLog "Backed up $n to $dst"
|
||||
}
|
||||
catch { WriteLog "Failed backing up $($n): $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$manifestPath = Join-Path $sessionDir 'currentRun.json'
|
||||
$manifest | ConvertTo-Json -Depth 5 | Set-Content -Path $manifestPath -Encoding UTF8
|
||||
WriteLog "Run session initialized at $sessionDir"
|
||||
}
|
||||
catch {
|
||||
WriteLog "New-RunSession failed: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
function Get-CurrentRunManifest {
|
||||
param([string]$FFUDevelopmentPath)
|
||||
$manifestPath = Join-Path $FFUDevelopmentPath '.session\currentRun.json'
|
||||
if (Test-Path $manifestPath) { return (Get-Content $manifestPath -Raw | ConvertFrom-Json) }
|
||||
return $null
|
||||
}
|
||||
function Save-RunManifest {
|
||||
param([string]$FFUDevelopmentPath, [object]$Manifest)
|
||||
if ($null -eq $Manifest) { return }
|
||||
$manifestPath = Join-Path $FFUDevelopmentPath '.session\currentRun.json'
|
||||
$Manifest | ConvertTo-Json -Depth 5 | Set-Content -Path $manifestPath -Encoding UTF8
|
||||
}
|
||||
function Mark-DownloadInProgress {
|
||||
param([string]$FFUDevelopmentPath, [string]$TargetPath)
|
||||
if ([string]::IsNullOrWhiteSpace($FFUDevelopmentPath) -or [string]::IsNullOrWhiteSpace($TargetPath)) { return }
|
||||
$sessionInprog = Join-Path (Join-Path $FFUDevelopmentPath '.session') 'inprogress'
|
||||
if (-not (Test-Path $sessionInprog)) { New-Item -ItemType Directory -Path $sessionInprog -Force | Out-Null }
|
||||
$marker = Join-Path $sessionInprog ("{0}.marker" -f ([guid]::NewGuid()))
|
||||
$payload = @{ TargetPath = $TargetPath; CreatedUtc = (Get-Date).ToUniversalTime().ToString('o') }
|
||||
$payload | ConvertTo-Json -Depth 3 | Set-Content -Path $marker -Encoding UTF8
|
||||
WriteLog "Marked in-progress: $TargetPath"
|
||||
}
|
||||
function Clear-DownloadInProgress {
|
||||
param([string]$FFUDevelopmentPath, [string]$TargetPath)
|
||||
$sessionInprog = Join-Path (Join-Path $FFUDevelopmentPath '.session') 'inprogress'
|
||||
if (-not (Test-Path $sessionInprog)) { return }
|
||||
Get-ChildItem -Path $sessionInprog -Filter *.marker -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
try {
|
||||
$data = Get-Content $_.FullName -Raw | ConvertFrom-Json
|
||||
if ($data.TargetPath -eq $TargetPath) { Remove-Item -Path $_.FullName -Force }
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
WriteLog "Cleared in-progress: $TargetPath"
|
||||
}
|
||||
function Remove-InProgressItems {
|
||||
param([string]$FFUDevelopmentPath)
|
||||
$sessionInprog = Join-Path (Join-Path $FFUDevelopmentPath '.session') 'inprogress'
|
||||
if (-not (Test-Path $sessionInprog)) { return }
|
||||
|
||||
function Remove-PathWithRetry {
|
||||
param(
|
||||
[string]$path,
|
||||
[bool]$isDirectory
|
||||
)
|
||||
for ($i = 0; $i -lt 3; $i++) {
|
||||
try {
|
||||
if ($isDirectory) {
|
||||
Remove-Item -Path $path -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
else {
|
||||
# clear readonly if set
|
||||
try { (Get-Item -LiteralPath $path -ErrorAction SilentlyContinue).Attributes = 'Normal' } catch {}
|
||||
Remove-Item -Path $path -Force -ErrorAction Stop
|
||||
}
|
||||
return $true
|
||||
}
|
||||
catch {
|
||||
Start-Sleep -Milliseconds 350
|
||||
}
|
||||
}
|
||||
return -not (Test-Path -LiteralPath $path)
|
||||
}
|
||||
|
||||
Get-ChildItem -Path $sessionInprog -Filter *.marker -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
try {
|
||||
$data = Get-Content $_.FullName -Raw | ConvertFrom-Json
|
||||
$target = $data.TargetPath
|
||||
try {
|
||||
if ($DriversFolder -and $target) {
|
||||
$fullTarget = [System.IO.Path]::GetFullPath($target).TrimEnd('\')
|
||||
$driversRoot = [System.IO.Path]::GetFullPath($DriversFolder).TrimEnd('\')
|
||||
if ($fullTarget.StartsWith($driversRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$remainder = $fullTarget.Substring($driversRoot.Length).TrimStart('\')
|
||||
$parts = $remainder -split '\\'
|
||||
if ($parts.Length -ge 1) {
|
||||
$knownMakes = @('Dell', 'HP', 'Lenovo', 'Microsoft')
|
||||
if ($parts.Length -ge 2 -and $knownMakes -contains $parts[0]) {
|
||||
# Drivers\<Make>\<Model>\...
|
||||
$modelFolder = Join-Path (Join-Path $driversRoot $parts[0]) $parts[1]
|
||||
}
|
||||
else {
|
||||
# Drivers\<Model>\... (when DriversFolder already includes Make)
|
||||
$modelFolder = Join-Path $driversRoot $parts[0]
|
||||
}
|
||||
if ($modelFolder) {
|
||||
WriteLog "Promoting in-progress driver target to model folder: $modelFolder (from $target)"
|
||||
$target = $modelFolder
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
|
||||
if (Test-Path $target) {
|
||||
# Special-case Office: preserve DeployFFU.xml and DownloadFFU.xml; remove everything else with retries.
|
||||
$targetFull = [System.IO.Path]::GetFullPath($target).TrimEnd('\')
|
||||
$officeFull = $null
|
||||
if ($OfficePath) { $officeFull = [System.IO.Path]::GetFullPath($OfficePath).TrimEnd('\') }
|
||||
|
||||
if ($officeFull -and ($targetFull -ieq $officeFull) -and (Test-Path $OfficePath -PathType Container)) {
|
||||
$preserve = @('DeployFFU.xml', 'DownloadFFU.xml')
|
||||
WriteLog "Cleaning in-progress Office folder: preserving $($preserve -join ', ') and removing other content."
|
||||
Get-ChildItem -Path $OfficePath -Force | ForEach-Object {
|
||||
if ($preserve -notcontains $_.Name) {
|
||||
$itemPath = $_.FullName
|
||||
$isDir = $_.PSIsContainer
|
||||
WriteLog "Removing Office item: $itemPath"
|
||||
$removed = $false
|
||||
try { $removed = Remove-PathWithRetry -path $itemPath -isDirectory:$isDir } catch {}
|
||||
if (-not $removed) {
|
||||
# If setup.exe (or ODT stub) is locked, try to stop the exact owning process by path and retry.
|
||||
try {
|
||||
$basename = [System.IO.Path]::GetFileName($itemPath)
|
||||
if (-not $isDir -and $basename -in @('setup.exe', 'odtsetup.exe')) {
|
||||
Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.Path -eq $itemPath } | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Milliseconds 500
|
||||
$removed = Remove-PathWithRetry -path $itemPath -isDirectory:$false
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Process stop attempt for $itemPath failed: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
if (-not $removed) {
|
||||
WriteLog "Failed removing Office item $itemPath after retries."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Removing in-progress target: $target"
|
||||
$isDir = Test-Path $target -PathType Container
|
||||
[void](Remove-PathWithRetry -path $target -isDirectory:$isDir)
|
||||
}
|
||||
}
|
||||
|
||||
Remove-Item -Path $_.FullName -Force
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed Remove-InProgressItems marker '$($_.FullName)': $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
# Also clean up any driver content created this run (model folders and temp folders),
|
||||
# even when broader current-run cleanup is not requested.
|
||||
try {
|
||||
if ($DriversFolder -and (Test-Path $DriversFolder)) {
|
||||
$manifest = Get-CurrentRunManifest -FFUDevelopmentPath $FFUDevelopmentPath
|
||||
if ($manifest -and $manifest.RunStartUtc) {
|
||||
$runStart = [datetime]::Parse($manifest.RunStartUtc)
|
||||
|
||||
# Remove OEM temp folders like _TEMP_* (safe to always remove)
|
||||
Get-ChildItem -Path $DriversFolder -Directory -Recurse -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -like '_TEMP_*' } |
|
||||
ForEach-Object {
|
||||
WriteLog "Removing driver temp folder: $($_.FullName)"
|
||||
Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Remove model folders created/modified this run; never remove top-level make roots
|
||||
Get-ChildItem -Path $DriversFolder -Directory -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
$makeRoot = $_.FullName
|
||||
# Model-level folders are immediate children under a make root (e.g. Drivers\Lenovo\<Model>)
|
||||
Get-ChildItem -Path $makeRoot -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.CreationTimeUtc -ge $runStart -or $_.LastWriteTimeUtc -ge $runStart } |
|
||||
ForEach-Object {
|
||||
WriteLog "Removing driver model folder from current run: $($_.FullName)"
|
||||
Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# Remove make root folders created this run (if empty)
|
||||
Get-ChildItem -Path $DriversFolder -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.CreationTimeUtc -ge $runStart -and $_.LastWriteTimeUtc -ge $runStart } |
|
||||
ForEach-Object {
|
||||
$any = Get-ChildItem -Path $_.FullName -Force -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($null -eq $any) {
|
||||
WriteLog "Removing empty make root folder created this run: $($_.FullName)"
|
||||
Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
else {
|
||||
WriteLog "Skipping non-empty make root folder: $($_.FullName)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Driver in-progress cleanup step failed: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
function Cleanup-CurrentRunDownloads {
|
||||
param([string]$FFUDevelopmentPath)
|
||||
$manifest = Get-CurrentRunManifest -FFUDevelopmentPath $FFUDevelopmentPath
|
||||
if ($null -eq $manifest) { WriteLog "No current run manifest; skipping current-run cleanup."; return }
|
||||
$runStart = [datetime]::Parse($manifest.RunStartUtc)
|
||||
|
||||
# 1) Generic current-run scrub across known roots (includes Orchestration now)
|
||||
$roots = @()
|
||||
if ($AppsPath) { $roots += (Join-Path $AppsPath 'Win32'); $roots += (Join-Path $AppsPath 'MSStore') }
|
||||
if ($DefenderPath) { $roots += $DefenderPath }
|
||||
if ($MSRTPath) { $roots += $MSRTPath }
|
||||
if ($OneDrivePath) { $roots += $OneDrivePath }
|
||||
if ($EdgePath) { $roots += $EdgePath }
|
||||
if ($KBPath) { $roots += $KBPath }
|
||||
if ($DriversFolder) { $roots += $DriversFolder }
|
||||
if ($orchestrationPath) { $roots += $orchestrationPath }
|
||||
|
||||
foreach ($root in $roots | Where-Object { $_ -and (Test-Path $_) }) {
|
||||
$isDriversRoot = $false
|
||||
try {
|
||||
if ($DriversFolder) {
|
||||
$isDriversRoot = ([System.IO.Path]::GetFullPath($root).TrimEnd('\') -ieq [System.IO.Path]::GetFullPath($DriversFolder).TrimEnd('\'))
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
|
||||
if ($isDriversRoot) {
|
||||
WriteLog "Scanning Drivers folder (creation-time filter) in $root"
|
||||
|
||||
# Remove driver folders created this run (skip non-empty make roots)
|
||||
Get-ChildItem -Path $root -Directory -Recurse -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.CreationTimeUtc -ge $runStart } |
|
||||
Sort-Object FullName -Descending | ForEach-Object {
|
||||
try {
|
||||
$parent = Split-Path -Path $_.FullName -Parent
|
||||
$parentIsDriversRoot = ([System.IO.Path]::GetFullPath($parent).TrimEnd('\') -ieq [System.IO.Path]::GetFullPath($root).TrimEnd('\'))
|
||||
if ($parentIsDriversRoot) {
|
||||
# Only remove top-level make folders if created this run AND empty (avoid deleting existing Lenovo/HP/Dell/Microsoft trees)
|
||||
$any = Get-ChildItem -Path $_.FullName -Force -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($null -eq $any) {
|
||||
WriteLog "Removing empty make folder created this run: $($_.FullName)"
|
||||
Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Removing current-run driver folder: $($_.FullName)"
|
||||
Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
catch { WriteLog "Failed removing driver folder $($_.FullName): $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
# Remove driver files created this run
|
||||
Get-ChildItem -Path $root -File -Recurse -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.CreationTimeUtc -ge $runStart } |
|
||||
ForEach-Object {
|
||||
try {
|
||||
WriteLog "Removing current-run driver file: $($_.FullName)"
|
||||
Remove-Item -Path $_.FullName -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
catch { WriteLog "Failed removing driver file $($_.FullName): $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
# Prune empty driver folders (skip existing make roots)
|
||||
Get-ChildItem -Path $root -Directory -Recurse -ErrorAction SilentlyContinue |
|
||||
Sort-Object FullName -Descending | ForEach-Object {
|
||||
try {
|
||||
$any = Get-ChildItem -Path $_.FullName -Force -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($null -eq $any) {
|
||||
$parent = Split-Path -Path $_.FullName -Parent
|
||||
$parentIsDriversRoot = ([System.IO.Path]::GetFullPath($parent).TrimEnd('\') -ieq [System.IO.Path]::GetFullPath($root).TrimEnd('\'))
|
||||
if ($parentIsDriversRoot) {
|
||||
# Only remove empty make roots if they were created this run
|
||||
if ($_.CreationTimeUtc -ge $runStart) {
|
||||
WriteLog "Removing empty make folder created this run: $($_.FullName)"
|
||||
Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Removing empty driver subfolder: $($_.FullName)"
|
||||
Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { WriteLog "Failed pruning empty driver folder $($_.FullName): $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Scanning for current-run items in $root"
|
||||
# Remove folders created/modified this run (legacy behavior for non-Drivers roots)
|
||||
Get-ChildItem -Path $root -Directory -Recurse -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.LastWriteTimeUtc -ge $runStart } |
|
||||
Sort-Object FullName -Descending | ForEach-Object {
|
||||
try {
|
||||
WriteLog "Removing current-run folder: $($_.FullName)"
|
||||
Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
catch { WriteLog "Failed removing folder $($_.FullName): $($_.Exception.Message)" }
|
||||
}
|
||||
# Remove files created/modified this run (preserve Office XMLs)
|
||||
Get-ChildItem -Path $root -File -Recurse -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.LastWriteTimeUtc -ge $runStart -and $_.Name -notin @('DeployFFU.xml', 'DownloadFFU.xml') } |
|
||||
ForEach-Object {
|
||||
try {
|
||||
WriteLog "Removing current-run file: $($_.FullName)"
|
||||
Remove-Item -Path $_.FullName -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
catch { WriteLog "Failed removing file $($_.FullName): $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 2) Office folder policy: keep XML configs, remove everything else
|
||||
if ($OfficePath -and (Test-Path $OfficePath)) {
|
||||
$preserve = @('DeployFFU.xml', 'DownloadFFU.xml')
|
||||
WriteLog "Cleaning Office folder: preserving $($preserve -join ', ') and removing other content."
|
||||
Get-ChildItem -Path $OfficePath -Force | ForEach-Object {
|
||||
if ($preserve -notcontains $_.Name) {
|
||||
try {
|
||||
WriteLog "Removing Office item: $($_.FullName)"
|
||||
if ($_.PSIsContainer) {
|
||||
Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
else {
|
||||
Remove-Item -Path $_.FullName -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
catch { WriteLog "Failed removing Office item $($_.FullName): $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 3) Remove generated update artifacts under Orchestration (Update-*.ps1) created this run
|
||||
if ($orchestrationPath -and (Test-Path $orchestrationPath)) {
|
||||
try {
|
||||
Get-ChildItem -Path $orchestrationPath -Filter 'Update-*.ps1' -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.LastWriteTimeUtc -ge $runStart } | ForEach-Object {
|
||||
WriteLog "Removing current-run artifact: $($_.FullName)"
|
||||
Remove-Item -Path $_.FullName -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
catch { WriteLog "Failed removing Update-*.ps1 artifacts: $($_.Exception.Message)" }
|
||||
# Also remove Install-Office.ps1 if created this run
|
||||
$installOffice = Join-Path $orchestrationPath 'Install-Office.ps1'
|
||||
if (Test-Path $installOffice) {
|
||||
$fi = Get-Item $installOffice
|
||||
if ($fi.LastWriteTimeUtc -ge $runStart) {
|
||||
WriteLog "Removing current-run artifact: $installOffice"
|
||||
Remove-Item -Path $installOffice -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 4) If Defender/OneDrive/Edge/MSRT folders exist, remove them entirely (they're session downloads)
|
||||
foreach ($p in @($DefenderPath, $OneDrivePath, $EdgePath, $MSRTPath)) {
|
||||
if ($p -and (Test-Path $p)) {
|
||||
try {
|
||||
WriteLog "Removing current-run folder (entire): $p"
|
||||
Remove-Item -Path $p -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
catch { WriteLog "Failed removing folder $($p): $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
|
||||
# 5) Remove any ESDs downloaded this run
|
||||
Get-ChildItem -Path $PSScriptRoot -Filter *.esd -File -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTimeUtc -ge $runStart } | ForEach-Object {
|
||||
try {
|
||||
WriteLog "Removing current-run ESD: $($_.FullName)"
|
||||
Remove-Item -Path $_.FullName -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
catch { WriteLog "Failed removing ESD $($_.FullName): $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
# 6) Remove empty top-level subfolders under Apps (cosmetic)
|
||||
if ($AppsPath -and (Test-Path $AppsPath)) {
|
||||
Get-ChildItem -Path $AppsPath -Directory -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
try {
|
||||
$any = Get-ChildItem -Path $_.FullName -Force -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($null -eq $any) {
|
||||
WriteLog "Removing empty folder: $($_.FullName)"
|
||||
Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
catch { WriteLog "Failed removing empty folder $($_.FullName): $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
function Restore-RunJsonBackups {
|
||||
param([string]$FFUDevelopmentPath)
|
||||
$manifest = Get-CurrentRunManifest -FFUDevelopmentPath $FFUDevelopmentPath
|
||||
if ($null -eq $manifest) { return }
|
||||
$runStart = [datetime]::Parse($manifest.RunStartUtc)
|
||||
|
||||
foreach ($entry in $manifest.JsonBackups) {
|
||||
$path = $entry.Path
|
||||
$backup = $entry.Backup
|
||||
try {
|
||||
if (Test-Path $backup) {
|
||||
WriteLog "Restoring JSON from backup: $path"
|
||||
Copy-Item -Path $backup -Destination $path -Force
|
||||
}
|
||||
}
|
||||
catch { WriteLog "Failed restoring backup for $($path): $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
$candidateJsons = @()
|
||||
if ($DriversFolder) { $candidateJsons += (Join-Path $DriversFolder 'DriverMapping.json') }
|
||||
if ($orchestrationPath) { $candidateJsons += (Join-Path $orchestrationPath 'WinGetWin32Apps.json') }
|
||||
|
||||
foreach ($jp in $candidateJsons) {
|
||||
if (Test-Path $jp) {
|
||||
$hasBackup = $manifest.JsonBackups | Where-Object { $_.Path -eq $jp }
|
||||
if ($null -eq $hasBackup) {
|
||||
$fi = Get-Item $jp
|
||||
if ($fi.LastWriteTimeUtc -ge $runStart) {
|
||||
WriteLog "Removing current-run JSON: $jp"
|
||||
Remove-Item -Path $jp -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Restore Office XML backups if present; ensure Office folder exists and only XMLs remain
|
||||
if ($manifest.OfficeXmlBackups -and $OfficePath) {
|
||||
if (-not (Test-Path $OfficePath)) {
|
||||
try { New-Item -ItemType Directory -Path $OfficePath -Force | Out-Null } catch {}
|
||||
}
|
||||
foreach ($ox in $manifest.OfficeXmlBackups) {
|
||||
try {
|
||||
WriteLog "Restoring Office XML from backup: $($ox.Path)"
|
||||
Copy-Item -Path $ox.Backup -Destination $ox.Path -Force
|
||||
}
|
||||
catch { WriteLog "Failed restoring Office XML $($ox.Path): $($_.Exception.Message)" }
|
||||
}
|
||||
# Ensure only DeployFFU.xml and DownloadFFU.xml remain
|
||||
$preserve = @('DeployFFU.xml', 'DownloadFFU.xml')
|
||||
Get-ChildItem -Path $OfficePath -Force -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
if ($preserve -notcontains $_.Name) {
|
||||
try {
|
||||
if ($_.PSIsContainer) { Remove-Item -Path $_.FullName -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
else { Remove-Item -Path $_.FullName -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
catch { WriteLog "Failed removing extra Office item $($_.FullName): $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
###END FUNCTIONS
|
||||
|
||||
|
||||
if (-not $Cleanup) {
|
||||
#Remove old log file if found
|
||||
if (Test-Path -Path $Logfile) {
|
||||
Remove-item -Path $LogFile -Force
|
||||
@@ -3674,8 +4328,18 @@ $startTime = Get-Date
|
||||
Write-Host "FFU build process started at" $startTime
|
||||
Write-Host "This process can take 20 minutes or more. Please do not close this window or any additional windows that pop up"
|
||||
Write-Host "To track progress, please open the log file $Logfile or use the -Verbose parameter next time"
|
||||
}
|
||||
|
||||
|
||||
if ($Cleanup) {
|
||||
WriteLog 'User cancelled, starting cleanup process'
|
||||
WriteLog 'Cleanup requested via -Cleanup. Running Get-FFUEnvironment...'
|
||||
Get-FFUEnvironment
|
||||
return
|
||||
}
|
||||
|
||||
WriteLog 'Begin Logging'
|
||||
New-RunSession -FFUDevelopmentPath $FFUDevelopmentPath -DriversFolder $DriversFolder -OrchestrationPath $orchestrationPath
|
||||
Set-Progress -Percentage 1 -Message "FFU build process started..."
|
||||
|
||||
####### Generate Config File #######
|
||||
@@ -3734,14 +4398,10 @@ if ($InstallDrivers -or $CopyDrivers) {
|
||||
throw "-InstallDrivers or -CopyDrivers is set to `$true, but the $DriversFolder folder is missing"
|
||||
}
|
||||
if ((Get-ChildItem -Path $DriversFolder -Recurse | Measure-Object -Property Length -Sum).Sum -lt 1MB) {
|
||||
WriteLog "-InstallDrivers or -CopyDrivers is set to `$true, but the $DriversFolder folder is empty"
|
||||
throw "-InstallDrivers or -CopyDrivers is set to `$true, but the $DriversFolder folder is empty"
|
||||
WriteLog "-InstallDrivers or -CopyDrivers is set to `$true, but the $DriversFolder folder is empty, and no drivers are specified for download."
|
||||
throw "-InstallDrivers or -CopyDrivers is set to `$true, but the $DriversFolder folder is empty, and no drivers are specified for download."
|
||||
}
|
||||
if (!(Test-Path -Path $DriversJsonPath)) {
|
||||
WriteLog "-InstallDrivers or -CopyDrivers is set to `$true, but the $DriversJsonPath file is missing"
|
||||
throw "-InstallDrivers or -CopyDrivers is set to `$true, but the $DriversJsonPath file is missing"
|
||||
}
|
||||
WriteLog 'Driver validation complete'
|
||||
WriteLog "Drivers folder found with content. Will use existing drivers."
|
||||
}
|
||||
}
|
||||
#Validate PEDrivers folder
|
||||
@@ -4292,6 +4952,10 @@ if ($InstallApps) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
# Remove residual update artifacts for any updates disabled via flags
|
||||
Remove-DisabledUpdates
|
||||
|
||||
#Update Latest Defender Platform and Definitions - these can't be serviced into the VHDX, will be saved to AppsPath
|
||||
if ($UpdateLatestDefender) {
|
||||
# Check if Defender has already been downloaded, if so, skip download
|
||||
@@ -4534,6 +5198,28 @@ if ($InstallApps) {
|
||||
}
|
||||
|
||||
#Create Apps ISO
|
||||
# Inject Unattend.xml into Apps if requested and applicable
|
||||
if ($InstallApps -and $InjectUnattend) {
|
||||
# Determine source unattend.xml based on architecture
|
||||
$archSuffix = if ($WindowsArch -ieq 'arm64') { 'arm64' } else { 'x64' }
|
||||
$unattendSource = Join-Path $UnattendFolder "unattend_$archSuffix.xml"
|
||||
|
||||
# Ensure target folder exists under Apps
|
||||
$targetFolder = Join-Path $AppsPath 'Unattend'
|
||||
if (-not (Test-Path -Path $targetFolder -PathType Container)) {
|
||||
New-Item -Path $targetFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
# Copy if source exists; otherwise log and skip
|
||||
if (Test-Path -Path $unattendSource -PathType Leaf) {
|
||||
$destination = Join-Path $targetFolder 'Unattend.xml'
|
||||
Copy-Item -Path $unattendSource -Destination $destination -Force | Out-Null
|
||||
WriteLog "Injected unattend file into Apps: $unattendSource -> $destination"
|
||||
}
|
||||
else {
|
||||
WriteLog "InjectUnattend is true but source file missing: $unattendSource. Skipping unattend injection."
|
||||
}
|
||||
}
|
||||
Set-Progress -Percentage 10 -Message "Creating Apps ISO..."
|
||||
WriteLog "Creating $AppsISO file"
|
||||
New-AppsISO
|
||||
@@ -4924,20 +5610,6 @@ try {
|
||||
Set-WindowsProductKey -Path $WindowsPartition -ProductKey $ProductKey
|
||||
}
|
||||
|
||||
|
||||
If ($InstallApps) {
|
||||
#Copy Unattend file so VM Boots into Audit Mode
|
||||
WriteLog 'Copying unattend file to boot to audit mode'
|
||||
New-Item -Path "$($osPartitionDriveLetter):\Windows\Panther\unattend" -ItemType Directory -Force | Out-Null
|
||||
if ($WindowsArch -eq 'x64') {
|
||||
Copy-Item -Path "$FFUDevelopmentPath\BuildFFUUnattend\unattend_x64.xml" -Destination "$($osPartitionDriveLetter):\Windows\Panther\Unattend\Unattend.xml" -Force | Out-Null
|
||||
}
|
||||
else {
|
||||
Copy-Item -Path "$FFUDevelopmentPath\BuildFFUUnattend\unattend_arm64.xml" -Destination "$($osPartitionDriveLetter):\Windows\Panther\Unattend\Unattend.xml" -Force | Out-Null
|
||||
}
|
||||
WriteLog 'Copy completed'
|
||||
}
|
||||
|
||||
Set-Progress -Percentage 40 -Message "Finalizing VHDX..."
|
||||
if ($AllowVHDXCaching -and !$cachedVHDXFileFound) {
|
||||
WriteLog 'Caching VHDX file'
|
||||
@@ -4973,11 +5645,6 @@ try {
|
||||
Mount-Vhd -Path $VHDXPath
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ($InstallApps) {
|
||||
Dismount-ScratchVhdx -VhdxPath $VHDXPath
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Host 'Creating VHDX Failed'
|
||||
@@ -5002,6 +5669,33 @@ catch {
|
||||
|
||||
}
|
||||
|
||||
#Inject unattend after caching so cached VHDX never contains audit-mode unattend
|
||||
if ($InstallApps) {
|
||||
# Determine mount state and only mount if needed to avoid redundant mount/dismount cycles
|
||||
$vhdMeta = Get-VHD -Path $VHDXPath
|
||||
if ($vhdMeta.Attached) {
|
||||
WriteLog 'VHDX already mounted; reusing existing mount for unattend injection'
|
||||
$disk = Get-Disk -Number $vhdMeta.DiskNumber
|
||||
}
|
||||
else {
|
||||
WriteLog 'Mounting VHDX to inject unattend for audit-mode boot'
|
||||
$disk = Mount-VHD -Path $VHDXPath -Passthru | Get-Disk
|
||||
}
|
||||
$osPartition = $disk | Get-Partition | Where-Object { $_.GptType -eq '{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}' }
|
||||
$osPartitionDriveLetter = $osPartition.DriveLetter
|
||||
WriteLog 'Copying unattend file to boot to audit mode'
|
||||
New-Item -Path "$($osPartitionDriveLetter):\Windows\Panther\Unattend" -ItemType Directory -Force | Out-Null
|
||||
if ($WindowsArch -eq 'x64') {
|
||||
Copy-Item -Path "$FFUDevelopmentPath\BuildFFUUnattend\unattend_x64.xml" -Destination "$($osPartitionDriveLetter):\Windows\Panther\Unattend\Unattend.xml" -Force | Out-Null
|
||||
}
|
||||
else {
|
||||
Copy-Item -Path "$FFUDevelopmentPath\BuildFFUUnattend\unattend_arm64.xml" -Destination "$($osPartitionDriveLetter):\Windows\Panther\Unattend\Unattend.xml" -Force | Out-Null
|
||||
}
|
||||
WriteLog 'Copy completed'
|
||||
# Always dismount so downstream VM creation logic has a clean starting point
|
||||
Dismount-ScratchVhdx -VhdxPath $VHDXPath
|
||||
}
|
||||
|
||||
#If installing apps (Office or 3rd party), we need to build a VM and capture that FFU, if not, just cut the FFU from the VHDX file
|
||||
if ($InstallApps) {
|
||||
Set-Progress -Percentage 41 -Message "Starting VM for app installation..."
|
||||
@@ -5246,6 +5940,13 @@ if ($AllowVHDXCaching) {
|
||||
throw $_
|
||||
}
|
||||
}
|
||||
|
||||
# Remove WinGetWin32Apps.json so it is always rebuilt next run
|
||||
if (Test-Path -Path $wingetWin32jsonFile -PathType Leaf) {
|
||||
WriteLog "Removing $wingetWin32jsonFile"
|
||||
Remove-Item -Path $wingetWin32jsonFile -Force -ErrorAction SilentlyContinue
|
||||
WriteLog "Removal complete"
|
||||
}
|
||||
#Set $LongPathsEnabled registry value back to original value. $LongPathsEnabled could be $null if the registry value was not found
|
||||
if ($null -eq $LongPathsEnabled) {
|
||||
Remove-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -ErrorAction SilentlyContinue
|
||||
@@ -5256,6 +5957,11 @@ else {
|
||||
|
||||
#Clean up dirty.txt file
|
||||
Remove-Item -Path .\dirty.txt -Force | out-null
|
||||
# Remove per-run session folder if present
|
||||
$sessionDir = Join-Path $FFUDevelopmentPath '.session'
|
||||
if (Test-Path -Path $sessionDir) {
|
||||
Remove-Item -Path $sessionDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($VerbosePreference -ne 'Continue') {
|
||||
Write-Host 'Script complete'
|
||||
}
|
||||
|
||||
@@ -43,14 +43,17 @@ $script:uiState = [PSCustomObject]@{
|
||||
vmSwitchMap = @{};
|
||||
logData = $null;
|
||||
logStreamReader = $null;
|
||||
pollTimer = $null
|
||||
pollTimer = $null;
|
||||
lastConfigFilePath = $null
|
||||
};
|
||||
Flags = @{
|
||||
installAppsForcedByUpdates = $false;
|
||||
prevInstallAppsStateBeforeUpdates = $null;
|
||||
installAppsCheckedByOffice = $false;
|
||||
lastSortProperty = $null;
|
||||
lastSortAscending = $true
|
||||
lastSortAscending = $true;
|
||||
isBuilding = $false;
|
||||
isCleanupRunning = $false
|
||||
};
|
||||
Defaults = @{};
|
||||
LogFilePath = "$FFUDevelopmentPath\FFUDevelopment_UI.log"
|
||||
@@ -132,7 +135,245 @@ $script:uiState.Controls.btnRun.Add_Click({
|
||||
# Get a local reference to the button for convenience in this handler
|
||||
$btnRun = $script:uiState.Controls.btnRun
|
||||
try {
|
||||
# Disable button to prevent multiple clicks
|
||||
# If a build is running and cleanup is not already running, treat this click as Cancel
|
||||
if ($script:uiState.Flags.isBuilding -and -not $script:uiState.Flags.isCleanupRunning) {
|
||||
$btnRun.IsEnabled = $false
|
||||
$script:uiState.Controls.txtStatus.Text = "Cancel requested. Stopping build..."
|
||||
WriteLog "Cancel requested by user. Stopping background build job."
|
||||
|
||||
# Stop the timer
|
||||
if ($null -ne $script:uiState.Data.pollTimer) {
|
||||
$script:uiState.Data.pollTimer.Stop()
|
||||
$script:uiState.Data.pollTimer = $null
|
||||
}
|
||||
|
||||
# Close the log stream
|
||||
if ($null -ne $script:uiState.Data.logStreamReader) {
|
||||
$script:uiState.Data.logStreamReader.Close()
|
||||
$script:uiState.Data.logStreamReader.Dispose()
|
||||
$script:uiState.Data.logStreamReader = $null
|
||||
}
|
||||
|
||||
# Stop and remove the running build job
|
||||
$jobToStop = $script:uiState.Data.currentBuildJob
|
||||
$script:uiState.Data.currentBuildJob = $null
|
||||
if ($null -ne $jobToStop) {
|
||||
try {
|
||||
# Attempt graceful stop first
|
||||
Stop-Job -Job $jobToStop -ErrorAction SilentlyContinue
|
||||
Wait-Job -Job $jobToStop -Timeout 5 -ErrorAction SilentlyContinue | Out-Null
|
||||
}
|
||||
catch {
|
||||
WriteLog "Stop-Job threw: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# If the job's hosting process is still alive, kill its process tree to stop child tools like DISM
|
||||
try {
|
||||
$jobProcId = $null
|
||||
if ($null -ne $jobToStop.ChildJobs -and $jobToStop.ChildJobs.Count -gt 0) {
|
||||
$jobProcId = $jobToStop.ChildJobs[0].ProcessId
|
||||
}
|
||||
if ($jobProcId) {
|
||||
# Recursively terminate the job process and any children
|
||||
function Stop-ProcessTree {
|
||||
param([int]$parentPid)
|
||||
$children = Get-CimInstance Win32_Process -Filter "ParentProcessId=$parentPid" -ErrorAction SilentlyContinue
|
||||
foreach ($child in $children) {
|
||||
Stop-ProcessTree -parentPid $child.ProcessId
|
||||
}
|
||||
try { Stop-Process -Id $parentPid -Force -ErrorAction SilentlyContinue } catch {}
|
||||
}
|
||||
Stop-ProcessTree -parentPid $jobProcId
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error terminating job process tree: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Safety net: kill any active DISM capture still running
|
||||
try {
|
||||
$dismCaptures = Get-CimInstance Win32_Process -Filter "Name='DISM.EXE'" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match '/Capture-FFU' }
|
||||
foreach ($p in $dismCaptures) {
|
||||
try { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } catch {}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error stopping DISM capture processes: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Also stop Office ODT setup.exe if running (to avoid recreating files after cleanup)
|
||||
try {
|
||||
$officePathForKill = $null
|
||||
|
||||
# Prefer explicit UI path
|
||||
$uiOfficePath = $script:uiState.Controls.txtOfficePath.Text
|
||||
if (-not [string]::IsNullOrWhiteSpace($uiOfficePath)) {
|
||||
$officePathForKill = $uiOfficePath
|
||||
}
|
||||
else {
|
||||
# Fall back to the last config path only if known
|
||||
$lastConfigPathLocal = $script:uiState.Data.lastConfigFilePath
|
||||
if (-not [string]::IsNullOrWhiteSpace($lastConfigPathLocal)) {
|
||||
$ffuDevRoot = Split-Path (Split-Path $lastConfigPathLocal -Parent) -Parent
|
||||
if (-not [string]::IsNullOrWhiteSpace($ffuDevRoot)) {
|
||||
$officePathForKill = Join-Path $ffuDevRoot 'Apps\Office'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Only proceed when a valid Office folder exists
|
||||
if ($officePathForKill -and (Test-Path -LiteralPath $officePathForKill -PathType Container)) {
|
||||
$setupProcs = Get-CimInstance Win32_Process -Filter "Name='setup.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.ExecutablePath -like "$officePathForKill*" }
|
||||
foreach ($p in $setupProcs) {
|
||||
try { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error stopping Office setup.exe processes: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
try {
|
||||
Remove-Job -Job $jobToStop -Force -ErrorAction SilentlyContinue
|
||||
WriteLog "Background build job stopped and removed."
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error removing background build job: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Start cleanup using the same BuildFFUVM.ps1 via -Cleanup short-circuit
|
||||
$lastConfigPath = $script:uiState.Data.lastConfigFilePath
|
||||
if ([string]::IsNullOrWhiteSpace($lastConfigPath)) {
|
||||
WriteLog "No stored config file path found. Cleanup cannot proceed."
|
||||
$script:uiState.Controls.txtStatus.Text = "Build canceled. No config found for cleanup."
|
||||
$script:uiState.Flags.isBuilding = $false
|
||||
$script:uiState.Flags.isCleanupRunning = $false
|
||||
$btnRun.Content = "Build FFU"
|
||||
$btnRun.IsEnabled = $true
|
||||
return
|
||||
}
|
||||
|
||||
$ffuDevPath = Split-Path (Split-Path $lastConfigPath -Parent) -Parent
|
||||
$mainLogPath = Join-Path $ffuDevPath "FFUDevelopment.log"
|
||||
|
||||
WriteLog "Starting cleanup without deleting FFUDevelopment.log (will append new entries)."
|
||||
|
||||
$script:uiState.Controls.txtStatus.Text = "Cancel in progress... Cleaning environment..."
|
||||
WriteLog "Starting cleanup job (BuildFFUVM.ps1 -Cleanup)."
|
||||
|
||||
# Prepare parameters for cleanup
|
||||
# Inform user: in-progress items will be removed; ask whether to also remove other items downloaded during this run
|
||||
$removeCurrentRunToo = $false
|
||||
$promptText = "Cancel requested.`n`nWe'll remove the download currently in progress to avoid partial/corrupt content.`n`nDo you also want to remove other items downloaded during this run? Previously downloaded items will be kept."
|
||||
$result = [System.Windows.MessageBox]::Show($promptText, "Cancel cleanup options", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question)
|
||||
if ($result -eq [System.Windows.MessageBoxResult]::Yes) { $removeCurrentRunToo = $true }
|
||||
|
||||
$cleanupParams = @{
|
||||
ConfigFile = $lastConfigPath
|
||||
Cleanup = $true
|
||||
# Avoid wiping all user content on cancel
|
||||
RemoveApps = $false
|
||||
RemoveUpdates = $false
|
||||
CleanupDrivers = $false
|
||||
# Scoped removal to current run only (optional per user choice)
|
||||
CleanupCurrentRunDownloads = $removeCurrentRunToo
|
||||
}
|
||||
|
||||
$cleanupScriptBlock = {
|
||||
param($buildParams, $PSScriptRoot)
|
||||
& "$PSScriptRoot\BuildFFUVM.ps1" @buildParams
|
||||
}
|
||||
|
||||
# Start cleanup job
|
||||
$script:uiState.Data.currentBuildJob = Start-Job -ScriptBlock $cleanupScriptBlock -ArgumentList @($cleanupParams, $PSScriptRoot)
|
||||
|
||||
# Wait for log file to appear (or open immediately if it exists)
|
||||
$logWaitTimeout = 60
|
||||
$watch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
while (-not (Test-Path $mainLogPath) -and $watch.Elapsed.TotalSeconds -lt $logWaitTimeout) {
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
$watch.Stop()
|
||||
|
||||
# Open log stream for cleanup (tail to end to avoid re-reading the whole file)
|
||||
if (Test-Path $mainLogPath) {
|
||||
$fileStream = [System.IO.File]::Open($mainLogPath, 'Open', 'Read', 'ReadWrite')
|
||||
[void]$fileStream.Seek(0, [System.IO.SeekOrigin]::End)
|
||||
$script:uiState.Data.logStreamReader = [System.IO.StreamReader]::new($fileStream)
|
||||
}
|
||||
else {
|
||||
WriteLog "Warning: Main log file not found at $mainLogPath after waiting. Monitor tab will not update during cleanup."
|
||||
}
|
||||
|
||||
# Create a timer to poll the cleanup job
|
||||
$script:uiState.Data.pollTimer = New-Object System.Windows.Threading.DispatcherTimer
|
||||
$script:uiState.Data.pollTimer.Interval = [TimeSpan]::FromSeconds(1)
|
||||
$script:uiState.Flags.isCleanupRunning = $true
|
||||
|
||||
$script:uiState.Data.pollTimer.Add_Tick({
|
||||
param($sender, $e)
|
||||
$currentJob = $script:uiState.Data.currentBuildJob
|
||||
|
||||
# Read new lines from log
|
||||
if ($null -ne $script:uiState.Data.logStreamReader) {
|
||||
while ($null -ne ($line = $script:uiState.Data.logStreamReader.ReadLine())) {
|
||||
$script:uiState.Data.logData.Add($line)
|
||||
if ($script:uiState.Flags.autoScrollLog) {
|
||||
$script:uiState.Controls.lstLogOutput.ScrollIntoView($line)
|
||||
$script:uiState.Controls.lstLogOutput.SelectedIndex = $script:uiState.Controls.lstLogOutput.Items.Count - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $currentJob -or $null -eq $script:uiState.Data.pollTimer) {
|
||||
if ($null -ne $sender) { $sender.Stop() }
|
||||
$script:uiState.Data.pollTimer = $null
|
||||
return
|
||||
}
|
||||
|
||||
if ($currentJob.State -in 'Completed', 'Failed', 'Stopped') {
|
||||
if ($null -ne $sender) { $sender.Stop() }
|
||||
$script:uiState.Data.pollTimer = $null
|
||||
|
||||
if ($null -ne $script:uiState.Data.logStreamReader) {
|
||||
$lastLine = $null
|
||||
while ($null -ne ($line = $script:uiState.Data.logStreamReader.ReadLine())) {
|
||||
$script:uiState.Data.logData.Add($line)
|
||||
$lastLine = $line
|
||||
}
|
||||
if ($script:uiState.Flags.autoScrollLog -and $null -ne $lastLine) {
|
||||
$script:uiState.Controls.lstLogOutput.ScrollIntoView($lastLine)
|
||||
$script:uiState.Controls.lstLogOutput.SelectedIndex = $script:uiState.Controls.lstLogOutput.Items.Count - 1
|
||||
}
|
||||
$script:uiState.Data.logStreamReader.Close()
|
||||
$script:uiState.Data.logStreamReader.Dispose()
|
||||
$script:uiState.Data.logStreamReader = $null
|
||||
}
|
||||
|
||||
$script:uiState.Controls.txtStatus.Text = "Build canceled. Environment cleaned."
|
||||
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
|
||||
$script:uiState.Controls.pbOverallProgress.Value = 0
|
||||
|
||||
# Receive and remove cleanup job
|
||||
$currentJob | Receive-Job -ErrorAction SilentlyContinue | Out-Null
|
||||
Remove-Job -Job $currentJob -Force
|
||||
$script:uiState.Data.currentBuildJob = $null
|
||||
|
||||
# Reset flags and button
|
||||
$script:uiState.Flags.isCleanupRunning = $false
|
||||
$script:uiState.Flags.isBuilding = $false
|
||||
$btn = $script:uiState.Controls.btnRun
|
||||
$btn.Content = "Build FFU"
|
||||
$btn.IsEnabled = $true
|
||||
}
|
||||
})
|
||||
|
||||
$script:uiState.Data.pollTimer.Start()
|
||||
return
|
||||
}
|
||||
|
||||
# Not currently building: start a new build
|
||||
$btnRun.IsEnabled = $false
|
||||
|
||||
# Switch to Monitor Tab
|
||||
@@ -153,6 +394,7 @@ $script:uiState.Controls.btnRun.Add_Click({
|
||||
$config = Get-UIConfig -State $script:uiState
|
||||
$configFilePath = Join-Path $config.FFUDevelopmentPath "\config\FFUConfig.json"
|
||||
$config | ConvertTo-Json -Depth 10 | Set-Content -Path $configFilePath -Encoding UTF8
|
||||
$script:uiState.Data.lastConfigFilePath = $configFilePath
|
||||
|
||||
if ($config.InstallOffice -and $config.OfficeConfigXMLFile) {
|
||||
Copy-Item -Path $config.OfficeConfigXMLFile -Destination $config.OfficePath -Force
|
||||
@@ -283,25 +525,21 @@ $script:uiState.Controls.btnRun.Add_Click({
|
||||
$script:uiState.Data.logStreamReader = $null
|
||||
}
|
||||
|
||||
# Determine final status based on job result and whether cleanup was running (should be false here)
|
||||
$finalStatusText = "FFU build completed successfully."
|
||||
if ($currentJob.State -eq 'Failed') {
|
||||
$reason = $null
|
||||
|
||||
# Use Receive-Job with -ErrorVariable to reliably capture the error stream from the job,
|
||||
# as suggested by the research on handling job errors.
|
||||
Receive-Job -Job $currentJob -Keep -ErrorVariable jobErrors -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
if ($null -ne $jobErrors -and $jobErrors.Count -gt 0) {
|
||||
# The terminating error is typically the last one in the stream.
|
||||
$reason = ($jobErrors | Select-Object -Last 1).ToString()
|
||||
}
|
||||
|
||||
# If Receive-Job didn't surface an error, fall back to the JobStateInfo.Reason property.
|
||||
if ([string]::IsNullOrWhiteSpace($reason) -and $currentJob.JobStateInfo.Reason) {
|
||||
$reason = $currentJob.JobStateInfo.Reason.Message
|
||||
}
|
||||
|
||||
# Final fallback if no specific reason can be found.
|
||||
if ([string]::IsNullOrWhiteSpace($reason)) {
|
||||
$reason = "An unknown error occurred. The job failed without a specific reason."
|
||||
}
|
||||
@@ -318,19 +556,27 @@ $script:uiState.Controls.btnRun.Add_Click({
|
||||
|
||||
# Update UI elements
|
||||
$script:uiState.Controls.txtStatus.Text = $finalStatusText
|
||||
$script:uiState.Controls.btnRun.IsEnabled = $true
|
||||
|
||||
# Clean up the job object
|
||||
# Receive & remove job and clear state
|
||||
$currentJob | Receive-Job -ErrorAction SilentlyContinue | Out-Null
|
||||
Remove-Job -Job $currentJob -Force
|
||||
|
||||
# Clear the job from the state
|
||||
$script:uiState.Data.currentBuildJob = $null
|
||||
|
||||
# Reset button and flags for next run
|
||||
$script:uiState.Flags.isBuilding = $false
|
||||
$script:uiState.Flags.isCleanupRunning = $false
|
||||
$script:uiState.Controls.btnRun.Content = "Build FFU"
|
||||
$script:uiState.Controls.btnRun.IsEnabled = $true
|
||||
}
|
||||
})
|
||||
|
||||
# Start the timer
|
||||
$script:uiState.Data.pollTimer.Start()
|
||||
|
||||
# Mark building and toggle button to Cancel
|
||||
$script:uiState.Flags.isBuilding = $true
|
||||
$btnRun.Content = "Cancel"
|
||||
$btnRun.IsEnabled = $true
|
||||
}
|
||||
catch {
|
||||
# This catch block handles errors during the setup of the job (e.g., Get-UIConfig fails)
|
||||
@@ -350,6 +596,9 @@ $script:uiState.Controls.btnRun.Add_Click({
|
||||
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
|
||||
if ($null -ne $script:uiState.Controls.btnRun) {
|
||||
$script:uiState.Controls.btnRun.IsEnabled = $true
|
||||
$script:uiState.Controls.btnRun.Content = "Build FFU"
|
||||
$script:uiState.Flags.isBuilding = $false
|
||||
$script:uiState.Flags.isCleanupRunning = $false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -221,7 +221,6 @@
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" Margin="0,5,0,0">
|
||||
<StackPanel x:Name="stackFeaturesContainer" Margin="15,5">
|
||||
<TextBlock Text="Selected features (semicolon):" Margin="0,10,0,5" FontStyle="Italic"/>
|
||||
<TextBox x:Name="txtOptionalFeatures" IsReadOnly="True" Width="350" Margin="0,0,0,10" ToolTip="Provide a semicolon-separated list of Windows optional features you want to include in the FFU (e.g., netfx3;TFTP)."/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Expander>
|
||||
@@ -373,6 +372,13 @@
|
||||
<Button x:Name="btnBrowseAppSource" Grid.Column="1" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Additional Exit Codes -->
|
||||
<TextBlock Text="Additional Exit Codes:" Margin="0,0,0,5"/>
|
||||
<TextBox x:Name="txtAppAdditionalExitCodes" Margin="0,0,0,10" ToolTip="Enter a comma-separated list of additional success exit codes."/>
|
||||
|
||||
<!-- Ignore Non-Zero Exit Codes Checkbox -->
|
||||
<CheckBox x:Name="chkIgnoreExitCodes" Content="Ignore all non-zero exit codes" Margin="0,0,0,10" ToolTip="If checked, any non-zero exit code will be considered a success."/>
|
||||
|
||||
<!-- Add Application Button -->
|
||||
<Button x:Name="btnAddApplication" Content="Add Application" Width="120" HorizontalAlignment="Left" Margin="0,10,0,10" Padding="10,5" ToolTip="Add the application to the list"/>
|
||||
|
||||
@@ -385,25 +391,6 @@
|
||||
|
||||
<!-- Applications ListView -->
|
||||
<ListView x:Name="lstApplications" Grid.Column="0" Height="200">
|
||||
<ListView.View>
|
||||
<GridView>
|
||||
<GridViewColumn Header="Priority" DisplayMemberBinding="{Binding Priority}" Width="60"/>
|
||||
<GridViewColumn Header="Name" DisplayMemberBinding="{Binding Name}" Width="150"/>
|
||||
<GridViewColumn Header="Command Line" DisplayMemberBinding="{Binding CommandLine}" Width="200"/>
|
||||
<GridViewColumn Header="Arguments" DisplayMemberBinding="{Binding Arguments}" Width="200"/>
|
||||
<GridViewColumn Header="Source" DisplayMemberBinding="{Binding Source}" Width="150"/>
|
||||
<GridViewColumn Header="Copy Status" DisplayMemberBinding="{Binding CopyStatus}" Width="150"/>
|
||||
<GridViewColumn Header="Action" Width="85">
|
||||
<GridViewColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Grid HorizontalAlignment="Stretch">
|
||||
<Button Content="Remove" Tag="{Binding Priority}" Width="70" HorizontalAlignment="Center" ToolTip="Remove this application from the list and reorder priorities"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</GridViewColumn.CellTemplate>
|
||||
</GridViewColumn>
|
||||
</GridView>
|
||||
</ListView.View>
|
||||
</ListView>
|
||||
|
||||
<!-- Reorder Buttons -->
|
||||
@@ -420,7 +407,9 @@
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left" Margin="0,0,0,10">
|
||||
<Button x:Name="btnSaveBYOApplications" Content="Save UserAppList.json" Margin="0,0,10,0" Padding="10,5" ToolTip="Save application list to JSON file"/>
|
||||
<Button x:Name="btnLoadBYOApplications" Content="Import UserAppList.json" Margin="0,0,10,0" Padding="10,5" ToolTip="Import application list from JSON file"/>
|
||||
<Button x:Name="btnEditApplication" Content="Edit Application" IsEnabled="False" Margin="0,0,10,0" Padding="10,5" ToolTip="Edit the selected application's details"/>
|
||||
<Button x:Name="btnCopyBYOApps" Content="Copy Apps" IsEnabled="False" Margin="0,0,10,0" Padding="10,5" ToolTip="Copy applications with a specified source path to the AppsPath\Win32 folder"/>
|
||||
<Button x:Name="btnRemoveSelectedBYOApps" Content="Remove Selected" IsEnabled="False" Margin="0,0,10,0" Padding="10,5" ToolTip="Remove selected applications from the list"/>
|
||||
<Button x:Name="btnClearBYOApplications" Content="Clear List" Padding="10,5" ToolTip="Clear all applications from the list"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
@@ -751,6 +740,7 @@
|
||||
<CheckBox x:Name="chkAllowVHDXCaching" Content="Allow VHDX Caching" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will cache the VHDX file to cache folder and create a config json file to track Windows build information."/>
|
||||
<CheckBox x:Name="chkCreateCaptureMedia" Content="Create Capture Media" Margin="5" VerticalAlignment="Center" Tag="When set to $true, this will create WinPE capture media for use when InstallApps is set to $true."/>
|
||||
<CheckBox x:Name="chkCreateDeploymentMedia" Content="Create Deployment Media" Margin="5" VerticalAlignment="Center" Tag="When set to $true, this will create WinPE deployment media for use when deploying to a physical device."/>
|
||||
<CheckBox x:Name="chkInjectUnattend" Content="Inject Unattend.xml" Margin="5" VerticalAlignment="Center" Tag="When set to $true and Install Apps is enabled, copies unattend_[arch].xml from $FFUDevelopmentPath\unattend into Apps\Unattend\Unattend.xml to be used by sysprep."/>
|
||||
<CheckBox x:Name="chkVerbose" Content="Verbose" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will enable write-verbose output to the console for the build script."/>
|
||||
</WrapPanel>
|
||||
|
||||
@@ -766,6 +756,12 @@
|
||||
<CheckBox x:Name="chkCopyUnattend" Content="Copy Unattend.xml" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will copy the Unattend.xml file to the USB drive."/>
|
||||
<CheckBox x:Name="chkCopyPPKG" Content="Copy Provisioning Package" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will copy the provisioning package to the USB drive."/>
|
||||
|
||||
<!-- Max USB Drives -->
|
||||
<StackPanel Orientation="Horizontal" Margin="5">
|
||||
<TextBlock Text="Max USB Drives" VerticalAlignment="Center" ToolTip="Maximum number of USB drives to build at once. Enter 0 to process all discovered (or all selected) drives."/>
|
||||
<TextBox x:Name="txtMaxUSBDrives" Width="50" Margin="10,0,0,0" Text="5" VerticalAlignment="Center" ToolTip="Maximum number of USB drives to build at once. Enter 0 to process all discovered (or all selected) drives."/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- USB Drive Selection Section -->
|
||||
<Grid x:Name="usbDriveSelectionPanel" Margin="5,0,0,0">
|
||||
<Grid.RowDefinitions>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Start & Initialization"
|
||||
A[Start] --> B{Load ConfigFile?};
|
||||
B --> C[Process Parameters];
|
||||
C --> D{Validate Parameters};
|
||||
D --> E{"dirty.txt exists?"};
|
||||
E -- Yes --> F[Run Cleanup Routine];
|
||||
F --> G["Create new dirty.txt"];
|
||||
E -- No --> G;
|
||||
end
|
||||
|
||||
G --> H{"-InstallDrivers or -CopyDrivers?"};
|
||||
|
||||
subgraph "Pre-Build Preparations"
|
||||
H -- Yes --> I{Driver Source?};
|
||||
I -- "-DriversJsonPath" --> J[Download Drivers via JSON in Parallel];
|
||||
I -- "-Make and -Model" --> K[Download Drivers for specific Make/Model];
|
||||
I -- "Local Folder" --> L[Use Existing Drivers in Drivers Folder];
|
||||
|
||||
subgraph "ADK & WinPE"
|
||||
M[Check for ADK & WinPE Add-on];
|
||||
M --> N{Latest Version Installed?};
|
||||
N -- No --> O[Uninstall Old & Install Latest ADK/WinPE];
|
||||
N -- Yes --> P[Get ADK Path];
|
||||
O --> P;
|
||||
end
|
||||
|
||||
Q{"-InstallApps?"};
|
||||
subgraph "Application & In-VM Content Preparation"
|
||||
direction LR
|
||||
R[Check for existing downloaded apps];
|
||||
R --> S{Download missing WinGet apps};
|
||||
S --> T{"-InstallOffice?"};
|
||||
T -- Yes --> U[Download ODT & Office content];
|
||||
T -- No --> V[Continue];
|
||||
U --> V;
|
||||
V --> W["Download in-VM updates: Defender, MSRT, etc."];
|
||||
W --> X["Create Apps.iso"];
|
||||
end
|
||||
end
|
||||
|
||||
J --> M;
|
||||
K --> M;
|
||||
L --> M;
|
||||
H -- No --> M;
|
||||
P --> Q;
|
||||
Q -- Yes --> R;
|
||||
X --> Y;
|
||||
Q -- No --> Y{"-AllowVHDXCaching?"};
|
||||
|
||||
subgraph "VHDX Management"
|
||||
Y -- Yes --> Z[Check for matching cached VHDX];
|
||||
Z --> AA{Cache Hit?};
|
||||
AA -- Yes --> AB[Use Cached VHDX];
|
||||
AA -- No --> AC[Create New VHDX];
|
||||
Y -- No --> AC;
|
||||
|
||||
subgraph "VHDX Creation Workflow"
|
||||
AC --> AD{ISOPath provided?};
|
||||
AD -- No --> AE[Download Windows ESD media];
|
||||
AD -- Yes --> AF[Use provided ISO];
|
||||
AE --> AG[Create & Partition VHDX];
|
||||
AF --> AG;
|
||||
AG --> AH[Apply Base Windows Image to VHDX];
|
||||
AH --> AI{"Updates specified? (CU, dotNET, etc.)"};
|
||||
AI -- Yes --> AJ[Apply Updates to Offline VHDX];
|
||||
AJ --> AK[Run Component Cleanup];
|
||||
AI -- No --> AK;
|
||||
AK --> AL{"Optional Features specified?"};
|
||||
AL -- Yes --> AM[Enable Optional Features];
|
||||
AL -- No --> AN[Finalize VHDX Setup];
|
||||
AM --> AN;
|
||||
AN --> AO{"-AllowVHDXCaching?"};
|
||||
AO -- Yes --> AP[Optimize and Copy VHDX to Cache];
|
||||
AO -- No --> AQ[Continue];
|
||||
AP --> AQ;
|
||||
end
|
||||
end
|
||||
|
||||
AB --> BA;
|
||||
AQ --> BA{"-InstallApps?"};
|
||||
|
||||
subgraph "FFU Creation"
|
||||
subgraph "VM-Based Capture (-InstallApps)"
|
||||
direction LR
|
||||
BB[Create Hyper-V VM from VHDX];
|
||||
BB --> BC["Create WinPE Capture Media iso"];
|
||||
BC --> BD[Configure network share for capture];
|
||||
BD --> BE["Start VM: Boots to Audit Mode"];
|
||||
BE --> BF[Orchestrator runs: Installs apps, syspreps, shuts down];
|
||||
BF --> BG[VM reboots from Capture Media];
|
||||
BG --> BH["CaptureFFU.ps1 runs, saves FFU to share, shuts down"];
|
||||
end
|
||||
|
||||
subgraph "Direct VHDX Capture"
|
||||
BI[Capture FFU directly from VHDX using DISM];
|
||||
end
|
||||
end
|
||||
|
||||
BA -- Yes --> BB;
|
||||
BA -- No --> BI;
|
||||
|
||||
subgraph "Post-Processing & Media Creation"
|
||||
BK{"-InstallDrivers?"};
|
||||
BK -- Yes --> BL[Mount FFU & Inject Drivers];
|
||||
BK -- No --> BM[Continue];
|
||||
BL --> BM;
|
||||
BM --> BN{"-Optimize?"};
|
||||
BN -- Yes --> BO[Optimize FFU using DISM];
|
||||
BN -- No --> BP[Continue];
|
||||
BO --> BP;
|
||||
BP --> BQ{"-BuildUSBDrive?"};
|
||||
BQ -- Yes --> BR[Create WinPE Deployment Media];
|
||||
BR --> BS["Partition USB Drive(s)"];
|
||||
BS --> BT[Copy FFU, Deploy scripts & other assets to USB];
|
||||
BQ -- No --> BU[Continue];
|
||||
BT --> BU;
|
||||
end
|
||||
|
||||
BH --> BK;
|
||||
BI --> BK;
|
||||
|
||||
subgraph "Final Cleanup"
|
||||
BU --> BV[Cleanup VM, VHDX, temp files];
|
||||
BV --> BW["Remove dirty.txt"];
|
||||
BW --> BX[End];
|
||||
end
|
||||
@@ -163,6 +163,7 @@ function Invoke-ParallelProcessing {
|
||||
AppsPath = $localJobArgs['AppsPath']
|
||||
OrchestrationPath = $localJobArgs['OrchestrationPath']
|
||||
ProgressQueue = $localProgressQueue
|
||||
WindowsArch = $localJobArgs['WindowsArch']
|
||||
}
|
||||
$taskResult = Start-WingetAppDownloadTask @wingetTaskArgs
|
||||
if ($null -ne $taskResult) {
|
||||
|
||||
@@ -20,9 +20,11 @@ function Get-Application {
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$AppsPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ApplicationArch,
|
||||
[string]$WindowsArch,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OrchestrationPath
|
||||
[string]$OrchestrationPath,
|
||||
[switch]$SkipWin32Json
|
||||
)
|
||||
|
||||
# Block Company Portal from winget source
|
||||
@@ -48,6 +50,29 @@ function Get-Application {
|
||||
# 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."
|
||||
|
||||
# Add silent install command(s) only if not skipping JSON generation (build-time scenario)
|
||||
$appIsWin32Existing = ($Source -eq 'winget' -or ($Source -eq 'msstore' -and $AppId.StartsWith('XP')))
|
||||
if ($appIsWin32Existing -and -not $SkipWin32Json) {
|
||||
$win32BasePath = Join-Path -Path "$AppsPath\Win32" -ChildPath $AppName
|
||||
if (Test-Path -Path $win32BasePath -PathType Container) {
|
||||
$archFolders = Get-ChildItem -Path $win32BasePath -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -in @('x86', 'x64', 'arm64') }
|
||||
if ($archFolders) {
|
||||
foreach ($archFolder in $archFolders) {
|
||||
WriteLog "Adding silent install command for pre-downloaded $AppName ($($archFolder.Name)) to $OrchestrationPath\WinGetWin32Apps.json"
|
||||
Add-Win32SilentInstallCommand -AppFolder $AppName -AppFolderPath $archFolder.FullName -OrchestrationPath $OrchestrationPath -SubFolder $archFolder.Name | Out-Null
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Adding silent install command for pre-downloaded $AppName to $OrchestrationPath\WinGetWin32Apps.json"
|
||||
Add-Win32SilentInstallCommand -AppFolder $AppName -AppFolderPath $win32BasePath -OrchestrationPath $OrchestrationPath | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($appIsWin32Existing -and $SkipWin32Json) {
|
||||
WriteLog "Skipping WinGetWin32Apps.json regeneration for pre-downloaded $AppName (UI mode)."
|
||||
}
|
||||
|
||||
return 0 # Success, already present
|
||||
}
|
||||
}
|
||||
@@ -64,8 +89,8 @@ function Get-Application {
|
||||
return 1 # Return error code
|
||||
}
|
||||
|
||||
# Determine architectures to download
|
||||
$architecturesToDownload = if ($WindowsArch -eq 'x86 x64') { @('x86', 'x64') } else { @($WindowsArch) }
|
||||
# Determine architectures to download (ApplicationArch controls download set; WindowsArch (optional) used later for pruning store installers)
|
||||
$architecturesToDownload = if ($ApplicationArch -eq 'x86 x64') { @('x86', 'x64') } else { @($ApplicationArch) }
|
||||
$overallResult = 0
|
||||
|
||||
# For msstore, we don't specify architecture, so we only need to loop once.
|
||||
@@ -196,9 +221,15 @@ function Get-Application {
|
||||
}
|
||||
# If app is in Win32 folder, add the silent install command to the WinGetWin32Apps.json file
|
||||
elseif ($appFolderPath -match 'Win32') {
|
||||
if (-not $SkipWin32Json) {
|
||||
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 {
|
||||
WriteLog "$AppName is a Win32 app. Skipping WinGetWin32Apps.json generation (UI mode)."
|
||||
$result = 0
|
||||
}
|
||||
}
|
||||
else {
|
||||
# For any other case, set result to 0 (success)
|
||||
$result = 0
|
||||
@@ -222,15 +253,52 @@ function Get-Application {
|
||||
}
|
||||
}
|
||||
|
||||
# Clean up multiple versions (keep only the latest)
|
||||
WriteLog "$AppName has completed downloading. Identifying the latest version of $AppName."
|
||||
# Clean up multiple versions honoring WindowsArch (pruning target; keep only one installer)
|
||||
WriteLog "$AppName has completed downloading. Evaluating installer set for pruning."
|
||||
$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."
|
||||
if ($packages.Count -gt 1 -and $WindowsArch) {
|
||||
WriteLog "WindowsArch pruning target provided: $WindowsArch"
|
||||
# Detect universal bundles (contain x86,x64,arm64 in name)
|
||||
$universalCandidates = $packages | Where-Object {
|
||||
$base = $_.BaseName
|
||||
# Split base name into tokens to avoid partial matches (e.g. arm inside arm64)
|
||||
$tokens = ($base -split '[\.\-_]') | ForEach-Object { $_.ToLower() }
|
||||
# Architecture tokens we recognize
|
||||
$archTokens = @('x86', 'x64', 'arm', 'arm64')
|
||||
# Distinct matched architecture tokens
|
||||
$matched = $tokens | Where-Object { $_ -in $archTokens } | Select-Object -Unique
|
||||
if ($matched.Count -ge 2) {
|
||||
WriteLog "Multi-architecture bundle detected: $base (tokens: $($matched -join ', '))"
|
||||
$true
|
||||
}
|
||||
else {
|
||||
$false
|
||||
}
|
||||
}
|
||||
if ($universalCandidates) {
|
||||
WriteLog "Universal bundle candidate(s) detected: $($universalCandidates.Name -join ', ')"
|
||||
$candidateSet = $universalCandidates
|
||||
}
|
||||
else {
|
||||
$archToken = switch -Regex ($WindowsArch.ToLower()) {
|
||||
'^x64$' { 'x64' ; break }
|
||||
'^x86$' { 'x86' ; break }
|
||||
'^arm64$' { 'arm64' ; break }
|
||||
default { $WindowsArch.ToLower() }
|
||||
}
|
||||
$archMatches = $packages | Where-Object { $_.BaseName -match "(?i)$archToken" }
|
||||
if ($archMatches) {
|
||||
WriteLog "Architecture-specific candidates matching '$archToken': $($archMatches.Name -join ', ')"
|
||||
$candidateSet = $archMatches
|
||||
}
|
||||
else {
|
||||
WriteLog "No installer filename matched '$archToken'. Falling back to all installers."
|
||||
$candidateSet = $packages
|
||||
}
|
||||
}
|
||||
# From candidate set, choose latest by signature date
|
||||
$latestPackage = $candidateSet | Sort-Object { (Get-AuthenticodeSignature $_.FullName).SignerCertificate.NotBefore } -Descending | Select-Object -First 1
|
||||
WriteLog "Retaining installer: $($latestPackage.Name)"
|
||||
foreach ($package in $packages) {
|
||||
if ($package.FullName -ne $latestPackage.FullName) {
|
||||
try {
|
||||
@@ -244,6 +312,27 @@ function Get-Application {
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($packages.Count -gt 1) {
|
||||
WriteLog "Multiple installers present but no WindowsArch pruning target supplied. Using original latest-version logic."
|
||||
$latestPackage = $packages | Sort-Object { (Get-AuthenticodeSignature $_.FullName).SignerCertificate.NotBefore } -Descending | Select-Object -First 1
|
||||
WriteLog "Retaining latest by signature date: $($latestPackage.Name)"
|
||||
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 $_
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Single installer present; no pruning required."
|
||||
}
|
||||
}
|
||||
} # End foreach ($arch in $architecturesToDownload)
|
||||
|
||||
return $overallResult
|
||||
@@ -300,7 +389,7 @@ function Get-Apps {
|
||||
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
|
||||
Get-Application -AppName $wingetApp.Name -AppId $wingetApp.Id -Source 'winget' -AppsPath $AppsPath -ApplicationArch $appArch -OrchestrationPath $OrchestrationPath
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error occurred while processing $($wingetApp.Name): $_"
|
||||
@@ -318,7 +407,7 @@ function Get-Apps {
|
||||
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
|
||||
Get-Application -AppName $storeApp.Name -AppId $storeApp.Id -Source 'msstore' -AppsPath $AppsPath -ApplicationArch $appArch -WindowsArch $WindowsArch -OrchestrationPath $OrchestrationPath
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error occurred while processing $($storeApp.Name): $_"
|
||||
@@ -326,6 +415,62 @@ function Get-Apps {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Post-processing: Override CommandLine / Arguments from AppList.json if provided
|
||||
# Users may supply custom silent install commands or arguments. These optional
|
||||
# properties (CommandLine, Arguments) in AppList.json replace the auto-generated
|
||||
# values in WinGetWin32Apps.json. Keyed by Name.
|
||||
try {
|
||||
$overrideMap = @{}
|
||||
foreach ($app in $apps.apps) {
|
||||
if ($app.source -in @('winget', 'msstore')) {
|
||||
$hasCmd = ($app.PSObject.Properties['CommandLine'] -and -not [string]::IsNullOrWhiteSpace($app.CommandLine))
|
||||
$hasArgs = ($app.PSObject.Properties['Arguments'] -and -not [string]::IsNullOrWhiteSpace($app.Arguments))
|
||||
if ($hasCmd -or $hasArgs) {
|
||||
$overrideMap[$app.name] = @{
|
||||
CommandLine = if ($hasCmd) { $app.CommandLine } else { $null }
|
||||
Arguments = if ($hasArgs) { $app.Arguments } else { $null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($overrideMap.Count -gt 0) {
|
||||
$winGetWin32Path = Join-Path -Path $OrchestrationPath -ChildPath 'WinGetWin32Apps.json'
|
||||
if (Test-Path -Path $winGetWin32Path) {
|
||||
[array]$appsDataUpdated = Get-Content -Path $winGetWin32Path -Raw | ConvertFrom-Json
|
||||
$changed = $false
|
||||
foreach ($entry in $appsDataUpdated) {
|
||||
if ($overrideMap.ContainsKey($entry.Name)) {
|
||||
$ov = $overrideMap[$entry.Name]
|
||||
if ($ov.CommandLine) {
|
||||
WriteLog "Override (AppList.json) CommandLine for $($entry.Name)"
|
||||
$entry.CommandLine = $ov.CommandLine
|
||||
$changed = $true
|
||||
}
|
||||
if ($ov.Arguments) {
|
||||
WriteLog "Override (AppList.json) Arguments for $($entry.Name)"
|
||||
$entry.Arguments = $ov.Arguments
|
||||
$changed = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($changed) {
|
||||
$appsDataUpdated | ConvertTo-Json -Depth 10 | Set-Content -Path $winGetWin32Path
|
||||
WriteLog "Applied AppList.json command overrides to WinGetWin32Apps.json"
|
||||
}
|
||||
else {
|
||||
WriteLog "No matching apps required command overrides."
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "WinGetWin32Apps.json not found; no overrides applied."
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to apply AppList.json command overrides: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
function Install-WinGet {
|
||||
param (
|
||||
@@ -409,33 +554,144 @@ function Add-Win32SilentInstallCommand {
|
||||
[string]$SubFolder
|
||||
)
|
||||
$appName = $AppFolder
|
||||
$installerPath = Get-ChildItem -Path "$appFolderPath\*" -Include "*.exe", "*.msi" -File -ErrorAction Stop
|
||||
if (-not $installerPath) {
|
||||
|
||||
# Discover installer candidates (top-level files as before)
|
||||
$installerCandidates = Get-ChildItem -Path "$appFolderPath\*" -Include "*.exe", "*.msi" -File -ErrorAction SilentlyContinue
|
||||
if (-not $installerCandidates) {
|
||||
WriteLog "No win32 app installers were found. Skipping the inclusion of $AppFolder"
|
||||
Remove-Item -Path $AppFolderPath -Recurse -Force
|
||||
return 1
|
||||
}
|
||||
|
||||
# Read the exported WinGet YAML
|
||||
$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()
|
||||
$yamlText = Get-Content -Path $yamlFile -Raw
|
||||
|
||||
# Attempt to resolve the correct installer from YAML NestedInstallerFiles within the matching Architecture block
|
||||
$desiredArch = if (-not [string]::IsNullOrEmpty($SubFolder)) { $SubFolder } else { $null }
|
||||
$relativeFromYaml = $null
|
||||
$blockSilent = $null
|
||||
|
||||
$regexOptions = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
|
||||
$pattern = '-\s+Architecture:\s*(?<arch>\S+)[\s\S]*?NestedInstallerFiles:\s*-\s*RelativeFilePath:\s*(?<path>.+?)\r?\n'
|
||||
$yamlMatches = [regex]::Matches($yamlText, $pattern, $regexOptions)
|
||||
|
||||
$selectedMatch = $null
|
||||
if ($yamlMatches.Count -gt 0) {
|
||||
if ($desiredArch) {
|
||||
foreach ($m in $yamlMatches) {
|
||||
if ($m.Groups['arch'].Value -ieq $desiredArch) {
|
||||
$selectedMatch = $m
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $selectedMatch) {
|
||||
$selectedMatch = $yamlMatches[0]
|
||||
}
|
||||
|
||||
$pathValue = $selectedMatch.Groups['path'].Value.Trim()
|
||||
$pathValue = $pathValue.Trim("'").Trim('"')
|
||||
$relativeFromYaml = $pathValue
|
||||
|
||||
# Extract a Silent switch from within the same installer block if present
|
||||
$startIndex = $selectedMatch.Index
|
||||
$nextIndex = -1
|
||||
for ($i = 0; $i -lt $yamlMatches.Count; $i++) {
|
||||
if ($yamlMatches[$i].Index -gt $startIndex) {
|
||||
$nextIndex = $yamlMatches[$i].Index
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($nextIndex -gt -1) {
|
||||
$blockText = $yamlText.Substring($startIndex, $nextIndex - $startIndex)
|
||||
}
|
||||
else {
|
||||
$blockText = $yamlText.Substring($startIndex)
|
||||
}
|
||||
$blockSilentMatch = [regex]::Match($blockText, 'InstallerSwitches:[\s\S]*?Silent:\s*(.+?)\r?\n', $regexOptions)
|
||||
if ($blockSilentMatch.Success) {
|
||||
$blockSilent = $blockSilentMatch.Groups[1].Value.Trim().Trim("'").Trim('"')
|
||||
}
|
||||
}
|
||||
|
||||
# Resolve Silent switch (prefer block-level, fallback to first Silent in file)
|
||||
$silentInstallSwitch = $blockSilent
|
||||
if ([string]::IsNullOrEmpty($silentInstallSwitch)) {
|
||||
$globalSilentMatch = [regex]::Match($yamlText, 'Silent:\s*(.+)', $regexOptions)
|
||||
$silentInstallSwitch = $globalSilentMatch.Groups[1].Value.Trim().Trim("'").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
|
||||
|
||||
# Choose final installer path and extension
|
||||
$resolvedRelativePath = $null
|
||||
$installerExt = $null
|
||||
|
||||
if ($installerCandidates.Count -eq 1 -and -not $relativeFromYaml) {
|
||||
# Single installer – keep current behavior
|
||||
$resolvedRelativePath = $installerCandidates[0].Name
|
||||
$installerExt = $installerCandidates[0].Extension
|
||||
WriteLog "Single installer detected ($resolvedRelativePath). Using current behavior."
|
||||
}
|
||||
else {
|
||||
if ($relativeFromYaml) {
|
||||
$normalizedPath = ($relativeFromYaml -replace '/', '\')
|
||||
$resolvedRelativePath = $normalizedPath
|
||||
$installerExt = [System.IO.Path]::GetExtension($normalizedPath)
|
||||
if ([string]::IsNullOrEmpty($installerExt)) {
|
||||
$leafName = [System.IO.Path]::GetFileName($normalizedPath)
|
||||
$matchedCandidate = $installerCandidates | Where-Object { $_.Name -ieq $leafName } | Select-Object -First 1
|
||||
if ($matchedCandidate) {
|
||||
$installerExt = $matchedCandidate.Extension
|
||||
}
|
||||
}
|
||||
WriteLog "Multiple installers found. Selected by YAML NestedInstallerFiles: $resolvedRelativePath"
|
||||
}
|
||||
if (-not $resolvedRelativePath) {
|
||||
# Fallbacks when YAML lacks NestedInstallerFiles or couldn't be matched
|
||||
$msis = $installerCandidates | Where-Object { $_.Extension -ieq ".msi" }
|
||||
if ($msis.Count -eq 1) {
|
||||
$resolvedRelativePath = $msis[0].Name
|
||||
$installerExt = ".msi"
|
||||
WriteLog "Multiple installers found. YAML not used. Falling back to single MSI: $resolvedRelativePath"
|
||||
}
|
||||
else {
|
||||
$exes = $installerCandidates | Where-Object { $_.Extension -ieq ".exe" }
|
||||
if ($exes.Count -eq 1) {
|
||||
$resolvedRelativePath = $exes[0].Name
|
||||
$installerExt = ".exe"
|
||||
WriteLog "Multiple installers found. YAML not used. Falling back to single EXE: $resolvedRelativePath"
|
||||
}
|
||||
else {
|
||||
$first = $installerCandidates | Select-Object -First 1
|
||||
$resolvedRelativePath = $first.Name
|
||||
$installerExt = $first.Extension
|
||||
WriteLog "Multiple installers found and ambiguous. Selecting the first candidate: $resolvedRelativePath"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$basePath = "D:\win32\$AppFolder"
|
||||
if (-not [string]::IsNullOrEmpty($SubFolder)) {
|
||||
$basePath = "$basePath\$SubFolder"
|
||||
}
|
||||
|
||||
if ($installerPath.Extension -eq ".exe") {
|
||||
$silentInstallCommand = "$basePath\$installer"
|
||||
# Build final command/arguments
|
||||
if ($installerExt -ieq ".exe") {
|
||||
$silentInstallCommand = "$basePath\$resolvedRelativePath"
|
||||
}
|
||||
elseif ($installerPath.Extension -eq ".msi") {
|
||||
elseif ($installerExt -ieq ".msi") {
|
||||
$silentInstallCommand = "msiexec"
|
||||
$silentInstallSwitch = "/i `"$basePath\$installer`" $silentInstallSwitch"
|
||||
$silentInstallSwitch = "/i `"$basePath\$resolvedRelativePath`" $silentInstallSwitch"
|
||||
}
|
||||
else {
|
||||
# Default path usage if extension could not be inferred
|
||||
$silentInstallCommand = "$basePath\$resolvedRelativePath"
|
||||
}
|
||||
|
||||
# Path to the JSON file
|
||||
|
||||
@@ -5,6 +5,85 @@
|
||||
This module contains all the functions that power the "Applications" tab in the BuildFFUVM_UI. It handles user interactions for managing custom application lists (BYO Apps), such as adding, removing, reordering, and saving/loading the list from a JSON file (UserAppList.json). It also includes the logic for copying the application source files to the designated staging directory in parallel. Additionally, it manages the UI for creating and removing key-value pairs for the AppsScriptVariables.json file, which allows for custom parameterization of user-provided scripts.
|
||||
#>
|
||||
|
||||
# Function to update the enabled state of BYO Apps action buttons based on selection
|
||||
function Update-BYOAppsActionButtonsState {
|
||||
param(
|
||||
[psobject]$State
|
||||
)
|
||||
$listView = $State.Controls.lstApplications
|
||||
$removeButton = $State.Controls.btnRemoveSelectedBYOApps
|
||||
$editButton = $State.Controls.btnEditApplication
|
||||
|
||||
if ($listView -and $removeButton -and $editButton) {
|
||||
# Count selected items
|
||||
$selectedItems = @($listView.Items | Where-Object { $_.IsSelected })
|
||||
$selectedCount = $selectedItems.Count
|
||||
|
||||
# Enable the remove button if any item is selected
|
||||
$removeButton.IsEnabled = ($selectedCount -gt 0)
|
||||
|
||||
# Enable the edit button only if exactly one item is selected
|
||||
$editButton.IsEnabled = ($selectedCount -eq 1)
|
||||
}
|
||||
}
|
||||
|
||||
# Function to remove all selected BYO applications
|
||||
function Remove-SelectedBYOApplications {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
$listView = $State.Controls.lstApplications
|
||||
$itemsToRemove = @($listView.Items | Where-Object { $_.IsSelected })
|
||||
|
||||
if ($itemsToRemove.Count -eq 0) {
|
||||
# This should not happen if the button is correctly disabled, but as a safeguard:
|
||||
[System.Windows.MessageBox]::Show("No applications are selected for removal.", "Remove Applications", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
|
||||
return
|
||||
}
|
||||
|
||||
# Check if the item being edited is among those being removed
|
||||
if ($null -ne $State.Data.editingBYOApplication -and $itemsToRemove.Contains($State.Data.editingBYOApplication)) {
|
||||
# Reset the edit state
|
||||
$State.Data.editingBYOApplication = $null
|
||||
$State.Controls.btnAddApplication.Content = "Add Application"
|
||||
# Clear the form fields
|
||||
$State.Controls.txtAppName.Clear()
|
||||
$State.Controls.txtAppCommandLine.Clear()
|
||||
$State.Controls.txtAppArguments.Clear()
|
||||
$State.Controls.txtAppSource.Clear()
|
||||
$State.Controls.txtAppAdditionalExitCodes.Clear()
|
||||
$State.Controls.chkIgnoreExitCodes.IsChecked = $false
|
||||
}
|
||||
|
||||
foreach ($item in $itemsToRemove) {
|
||||
$listView.Items.Remove($item)
|
||||
}
|
||||
|
||||
# Re-calculate priorities for the remaining items
|
||||
Update-ListViewPriorities -ListView $listView
|
||||
|
||||
# Update button states (Copy and Remove)
|
||||
Update-CopyButtonState -State $State
|
||||
Update-BYOAppsActionButtonsState -State $State
|
||||
|
||||
# Update the header checkbox state
|
||||
$headerChk = $State.Controls.chkSelectAllBYOApps
|
||||
if ($null -ne $headerChk) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $listView -HeaderCheckBox $headerChk
|
||||
}
|
||||
|
||||
# Ask user if they want to save the changes
|
||||
$result = [System.Windows.MessageBox]::Show("The selected applications have been removed from the list. Do you want to save these changes to UserAppList.json now?", "Save Changes", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question)
|
||||
|
||||
if ($result -eq 'Yes') {
|
||||
$userAppListPath = Join-Path -Path $State.Controls.txtApplicationPath.Text -ChildPath 'UserAppList.json'
|
||||
Save-BYOApplicationList -Path $userAppListPath -State $State
|
||||
}
|
||||
}
|
||||
|
||||
# Function to update the enabled state of the Copy Apps button
|
||||
function Update-CopyButtonState {
|
||||
param(
|
||||
@@ -40,6 +119,7 @@ function Remove-Application {
|
||||
Update-ListViewPriorities -ListView $listView
|
||||
# Update the Copy Apps button state
|
||||
Update-CopyButtonState -State $State
|
||||
Update-BYOAppsActionButtonsState -State $State
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,12 +135,44 @@ function Add-BYOApplication {
|
||||
$commandLine = $State.Controls.txtAppCommandLine.Text
|
||||
$arguments = $State.Controls.txtAppArguments.Text
|
||||
$source = $State.Controls.txtAppSource.Text
|
||||
$additionalExitCodes = $State.Controls.txtAppAdditionalExitCodes.Text
|
||||
$ignoreNonZeroExitCodes = $State.Controls.chkIgnoreExitCodes.IsChecked
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($name) -or [string]::IsNullOrWhiteSpace($commandLine)) {
|
||||
[System.Windows.MessageBox]::Show("Please fill in all fields (Name and Command Line)", "Missing Information", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
|
||||
return
|
||||
}
|
||||
$listView = $State.Controls.lstApplications
|
||||
|
||||
# Check if we are in edit mode
|
||||
if ($null -ne $State.Data.editingBYOApplication) {
|
||||
$itemToUpdate = $State.Data.editingBYOApplication
|
||||
|
||||
# Check for duplicate names, excluding the item being edited
|
||||
$existingApp = $listView.Items | Where-Object { $_.Name -eq $name -and $_ -ne $itemToUpdate }
|
||||
if ($existingApp) {
|
||||
[System.Windows.MessageBox]::Show("An application with the name '$name' already exists.", "Duplicate Name", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
|
||||
return
|
||||
}
|
||||
|
||||
# Update the properties of the existing object
|
||||
$itemToUpdate.Name = $name
|
||||
$itemToUpdate.CommandLine = $commandLine
|
||||
$itemToUpdate.Arguments = $arguments
|
||||
$itemToUpdate.Source = $source
|
||||
$itemToUpdate.AdditionalExitCodes = $additionalExitCodes
|
||||
$itemToUpdate.IgnoreNonZeroExitCodes = $ignoreNonZeroExitCodes
|
||||
$itemToUpdate.IgnoreExitCodes = if ($ignoreNonZeroExitCodes) { "Yes" } else { "No" }
|
||||
|
||||
# Refresh the ListView to show the changes
|
||||
$listView.Items.Refresh()
|
||||
|
||||
# Reset state
|
||||
$State.Data.editingBYOApplication = $null
|
||||
$State.Controls.btnAddApplication.Content = "Add Application"
|
||||
}
|
||||
else {
|
||||
# This is a new application
|
||||
# Check for duplicate names
|
||||
$existingApp = $listView.Items | Where-Object { $_.Name -eq $name }
|
||||
if ($existingApp) {
|
||||
@@ -71,13 +183,61 @@ function Add-BYOApplication {
|
||||
if ($listView.Items.Count -gt 0) {
|
||||
$priority = ($listView.Items | Measure-Object -Property Priority -Maximum).Maximum + 1
|
||||
}
|
||||
$application = [PSCustomObject]@{ Priority = $priority; Name = $name; CommandLine = $commandLine; Arguments = $arguments; Source = $source; CopyStatus = "" }
|
||||
$application = [PSCustomObject]@{
|
||||
IsSelected = $false
|
||||
Priority = $priority
|
||||
Name = $name
|
||||
CommandLine = $commandLine
|
||||
Arguments = $arguments
|
||||
Source = $source
|
||||
AdditionalExitCodes = $additionalExitCodes
|
||||
IgnoreNonZeroExitCodes = $ignoreNonZeroExitCodes
|
||||
IgnoreExitCodes = if ($ignoreNonZeroExitCodes) { "Yes" } else { "No" }
|
||||
CopyStatus = ""
|
||||
}
|
||||
$listView.Items.Add($application)
|
||||
}
|
||||
|
||||
# Clear form and update button states for both add and update operations
|
||||
$State.Controls.txtAppName.Text = ""
|
||||
$State.Controls.txtAppCommandLine.Text = ""
|
||||
$State.Controls.txtAppArguments.Text = ""
|
||||
$State.Controls.txtAppSource.Text = ""
|
||||
$State.Controls.txtAppAdditionalExitCodes.Text = ""
|
||||
$State.Controls.chkIgnoreExitCodes.IsChecked = $false
|
||||
Update-CopyButtonState -State $State
|
||||
Update-BYOAppsActionButtonsState -State $State
|
||||
}
|
||||
|
||||
# Function to populate the form for editing a BYO application
|
||||
function Start-EditBYOApplication {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
$listView = $State.Controls.lstApplications
|
||||
$itemToEdit = @($listView.Items | Where-Object { $_.IsSelected }) | Select-Object -First 1
|
||||
|
||||
if ($null -eq $itemToEdit) {
|
||||
[System.Windows.MessageBox]::Show("No application selected or multiple applications selected.", "Edit Error", "OK", "Warning")
|
||||
return
|
||||
}
|
||||
|
||||
# Store the item being edited in the state
|
||||
$State.Data.editingBYOApplication = $itemToEdit
|
||||
|
||||
# Populate the form fields
|
||||
$State.Controls.txtAppName.Text = $itemToEdit.Name
|
||||
$State.Controls.txtAppCommandLine.Text = $itemToEdit.CommandLine
|
||||
$State.Controls.txtAppArguments.Text = $itemToEdit.Arguments
|
||||
$State.Controls.txtAppSource.Text = $itemToEdit.Source
|
||||
$State.Controls.txtAppAdditionalExitCodes.Text = $itemToEdit.AdditionalExitCodes
|
||||
$State.Controls.chkIgnoreExitCodes.IsChecked = $itemToEdit.IgnoreNonZeroExitCodes
|
||||
|
||||
# Change the Add button to Update
|
||||
$State.Controls.btnAddApplication.Content = "Update App"
|
||||
}
|
||||
|
||||
# Function to add a new Apps Script Variable from the UI
|
||||
@@ -160,8 +320,10 @@ function Save-BYOApplicationList {
|
||||
|
||||
try {
|
||||
# Ensure items are sorted by current priority before saving
|
||||
# Exclude CopyStatus when saving and ensure Priority is an integer
|
||||
$applications = $listView.Items | Sort-Object Priority | Select-Object @{N = 'Priority'; E = { [int]$_.Priority } }, Name, CommandLine, Arguments, Source
|
||||
# Exclude UI-only properties (CopyStatus, IgnoreExitCodes) and ensure Priority is an integer
|
||||
$propertiesToSave = 'Priority', 'Name', 'CommandLine', 'Arguments', 'Source', 'AdditionalExitCodes', 'IgnoreNonZeroExitCodes'
|
||||
$applications = $listView.Items | Sort-Object Priority | Select-Object @{N = 'Priority'; E = { [int]$_.Priority } }, Name, CommandLine, Arguments, Source, AdditionalExitCodes, IgnoreNonZeroExitCodes
|
||||
|
||||
$applications | ConvertTo-Json -Depth 5 | Set-Content -Path $Path -Force -Encoding UTF8
|
||||
[System.Windows.MessageBox]::Show("Applications saved successfully to `"$Path`".", "Save Applications", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
|
||||
}
|
||||
@@ -193,14 +355,18 @@ function Import-BYOApplicationList {
|
||||
# Add items and sort by priority from the file
|
||||
$sortedApps = $applications | Sort-Object Priority
|
||||
foreach ($app in $sortedApps) {
|
||||
# Ensure all properties exist, add CopyStatus
|
||||
$ignoreNonZero = if ($app.PSObject.Properties['IgnoreNonZeroExitCodes']) { $app.IgnoreNonZeroExitCodes } else { $false }
|
||||
$appObject = [PSCustomObject]@{
|
||||
Priority = $app.Priority # Keep original priority for now
|
||||
IsSelected = $false
|
||||
Priority = $app.Priority
|
||||
Name = $app.Name
|
||||
CommandLine = $app.CommandLine
|
||||
Arguments = if ($app.PSObject.Properties['Arguments']) { $app.Arguments } else { "" } # Handle missing Arguments
|
||||
Arguments = if ($app.PSObject.Properties['Arguments']) { $app.Arguments } else { "" }
|
||||
Source = $app.Source
|
||||
CopyStatus = "" # Initialize CopyStatus
|
||||
AdditionalExitCodes = if ($app.PSObject.Properties['AdditionalExitCodes']) { $app.AdditionalExitCodes } else { "" }
|
||||
IgnoreNonZeroExitCodes = $ignoreNonZero
|
||||
IgnoreExitCodes = if ($ignoreNonZero) { "Yes" } else { "No" }
|
||||
CopyStatus = ""
|
||||
}
|
||||
$listView.Items.Add($appObject)
|
||||
}
|
||||
@@ -209,7 +375,7 @@ function Import-BYOApplicationList {
|
||||
Update-ListViewPriorities -ListView $listView
|
||||
# Update the Copy Apps button state
|
||||
Update-CopyButtonState -State $State
|
||||
|
||||
Update-BYOAppsActionButtonsState -State $State
|
||||
[System.Windows.MessageBox]::Show("Applications imported successfully from `"$Path`".", "Import Applications", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
|
||||
}
|
||||
catch {
|
||||
|
||||
@@ -38,6 +38,7 @@ function Get-UIConfig {
|
||||
CopyUnattend = $State.Controls.chkCopyUnattend.IsChecked
|
||||
CreateCaptureMedia = $State.Controls.chkCreateCaptureMedia.IsChecked
|
||||
CreateDeploymentMedia = $State.Controls.chkCreateDeploymentMedia.IsChecked
|
||||
InjectUnattend = $State.Controls.chkInjectUnattend.IsChecked
|
||||
CustomFFUNameTemplate = $State.Controls.txtCustomFFUNameTemplate.Text
|
||||
Disksize = [int64]$State.Controls.txtDiskSize.Text * 1GB
|
||||
DownloadDrivers = $State.Controls.chkDownloadDrivers.IsChecked
|
||||
@@ -70,7 +71,7 @@ function Get-UIConfig {
|
||||
OfficeConfigXMLFile = $State.Controls.txtOfficeConfigXMLFilePath.Text
|
||||
OfficePath = $State.Controls.txtOfficePath.Text
|
||||
Optimize = $State.Controls.chkOptimize.IsChecked
|
||||
OptionalFeatures = $State.Controls.txtOptionalFeatures.Text
|
||||
OptionalFeatures = (($State.Controls.featureCheckBoxes.GetEnumerator() | Where-Object { $_.Value.IsChecked } | ForEach-Object { $_.Key } | Sort-Object) -join ';')
|
||||
OrchestrationPath = "$($State.Controls.txtApplicationPath.Text)\Orchestration"
|
||||
PEDriversFolder = $State.Controls.txtPEDriversFolder.Text
|
||||
Processors = [int]$State.Controls.txtProcessors.Text
|
||||
@@ -93,6 +94,7 @@ function Get-UIConfig {
|
||||
USBDriveList = @{}
|
||||
Username = $State.Controls.txtUsername.Text
|
||||
Threads = [int]$State.Controls.txtThreads.Text
|
||||
MaxUSBDrives = [int]$State.Controls.txtMaxUSBDrives.Text
|
||||
Verbose = $State.Controls.chkVerbose.IsChecked
|
||||
VMHostIPAddress = $State.Controls.txtVMHostIPAddress.Text
|
||||
VMLocation = $State.Controls.txtVMLocation.Text
|
||||
@@ -260,6 +262,58 @@ function Invoke-LoadConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
function Select-VMSwitchFromConfig {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$State,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$ConfigContent
|
||||
)
|
||||
|
||||
# Select VM switch based on configuration; fall back to 'Other' with custom name.
|
||||
$combo = $State.Controls.cmbVMSwitchName
|
||||
if ($null -eq $combo) {
|
||||
WriteLog "LoadConfig Error: 'cmbVMSwitchName' control not found."
|
||||
return
|
||||
}
|
||||
|
||||
$configSwitch = $ConfigContent.VMSwitchName
|
||||
if ($null -eq $configSwitch -or [string]::IsNullOrWhiteSpace($configSwitch)) {
|
||||
WriteLog "LoadConfig Info: VMSwitchName in config was empty or null. Leaving selection unchanged."
|
||||
return
|
||||
}
|
||||
|
||||
$itemFound = $false
|
||||
foreach ($item in $combo.Items) {
|
||||
if ($null -ne $item -and $item.ToString().Equals($configSwitch, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$itemFound = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ($itemFound) {
|
||||
$combo.SelectedItem = ($combo.Items | Where-Object { $_.ToString().Equals($configSwitch, [System.StringComparison]::OrdinalIgnoreCase) } | Select-Object -First 1)
|
||||
$State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
|
||||
WriteLog "LoadConfig: Selected existing VM switch '$configSwitch'."
|
||||
}
|
||||
else {
|
||||
# Ensure 'Other' exists
|
||||
$otherExists = $false
|
||||
foreach ($item in $combo.Items) {
|
||||
if ($null -ne $item -and $item.ToString() -eq 'Other') { $otherExists = $true; break }
|
||||
}
|
||||
if (-not $otherExists) { $combo.Items.Add('Other') | Out-Null }
|
||||
|
||||
# Select 'Other' and populate custom name
|
||||
$combo.SelectedItem = 'Other'
|
||||
$State.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
|
||||
$State.Controls.txtCustomVMSwitchName.Text = $configSwitch
|
||||
$State.Data.customVMSwitchName = $configSwitch
|
||||
$State.Data.customVMHostIP = $ConfigContent.VMHostIPAddress
|
||||
WriteLog "LoadConfig: VMSwitchName '$configSwitch' not found. Selected 'Other' and populated custom VM Switch Name textbox."
|
||||
}
|
||||
}
|
||||
|
||||
function Update-UIFromConfig {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
@@ -277,6 +331,7 @@ function Update-UIFromConfig {
|
||||
Set-UIValue -ControlName 'txtShareName' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ShareName' -State $State
|
||||
Set-UIValue -ControlName 'txtUsername' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Username' -State $State
|
||||
Set-UIValue -ControlName 'txtThreads' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Threads' -State $State
|
||||
Set-UIValue -ControlName 'txtMaxUSBDrives' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'MaxUSBDrives' -State $State
|
||||
Set-UIValue -ControlName 'chkBuildUSBDriveEnable' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'BuildUSBDrive' -State $State
|
||||
Set-UIValue -ControlName 'chkCompactOS' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CompactOS' -State $State
|
||||
Set-UIValue -ControlName 'chkUpdateADK' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'UpdateADK' -State $State
|
||||
@@ -286,6 +341,7 @@ function Update-UIFromConfig {
|
||||
Set-UIValue -ControlName 'chkPromptExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'PromptExternalHardDiskMedia' -State $State
|
||||
Set-UIValue -ControlName 'chkCreateCaptureMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CreateCaptureMedia' -State $State
|
||||
Set-UIValue -ControlName 'chkCreateDeploymentMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CreateDeploymentMedia' -State $State
|
||||
Set-UIValue -ControlName 'chkInjectUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'InjectUnattend' -State $State
|
||||
Set-UIValue -ControlName 'chkVerbose' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'Verbose' -State $State
|
||||
|
||||
# USB Drive Modification group (Build Tab)
|
||||
@@ -303,7 +359,7 @@ function Update-UIFromConfig {
|
||||
Set-UIValue -ControlName 'chkRemoveUpdates' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveUpdates' -State $State
|
||||
|
||||
# Hyper-V Settings
|
||||
Set-UIValue -ControlName 'cmbVMSwitchName' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'VMSwitchName' -State $State
|
||||
Select-VMSwitchFromConfig -State $State -ConfigContent $ConfigContent
|
||||
Set-UIValue -ControlName 'txtVMHostIPAddress' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'VMHostIPAddress' -State $State
|
||||
Set-UIValue -ControlName 'txtDiskSize' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Disksize' -TransformValue { param($val) $val / 1GB } -State $State
|
||||
Set-UIValue -ControlName 'txtMemory' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Memory' -TransformValue { param($val) $val / 1GB } -State $State
|
||||
@@ -363,10 +419,9 @@ function Update-UIFromConfig {
|
||||
Set-UIValue -ControlName 'cmbWindowsSKU' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'WindowsSKU' -State $State
|
||||
Set-UIValue -ControlName 'cmbMediaType' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'MediaType' -State $State
|
||||
Set-UIValue -ControlName 'txtProductKey' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ProductKey' -State $State
|
||||
Set-UIValue -ControlName 'txtOptionalFeatures' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'OptionalFeatures' -State $State
|
||||
|
||||
# Update Optional Features checkboxes based on the loaded text
|
||||
$loadedFeaturesString = $State.Controls.txtOptionalFeatures.Text
|
||||
# Update Optional Features checkboxes
|
||||
$loadedFeaturesString = $ConfigContent.OptionalFeatures
|
||||
if (-not [string]::IsNullOrWhiteSpace($loadedFeaturesString)) {
|
||||
$loadedFeaturesArray = $loadedFeaturesString.Split(';')
|
||||
WriteLog "LoadConfig: Updating Optional Features checkboxes. Loaded features: $($loadedFeaturesArray -join ', ')"
|
||||
|
||||
@@ -44,7 +44,8 @@ function Register-EventHandlers {
|
||||
$State.Controls.txtDiskSize,
|
||||
$State.Controls.txtMemory,
|
||||
$State.Controls.txtProcessors,
|
||||
$State.Controls.txtThreads
|
||||
$State.Controls.txtThreads,
|
||||
$State.Controls.txtMaxUSBDrives
|
||||
)
|
||||
|
||||
# Attach the handlers to each relevant textbox
|
||||
@@ -72,6 +73,20 @@ function Register-EventHandlers {
|
||||
})
|
||||
}
|
||||
|
||||
# Add specific validation for the Max USB Drives textbox to ensure it's an integer >=0 (allow 0 meaning all)
|
||||
if ($null -ne $State.Controls.txtMaxUSBDrives) {
|
||||
$State.Controls.txtMaxUSBDrives.Add_LostFocus({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$textBox = $eventSource
|
||||
$currentValue = 0
|
||||
$isValidInteger = [int]::TryParse($textBox.Text, [ref]$currentValue)
|
||||
if (-not $isValidInteger -or $currentValue -lt 0) {
|
||||
$textBox.Text = '0'
|
||||
WriteLog "Max USB Drives value was invalid or less than 0. Reset to 0 (process all)."
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
# Build Tab Event Handlers
|
||||
$State.Controls.btnBrowseFFUDevPath.Add_Click({
|
||||
param($eventSource, $routedEventArgs)
|
||||
@@ -192,7 +207,12 @@ function Register-EventHandlers {
|
||||
$selectedItem = $eventSource.SelectedItem
|
||||
if ($selectedItem -eq 'Other') {
|
||||
$localState.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
|
||||
$localState.Controls.txtVMHostIPAddress.Text = '' # Clear IP for custom
|
||||
if ([string]::IsNullOrWhiteSpace($localState.Controls.txtCustomVMSwitchName.Text) -and $null -ne $localState.Data.customVMSwitchName) {
|
||||
$localState.Controls.txtCustomVMSwitchName.Text = $localState.Data.customVMSwitchName
|
||||
}
|
||||
if ($null -ne $localState.Data.customVMHostIP -and -not [string]::IsNullOrWhiteSpace($localState.Data.customVMHostIP)) {
|
||||
$localState.Controls.txtVMHostIPAddress.Text = $localState.Data.customVMHostIP
|
||||
}
|
||||
}
|
||||
else {
|
||||
$localState.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
|
||||
@@ -205,6 +225,24 @@ function Register-EventHandlers {
|
||||
}
|
||||
})
|
||||
|
||||
# Persist custom VM switch name/IP when user edits them while 'Other' is selected
|
||||
$State.Controls.txtVMHostIPAddress.Add_LostFocus({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
if ($localState.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') {
|
||||
$localState.Data.customVMHostIP = $localState.Controls.txtVMHostIPAddress.Text
|
||||
}
|
||||
})
|
||||
$State.Controls.txtCustomVMSwitchName.Add_LostFocus({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
if ($localState.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') {
|
||||
$localState.Data.customVMSwitchName = $localState.Controls.txtCustomVMSwitchName.Text
|
||||
}
|
||||
})
|
||||
|
||||
# Windows Settings tab Event Handlers
|
||||
$State.Controls.txtISOPath.Add_TextChanged({
|
||||
param($eventSource, $textChangedEventArgs)
|
||||
@@ -356,6 +394,13 @@ function Register-EventHandlers {
|
||||
Add-BYOApplication -State $localState
|
||||
})
|
||||
|
||||
$State.Controls.btnEditApplication.Add_Click({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
Start-EditBYOApplication -State $localState
|
||||
})
|
||||
|
||||
$State.Controls.btnSaveBYOApplications.Add_Click({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
@@ -398,12 +443,21 @@ function Register-EventHandlers {
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
|
||||
# Before clearing, check if we are in edit mode and reset the state
|
||||
if ($null -ne $localState.Data.editingBYOApplication) {
|
||||
$localState.Data.editingBYOApplication = $null
|
||||
$localState.Controls.btnAddApplication.Content = "Add Application"
|
||||
}
|
||||
|
||||
Clear-ListViewContent -State $localState `
|
||||
-ListViewControl $localState.Controls.lstApplications `
|
||||
-ConfirmationTitle "Clear BYO Applications" `
|
||||
-ConfirmationMessage "Are you sure you want to clear all 'Bring Your Own' applications?" `
|
||||
-StatusMessage "BYO application list cleared." `
|
||||
-PostClearAction { Update-CopyButtonState -State $State }
|
||||
-PostClearAction {
|
||||
Update-CopyButtonState -State $State
|
||||
Update-BYOAppsActionButtonsState -State $State
|
||||
}
|
||||
})
|
||||
|
||||
$State.Controls.btnCopyBYOApps.Add_Click({
|
||||
@@ -413,6 +467,13 @@ function Register-EventHandlers {
|
||||
Invoke-CopyBYOApps -State $localState -Button $eventSource
|
||||
})
|
||||
|
||||
$State.Controls.btnRemoveSelectedBYOApps.Add_Click({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
Remove-SelectedBYOApplications -State $localState
|
||||
})
|
||||
|
||||
$State.Controls.btnMoveTop.Add_Click({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
@@ -441,6 +502,65 @@ function Register-EventHandlers {
|
||||
Move-ListViewItemBottom -ListView $localState.Controls.lstApplications
|
||||
})
|
||||
|
||||
$State.Controls.lstApplications.Add_PreviewKeyDown({
|
||||
param($eventSource, $keyEvent)
|
||||
if ($keyEvent.Key -eq 'Space') {
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
Invoke-ListViewItemToggle -ListView $eventSource -State $localState -HeaderCheckBoxKeyName 'chkSelectAllBYOApps'
|
||||
# Update button states after toggle
|
||||
Update-BYOAppsActionButtonsState -State $localState
|
||||
$keyEvent.Handled = $true
|
||||
}
|
||||
})
|
||||
|
||||
$State.Controls.lstApplications.Add_SelectionChanged({
|
||||
param($eventSource, $selChangeEvent)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
$headerChk = $localState.Controls.chkSelectAllBYOApps
|
||||
if ($null -ne $headerChk) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $localState.Controls.lstApplications -HeaderCheckBox $headerChk
|
||||
}
|
||||
# Update button states based on selection
|
||||
Update-BYOAppsActionButtonsState -State $localState
|
||||
})
|
||||
|
||||
# Add a routed event handler to catch checkbox clicks within the ListView
|
||||
$State.Controls.lstApplications.AddHandler(
|
||||
[System.Windows.Controls.Primitives.ButtonBase]::ClickEvent,
|
||||
[System.Windows.RoutedEventHandler] {
|
||||
param($eventSource, $e)
|
||||
# Check if the original source of the click was a CheckBox
|
||||
$clickedCheckBox = $e.OriginalSource
|
||||
if ($clickedCheckBox -is [System.Windows.Controls.CheckBox]) {
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
$dataItem = $clickedCheckBox.DataContext
|
||||
|
||||
if ($null -ne $dataItem) {
|
||||
# Defensively add the 'IsSelected' property if it's missing from the data object.
|
||||
# This can happen in some complex UI scenarios or if the object was created without it.
|
||||
if ($null -eq $dataItem.PSObject.Properties['IsSelected']) {
|
||||
$dataItem | Add-Member -MemberType NoteProperty -Name 'IsSelected' -Value $false
|
||||
}
|
||||
|
||||
# Now that we're sure the property exists, set its value.
|
||||
$dataItem.IsSelected = $clickedCheckBox.IsChecked
|
||||
}
|
||||
|
||||
# Update the state of the action buttons based on the new selection.
|
||||
Update-BYOAppsActionButtonsState -State $localState
|
||||
|
||||
# Also, update the header checkbox to reflect the change.
|
||||
$headerChk = $localState.Controls.chkSelectAllBYOApps
|
||||
if ($null -ne $headerChk) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $localState.Controls.lstApplications -HeaderCheckBox $headerChk
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Apps Script Variables Event Handlers
|
||||
# Attach the handler to the script variables checkbox
|
||||
$State.Controls.chkDefineAppsScriptVariables.Add_Checked($appPanelUpdateHandler)
|
||||
|
||||
@@ -28,7 +28,6 @@ function Initialize-UIControls {
|
||||
$State.Controls.cmbWindowsSKU = $window.FindName('cmbWindowsSKU')
|
||||
$State.Controls.cmbMediaType = $window.FindName('cmbMediaType')
|
||||
$State.Controls.MediaTypeStackPanel = $window.FindName('MediaTypeStackPanel')
|
||||
$State.Controls.txtOptionalFeatures = $window.FindName('txtOptionalFeatures')
|
||||
$State.Controls.featuresPanel = $window.FindName('stackFeaturesContainer')
|
||||
$State.Controls.chkDownloadDrivers = $window.FindName('chkDownloadDrivers')
|
||||
$State.Controls.cmbMake = $window.FindName('cmbMake')
|
||||
@@ -89,10 +88,14 @@ function Initialize-UIControls {
|
||||
$State.Controls.txtAppCommandLine = $window.FindName('txtAppCommandLine')
|
||||
$State.Controls.txtAppArguments = $window.FindName('txtAppArguments')
|
||||
$State.Controls.txtAppSource = $window.FindName('txtAppSource')
|
||||
$State.Controls.txtAppAdditionalExitCodes = $window.FindName('txtAppAdditionalExitCodes')
|
||||
$State.Controls.chkIgnoreExitCodes = $window.FindName('chkIgnoreExitCodes')
|
||||
$State.Controls.btnAddApplication = $window.FindName('btnAddApplication')
|
||||
$State.Controls.btnSaveBYOApplications = $window.FindName('btnSaveBYOApplications')
|
||||
$State.Controls.btnLoadBYOApplications = $window.FindName('btnLoadBYOApplications')
|
||||
$State.Controls.btnEditApplication = $window.FindName('btnEditApplication')
|
||||
$State.Controls.btnClearBYOApplications = $window.FindName('btnClearBYOApplications')
|
||||
$State.Controls.btnRemoveSelectedBYOApps = $window.FindName('btnRemoveSelectedBYOApps')
|
||||
$State.Controls.btnCopyBYOApps = $window.FindName('btnCopyBYOApps')
|
||||
$State.Controls.lstApplications = $window.FindName('lstApplications')
|
||||
$State.Controls.btnMoveTop = $window.FindName('btnMoveTop')
|
||||
@@ -111,11 +114,13 @@ function Initialize-UIControls {
|
||||
$State.Controls.txtShareName = $window.FindName('txtShareName')
|
||||
$State.Controls.txtUsername = $window.FindName('txtUsername')
|
||||
$State.Controls.txtThreads = $window.FindName('txtThreads')
|
||||
$State.Controls.txtMaxUSBDrives = $window.FindName('txtMaxUSBDrives')
|
||||
$State.Controls.chkCompactOS = $window.FindName('chkCompactOS')
|
||||
$State.Controls.chkOptimize = $window.FindName('chkOptimize')
|
||||
$State.Controls.chkAllowVHDXCaching = $window.FindName('chkAllowVHDXCaching')
|
||||
$State.Controls.chkCreateCaptureMedia = $window.FindName('chkCreateCaptureMedia')
|
||||
$State.Controls.chkCreateDeploymentMedia = $window.FindName('chkCreateDeploymentMedia')
|
||||
$State.Controls.chkInjectUnattend = $window.FindName('chkInjectUnattend')
|
||||
$State.Controls.chkVerbose = $window.FindName('chkVerbose')
|
||||
$State.Controls.chkCopyAutopilot = $window.FindName('chkCopyAutopilot')
|
||||
$State.Controls.chkCopyUnattend = $window.FindName('chkCopyUnattend')
|
||||
@@ -223,11 +228,13 @@ function Initialize-UIDefaults {
|
||||
$State.Controls.txtShareName.Text = $State.Defaults.generalDefaults.ShareName
|
||||
$State.Controls.txtUsername.Text = $State.Defaults.generalDefaults.Username
|
||||
$State.Controls.txtThreads.Text = $State.Defaults.generalDefaults.Threads
|
||||
$State.Controls.txtMaxUSBDrives.Text = $State.Defaults.generalDefaults.MaxUSBDrives
|
||||
$State.Controls.chkBuildUSBDriveEnable.IsChecked = $State.Defaults.generalDefaults.BuildUSBDriveEnable
|
||||
$State.Controls.chkCompactOS.IsChecked = $State.Defaults.generalDefaults.CompactOS
|
||||
$State.Controls.chkUpdateADK.IsChecked = $State.Defaults.generalDefaults.UpdateADK
|
||||
$State.Controls.chkOptimize.IsChecked = $State.Defaults.generalDefaults.Optimize
|
||||
$State.Controls.chkAllowVHDXCaching.IsChecked = $State.Defaults.generalDefaults.AllowVHDXCaching
|
||||
$State.Controls.chkInjectUnattend.IsChecked = $State.Defaults.generalDefaults.InjectUnattend
|
||||
$State.Controls.chkCreateCaptureMedia.IsChecked = $State.Defaults.generalDefaults.CreateCaptureMedia
|
||||
$State.Controls.chkCreateDeploymentMedia.IsChecked = $State.Defaults.generalDefaults.CreateDeploymentMedia
|
||||
$State.Controls.chkAllowExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.AllowExternalHardDiskMedia
|
||||
@@ -266,7 +273,6 @@ function Initialize-UIDefaults {
|
||||
$State.Controls.cmbWindowsLang.SelectedItem = $State.Defaults.windowsSettingsDefaults.DefaultWindowsLang
|
||||
$State.Controls.cmbMediaType.ItemsSource = $State.Defaults.windowsSettingsDefaults.AllowedMediaTypes
|
||||
$State.Controls.cmbMediaType.SelectedItem = $State.Defaults.windowsSettingsDefaults.DefaultMediaType
|
||||
$State.Controls.txtOptionalFeatures.Text = $State.Defaults.windowsSettingsDefaults.DefaultOptionalFeatures
|
||||
$State.Controls.txtProductKey.Text = $State.Defaults.windowsSettingsDefaults.DefaultProductKey
|
||||
|
||||
# Updates tab defaults from General Defaults
|
||||
@@ -454,6 +460,28 @@ function Initialize-DynamicUIElements {
|
||||
}
|
||||
)
|
||||
|
||||
# BYO Applications ListView setup
|
||||
$byoAppsGridView = New-Object System.Windows.Controls.GridView
|
||||
$State.Controls.lstApplications.View = $byoAppsGridView
|
||||
|
||||
# Set ListViewItem style to stretch content horizontally
|
||||
$itemStyleBYOApps = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
|
||||
$itemStyleBYOApps.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
|
||||
$State.Controls.lstApplications.ItemContainerStyle = $itemStyleBYOApps
|
||||
|
||||
# Add the selectable column
|
||||
Add-SelectableGridViewColumn -ListView $State.Controls.lstApplications -State $State -HeaderCheckBoxKeyName "chkSelectAllBYOApps" -ColumnWidth 60
|
||||
|
||||
# Add other sortable columns
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Priority" -binding "Priority" -width 60 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Name" -binding "Name" -width 150 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Command Line" -binding "CommandLine" -width 200 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Arguments" -binding "Arguments" -width 200 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Source" -binding "Source" -width 150 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Exit Codes" -binding "AdditionalExitCodes" -width 100 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Ignore Exit Codes" -binding "IgnoreExitCodes" -width 120 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Copy Status" -binding "CopyStatus" -width 150 -headerHorizontalAlignment Left
|
||||
|
||||
# Apps Script Variables ListView setup
|
||||
# Bind ItemsSource to the data list
|
||||
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
|
||||
|
||||
@@ -608,7 +608,6 @@ function UpdateOptionalFeaturesString {
|
||||
foreach ($entry in $State.Controls.featureCheckBoxes.GetEnumerator()) {
|
||||
if ($entry.Value.IsChecked) { $checkedFeatures += $entry.Key }
|
||||
}
|
||||
$State.Controls.txtOptionalFeatures.Text = $checkedFeatures -join ";"
|
||||
}
|
||||
function BuildFeaturesGrid {
|
||||
param (
|
||||
|
||||
@@ -385,7 +385,8 @@ function Start-WingetAppDownloadTask {
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OrchestrationPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue # Add queue parameter
|
||||
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue, # Add queue parameter
|
||||
[string]$WindowsArch
|
||||
)
|
||||
|
||||
$appName = $ApplicationItemData.Name
|
||||
@@ -398,10 +399,6 @@ function Start-WingetAppDownloadTask {
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
|
||||
WriteLog "Starting download task for $($appName) with ID $($appId) from source $($source)."
|
||||
# WriteLog "Apps Path: $($AppsPath)"
|
||||
# WriteLog "AppList JSON Path: $($AppListJsonPath)"
|
||||
# WriteLog "Windows Architecture: $($ApplicationItemData.Architecture)"
|
||||
# WriteLog "Orchestration Path: $($OrchestrationPath)"
|
||||
|
||||
try {
|
||||
# Define paths
|
||||
@@ -450,70 +447,35 @@ function Start-WingetAppDownloadTask {
|
||||
}
|
||||
}
|
||||
|
||||
# 2. Check previous Winget download
|
||||
if (-not $appFound) {
|
||||
if (-not $appFound) {
|
||||
$wingetWin32jsonFile = Join-Path -Path $OrchestrationPath -ChildPath "WinGetWin32Apps.json"
|
||||
if (Test-Path -Path $wingetWin32jsonFile) {
|
||||
try {
|
||||
$wingetAppsJson = Get-Content -Path $wingetWin32jsonFile -Raw | ConvertFrom-Json
|
||||
# Check if app already exists in WinGetWin32Apps.json
|
||||
# For multi-arch apps, there might be entries like "AppName (x86)" and "AppName (x64)"
|
||||
$existingWin32Entries = @($wingetAppsJson | Where-Object {
|
||||
$_.Name -eq $appName -or
|
||||
$_.Name -eq "$appName (x86)" -or
|
||||
$_.Name -eq "$appName (x64)"
|
||||
})
|
||||
|
||||
if ($existingWin32Entries.Count -gt 0) {
|
||||
# 2. Check existing downloaded Win32 content (folder-based; no WinGetWin32Apps.json dependency)
|
||||
if (-not $appFound -and $source -eq 'winget') {
|
||||
$appFolder = Join-Path -Path "$AppsPath\Win32" -ChildPath $appName
|
||||
$appContentFound = $false
|
||||
|
||||
# Check if it's a multi-arch app with subfolders
|
||||
if (Test-Path -Path $appFolder -PathType Container) {
|
||||
$contentFound = $false
|
||||
if ($ApplicationItemData.Architecture -eq 'x86 x64') {
|
||||
$x86Folder = Join-Path -Path $appFolder -ChildPath "x86"
|
||||
$x64Folder = Join-Path -Path $appFolder -ChildPath "x64"
|
||||
|
||||
if ((Test-Path -Path $x86Folder -PathType Container) -and (Test-Path -Path $x64Folder -PathType Container)) {
|
||||
$x86Size = (Get-ChildItem -Path $x86Folder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
$x64Size = (Get-ChildItem -Path $x64Folder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
|
||||
if ($x86Size -gt 1MB -and $x64Size -gt 1MB) {
|
||||
$appContentFound = $true
|
||||
$contentFound = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Single architecture app
|
||||
if (Test-Path -Path $appFolder -PathType Container) {
|
||||
$folderSize = (Get-ChildItem -Path $appFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($folderSize -gt 1MB) {
|
||||
$appContentFound = $true
|
||||
$contentFound = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($appContentFound) {
|
||||
if ($contentFound) {
|
||||
$appFound = $true
|
||||
$status = "Not Downloaded: App already in $wingetWin32jsonFile and found in $appFolder"
|
||||
$status = "Not Downloaded: Existing content found in $appFolder"
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
WriteLog "Found '$appName' in WinGetWin32Apps.json and content exists in '$appFolder'. Skipping download to prevent duplicate entry."
|
||||
WriteLog "Found existing content for '$appName' in '$appFolder'. Skipping download to prevent duplicate entry."
|
||||
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 0 }
|
||||
}
|
||||
else {
|
||||
# App entry exists in WinGetWin32Apps.json but folder is missing or incomplete
|
||||
$appFound = $true
|
||||
$status = "App in '$wingetWin32jsonFile' but content folder '$appFolder' not found or incomplete. Remove entry from WinGetWin32Apps.json or restore content."
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
WriteLog $status
|
||||
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Warning: Could not read or parse '$wingetWin32jsonFile'. Error: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,7 +596,7 @@ function Start-WingetAppDownloadTask {
|
||||
|
||||
try {
|
||||
# Call Get-Application
|
||||
$resultCode = Get-Application -AppName $appName -AppId $appId -Source $source -AppsPath $AppsPath -WindowsArch $ApplicationItemData.Architecture -OrchestrationPath $OrchestrationPath -ErrorAction Stop
|
||||
$resultCode = Get-Application -AppName $appName -AppId $appId -Source $source -AppsPath $AppsPath -ApplicationArch $ApplicationItemData.Architecture -WindowsArch $WindowsArch -OrchestrationPath $OrchestrationPath -SkipWin32Json -ErrorAction Stop
|
||||
|
||||
# Determine status based on result code
|
||||
switch ($resultCode) {
|
||||
@@ -756,6 +718,7 @@ function Invoke-WingetDownload {
|
||||
AppsPath = $localAppsPath
|
||||
AppListJsonPath = $localAppListJsonPath
|
||||
OrchestrationPath = $localOrchestrationPath
|
||||
WindowsArch = $localWindowsArch
|
||||
}
|
||||
|
||||
# Select only necessary properties before passing to Invoke-ParallelProcessing
|
||||
|
||||
@@ -117,6 +117,7 @@ function Get-GeneralDefaults {
|
||||
ShareName = "FFUCaptureShare"
|
||||
Username = "ffu_user"
|
||||
Threads = 5
|
||||
MaxUSBDrives = 5
|
||||
BuildUSBDriveEnable = $false
|
||||
CompactOS = $true
|
||||
Optimize = $true
|
||||
@@ -130,6 +131,7 @@ function Get-GeneralDefaults {
|
||||
CopyAutopilot = $false
|
||||
CopyUnattend = $false
|
||||
CopyPPKG = $false
|
||||
InjectUnattend = $false
|
||||
CleanupAppsISO = $true
|
||||
CleanupCaptureISO = $true
|
||||
CleanupDeployISO = $true
|
||||
@@ -202,6 +204,11 @@ function Update-ApplicationPanelVisibility {
|
||||
[string]$TriggeringControlName # Optional: to know which control initiated the change
|
||||
)
|
||||
|
||||
# If BYO Apps, Winget Apps, or Define Apps Script Variables is checked, force Install Apps to be checked
|
||||
if ($State.Controls.chkBringYourOwnApps.IsChecked -or $State.Controls.chkInstallWingetApps.IsChecked -or $State.Controls.chkDefineAppsScriptVariables.IsChecked) {
|
||||
$State.Controls.chkInstallApps.IsChecked = $true
|
||||
}
|
||||
|
||||
$installAppsChecked = $State.Controls.chkInstallApps.IsChecked
|
||||
|
||||
# If the main 'Install Apps' is unchecked, everything below it gets hidden and reset.
|
||||
@@ -268,6 +275,12 @@ function Update-InstallAppsState {
|
||||
# If no state was saved (e.g., it was never forced), ensure it's unchecked.
|
||||
$installAppsChk.IsChecked = $false
|
||||
}
|
||||
|
||||
# If BYO, Winget, or Apps Script Variables are checked, it overrides the restoration and keeps Install Apps checked.
|
||||
if ($State.Controls.chkBringYourOwnApps.IsChecked -or $State.Controls.chkInstallWingetApps.IsChecked -or $State.Controls.chkDefineAppsScriptVariables.IsChecked) {
|
||||
$installAppsChk.IsChecked = $true
|
||||
}
|
||||
|
||||
$installAppsChk.IsEnabled = $true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,12 +203,25 @@ function Stop-Script {
|
||||
Read-Host "Press Enter to exit"
|
||||
Exit
|
||||
}
|
||||
|
||||
function ConvertTo-ComparableModelName {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Text
|
||||
)
|
||||
# Normalize model strings by converting any non-alphanumeric sequence to a single space, collapsing whitespace, and trimming.
|
||||
if ($null -eq $Text) { return '' }
|
||||
$normalized = ($Text -replace '[^A-Za-z0-9]+', ' ')
|
||||
$normalized = ($normalized -replace '\s+', ' ').Trim()
|
||||
return $normalized
|
||||
}
|
||||
|
||||
#Get USB Drive and create log file
|
||||
$LogFileName = 'ScriptLog.txt'
|
||||
$USBDrive = Get-USBDrive
|
||||
New-item -Path $USBDrive -Name $LogFileName -ItemType "file" -Force | Out-Null
|
||||
$LogFile = $USBDrive + $LogFilename
|
||||
$version = '2507.1'
|
||||
$version = '2508.1Preview'
|
||||
WriteLog 'Begin Logging'
|
||||
WriteLog "Script version: $version"
|
||||
|
||||
@@ -337,8 +350,8 @@ If (Test-Path -Path $UnattendComputerNamePath) {
|
||||
}
|
||||
|
||||
#Ask for device name if unattend exists
|
||||
if ($Unattend -and $UnattendPrefix) {
|
||||
Write-SectionHeader 'Device Name Selection'
|
||||
if ($Unattend -and $UnattendPrefix) {
|
||||
Writelog 'Unattend file found with prefixes.txt. Getting prefixes.'
|
||||
$UnattendPrefixes = @(Get-content $UnattendPrefixFile)
|
||||
$UnattendPrefixCount = $UnattendPrefixes.Count
|
||||
@@ -381,11 +394,10 @@ if ($Unattend -and $UnattendPrefix) {
|
||||
$computername = $computername.substring(0, 15)
|
||||
}
|
||||
$computername = Set-Computername($computername)
|
||||
Writelog "Computer name set to $computername"
|
||||
Write-Host "Computer name set to $computername"
|
||||
Writelog "Computer name will be set to $computername"
|
||||
Write-Host "Computer name will be set to $computername"
|
||||
}
|
||||
elseif ($Unattend -and $UnattendComputerName) {
|
||||
Write-SectionHeader 'Device Name Selection'
|
||||
Writelog 'Unattend file found with SerialComputerNames.csv. Getting name for current computer.'
|
||||
$SerialComputerNames = Import-Csv -Path $UnattendComputerNameFile.FullName -Delimiter ","
|
||||
|
||||
@@ -395,25 +407,25 @@ elseif ($Unattend -and $UnattendComputerName) {
|
||||
If ($SCName) {
|
||||
[string]$computername = $SCName.ComputerName
|
||||
$computername = Set-Computername($computername)
|
||||
Writelog "Computer name set to $computername"
|
||||
Write-Host "Computer name set to $computername"
|
||||
Writelog "Computer name will be set to $computername"
|
||||
Write-Host "Computer name will be set to $computername"
|
||||
}
|
||||
else {
|
||||
Writelog 'No matching serial number found in SerialComputerNames.csv. Setting random computer name to complete setup.'
|
||||
Write-Host 'No matching serial number found in SerialComputerNames.csv. Setting random computer name to complete setup.'
|
||||
[string]$computername = ("FFU-" + ( -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 11 | ForEach-Object { [char]$_ })))
|
||||
$computername = Set-Computername($computername)
|
||||
Writelog "Computer name set to $computername"
|
||||
Write-Host "Computer name set to $computername"
|
||||
Writelog "Computer name will be set to $computername"
|
||||
Write-Host "Computer name will be set to $computername"
|
||||
}
|
||||
}
|
||||
elseif ($Unattend) {
|
||||
Writelog 'Unattend file found with no prefixes.txt, asking for name'
|
||||
Write-Host 'Unattend file found but no prefixes.txt. Please enter a device name.'
|
||||
[string]$computername = Read-Host 'Enter device name'
|
||||
Set-Computername($computername)
|
||||
Writelog "Computer name set to $computername"
|
||||
Write-Host "Computer name set to $computername"
|
||||
$computername = Set-Computername($computername)
|
||||
Writelog "Computer name will be set to $computername"
|
||||
Write-Host "Computer name will be set to $computername"
|
||||
}
|
||||
else {
|
||||
WriteLog 'No unattend folder found. Device name will be set via PPKG, AP JSON, or default OS name.'
|
||||
@@ -546,14 +558,18 @@ if (Test-Path -Path $driverMappingPath -PathType Leaf) {
|
||||
WriteLog "Detected System: Manufacturer='$systemManufacturer', Model='$systemModel'"
|
||||
|
||||
# Load and parse the mapping file, ensuring it's always an array
|
||||
$driverMappings = @(Get-Content -Path $driverMappingPath -Raw | ConvertFrom-Json -ErrorAction SilentlyContinue)
|
||||
$driverMappings = Get-Content -Path $driverMappingPath | Out-String | ConvertFrom-Json -ErrorAction SilentlyContinue
|
||||
|
||||
# Find all matching rules and select the most specific one
|
||||
$matchingRules = @()
|
||||
foreach ($rule in $driverMappings) {
|
||||
# Use -like for wildcard matching.
|
||||
# Prepare normalized model strings (ignore special characters and collapse whitespace)
|
||||
$systemModelNorm = ConvertTo-ComparableModelName -Text $systemModel
|
||||
$ruleModelNorm = ConvertTo-ComparableModelName -Text $rule.Model
|
||||
# This checks if the system model starts with the rule model, or vice-versa, for flexibility.
|
||||
if ($systemManufacturer -like "$($rule.Manufacturer)*" -and ($systemModel -like "$($rule.Model)*" -or $rule.Model -like "$systemModel*")) {
|
||||
if ($systemManufacturer -like "$($rule.Manufacturer)*" -and ($systemModelNorm -like "$($ruleModelNorm)*" -or $ruleModelNorm -like "$systemModelNorm*")) {
|
||||
WriteLog "Match found: Manufacturer='$($rule.Manufacturer)', Model='$($rule.Model)' (Normalized: System='$systemModelNorm', Rule='$ruleModelNorm')"
|
||||
$matchingRules += $rule
|
||||
}
|
||||
}
|
||||
@@ -894,8 +910,11 @@ if ($null -ne $DriverSourcePath) {
|
||||
}
|
||||
elseif ($DriverSourceType -eq 'Folder') {
|
||||
WriteLog "Injecting drivers from folder: $DriverSourcePath"
|
||||
Write-Host "Injecting drivers from folder: $DriverSourcePath"
|
||||
Write-Host "This may take a while, please be patient."
|
||||
Invoke-Process dism.exe "/image:W:\ /Add-Driver /Driver:""$DriverSourcePath"" /Recurse"
|
||||
WriteLog "Driver injection from folder succeeded."
|
||||
Write-Host "Driver injection from folder succeeded."
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
||||
Reference in New Issue
Block a user