Compare commits

..

35 Commits

Author SHA1 Message Date
rbalsleyMSFT bf27da5c66 Updates script version to 2508.1Preview
Aligns the version number in the FFU build and deployment scripts with the new preview release.
2025-08-26 17:14:37 -07:00
rbalsleyMSFT 1010b9fce7 Adds cleanup for disabled update artifacts
Introduces a new function to remove residual artifacts for updates that are disabled via script flags.

If updates for Defender, MSRT, OneDrive, or Edge are turned off, this change ensures that any related files are deleted from the build environment. This prevents unnecessary files from being included in the final image.
2025-08-26 17:11:57 -07:00
rbalsleyMSFT 3e34bd6bff Refactor architecture handling for app downloads
Clarifies the distinction between the application architecture to be downloaded and the target Windows architecture for installer pruning.

Renames the `SelectedWindowsArch` parameter to `WindowsArch` and introduces a new `ApplicationArch` parameter. This makes the download and subsequent installer selection logic more explicit and easier to understand, especially for MS Store apps where multiple installers might be available.
2025-08-22 18:47:43 -07:00
rbalsleyMSFT 3f892493c0 Improves Winget installer selection logic
Refines the post-download cleanup process for applications with multiple installers.

When multiple installers are downloaded for an application, this change introduces logic to select the most appropriate one based on the target Windows architecture. It prioritizes universal installers first, then architecture-specific installers, before falling back to the previous method of selecting the newest file by signature date.

This ensures a more accurate installer is chosen, especially when Winget provides multiple architecture options for a single package.
2025-08-22 17:33:09 -07:00
rbalsleyMSFT 7d4567efbe Refactor WinGet app handling and add command overrides
Improves the reliability of WinGet app processing by making several key changes.

The build process now deletes the `WinGetWin32Apps.json` file before each run to ensure it is always freshly generated.

The UI no longer relies on `WinGetWin32Apps.json` to detect previously downloaded content. Instead, it checks directly for the application's content on disk, preventing unnecessary re-downloads.

This change also introduces a feature allowing users to override the default silent install commands for Win32 apps. By specifying `CommandLine` or `Arguments` properties in `AppList.json`, these values will be used to update the corresponding entries in `WinGetWin32Apps.json` during the build process.
2025-08-21 16:46:17 -07:00
rbalsleyMSFT 9aed707a77 Removes redundant optional features textbox
Eliminates the read-only field and derives the features list directly from the checked items, producing a sorted semicolon string when collecting config. Avoids duplicated state, prevents desynchronization between UI elements, and yields deterministic ordering for persistence.
2025-08-19 18:40:09 -07:00
rbalsleyMSFT 0c373e6b2c fix: Fixes an issue with BYO apps that had null arguments. 2025-08-19 17:34:41 -07:00
rbalsleyMSFT a501b32a03 feat: Implement per-user Appx package detection and removal for Sysprep automation
- Added logic to check for per-user Appx packages that are not provisioned for all users, which could block the Sysprep process.
- Introduced a hash set to store provisioned package families and collect current user Appx packages while excluding frameworks and non-removable packages.
- Implemented removal of non-provisioned Appx packages with error handling and re-checking to ensure all blockers are resolved before proceeding with Sysprep.
2025-08-18 15:54:11 -07:00
rbalsleyMSFT 8ab6603999 feat: Add new checkbox to Inject Unattend.xml to VM.
- Creates a new parameter [bool]InjectUnattend

- This will take the FFUDevelopment\Unattend\unattend_[arch].xml file and copy it to the FFUDevelopment\Apps\Unattend and rename the file to unattend.xml.

- This is useful for situations where you don't use the USB drive for deploying the FFU but still have Unattend-related customizations that you want to apply.
2025-08-14 16:20:55 -07:00
rbalsleyMSFT 85383f989a fix: - Improved handling of special characters with driver model names when doing automatic driver/model matching when applying the FFU to a device.
- Added some console output when drivers are being installed from a folder
2025-08-13 18:48:28 -07:00
rbalsleyMSFT 0423ac31d9 feat: Enhance installer selection logic in Add-Win32SilentInstallCommand function
- Improved detection of installer candidates by reading YAML configuration for NestedInstallerFiles.
- Added logic to resolve silent install switches from YAML, prioritizing block-level settings.
- Enhanced fallback mechanisms for selecting installers when multiple candidates are found.
- Updated command construction to accommodate new installer resolution logic.
2025-08-13 16:48:18 -07:00
rbalsleyMSFT 35f37f3a36 fix: Microsoft Update Catalog now includes the windows version information in the KB article title. This caused an issue where parsing the KB article was failing. Fixed the regex to accomdate this. 2025-08-12 17:30:28 -07:00
rbalsleyMSFT 78d7bb9262 feat: Add VM switch selection logic and persist custom names/IPs in UI
- Implemented Select-VMSwitchFromConfig function to handle VM switch selection based on configuration.
- Enhanced Register-EventHandlers to persist custom VM switch name and IP address when 'Other' is selected.
- Improved user experience by ensuring relevant fields are populated correctly based on user input and configuration settings.
2025-08-12 12:14:03 -07:00
rbalsleyMSFT 3c545be5c5 Hardens driver downloads and cleanup
Adds in‑progress markers around OEM driver downloads to enable recovery and reliable post‑run cleanup.

Refactors driver cleanup to be run‑aware: maps download targets to model folders, removes temp/model content created during the run, prunes empties, and preserves existing make roots via creation‑time checks.

Includes the Drivers folder in current‑run cleanup with safer rules to avoid deleting pre‑existing content.

Improves Office process termination by resolving the Office path (prefers UI value) and only acting when a valid folder exists.
2025-08-11 19:36:58 -07:00
rbalsleyMSFT c1983f75e6 fix: fixed unattend file copying logic for USB deployment based on architecture 2025-08-08 19:29:50 -07:00
rbalsleyMSFT 7c3de6d77f feat: Add cleanup functionality and improve build cancellation process in UI
- Introduced flags to track if a build is in progress and if cleanup is running.
- Enhanced the button click handler to allow users to cancel an ongoing build and initiate a cleanup process.
- Implemented a mechanism to stop background jobs and terminate associated processes during cancellation.
- Added logic to manage log file reading during cleanup and ensure proper UI updates.
- Updated the state management to reflect the current operation status accurately.
2025-08-08 18:22:40 -07:00
rbalsleyMSFT 17dc80f11b fix: Fix computer name being echoed during device naming 2025-08-07 18:08:25 -07:00
rbalsleyMSFT 846d449aac feat: Add MaxUSBDrives parameter for parallel USB drive processing
- Introduced a new parameter `MaxUSBDrives` to control the maximum number of USB drives that can be built in parallel, with a default value of 5.
- Updated UI to include a textbox for setting `MaxUSBDrives`.
- Implemented validation to ensure the value is a non-negative integer.
- Adjusted the deployment function to respect the `MaxUSBDrives` limit during USB drive creation.
2025-08-07 16:32:25 -07:00
rbalsleyMSFT db9b7335f2 refactor: Inject unattend file after VHDX caching for audit-mode boot
- Moved unattend file injection logic to occur after VHDX caching to ensure the cached VHDX does not contain audit-mode unattend.
- Simplified the logic to determine if the VHDX is already mounted, reducing redundant mount/dismount cycles.
- Ensured the unattend file is copied to the correct directory based on the Windows architecture.
2025-08-07 13:58:09 -07:00
rbalsleyMSFT 6f98473009 Refines driver mapping logic and adds diagnostic logging
Improves JSON parsing of the driver mapping file to handle different file structures more reliably.

Adds logging to show all potential driver mapping rules that match the current system, making it easier to diagnose rule selection.
2025-08-06 17:46:13 -07:00
rbalsleyMSFT 357261ec73 Fix: Clarify computer name is set on reboot
Updates the logging messages to more accurately reflect that the computer name change takes effect after a restart. The `Set-Computername` command stages the change, so the log now indicates the name "will be set" instead of "was set".
2025-08-06 17:01:04 -07:00
rbalsleyMSFT 5bef901295 Deduplicate device name section header output
Moves the 'Device Name Selection' section header out of the conditional blocks to a common location. This refactoring avoids code repetition and improves maintainability.
2025-08-06 16:42:54 -07:00
rbalsleyMSFT 59e247c012 Fixes LocalAccounts module issue in PowerShell 7
Applies a workaround for an issue where the `Microsoft.PowerShell.LocalAccounts` module fails to load in PowerShell 7 on Windows 11 23H2 and earlier.

The script now checks the OS build number and imports the module using the Windows PowerShell compatibility layer on affected systems.

Fixes: https://github.com/PowerShell/PowerShell/issues/21645
2025-08-06 15:56:21 -07:00
rbalsleyMSFT a87c4796b5 feat: Implement multi-select and editing for BYO apps
Introduces multi-selection capabilities to the "Bring Your Own" applications list, allowing users to select and remove multiple applications at once.

This change also adds the ability to edit an existing application's details directly from the UI. The application list view is now dynamically generated to support these new features, including selectable rows.

Key changes:
- Adds "Edit Application" and "Remove Selected" buttons.
- Enables/disables action buttons based on the number of selected items.
- Modifies the "Add Application" form to function as an "Update" form when editing.
- Implements selection via mouse click, checkboxes, and the spacebar.
2025-08-05 19:07:41 -07:00
rbalsleyMSFT 4d289ee14a Add detailed flowchart for BuildFFUVM process in Markdown format 2025-08-05 14:51:01 -07:00
rbalsleyMSFT 08feb7c9dd Removes drivers.json file requirement for local drivers 2025-08-04 18:34:26 -07:00
rbalsleyMSFT 9cb06cb71e Updates the UI logic to automatically enable the 'Install Apps' option when 'Install Winget Apps' or 'Define Apps Script Variables' is selected.
This ensures the main application installation feature remains active when any of its dependent options are chosen, creating a more consistent user experience.
2025-08-04 17:50:12 -07:00
rbalsleyMSFT 5ec607d94a Enforce 'Install Apps' selection for BYO apps
Updates UI logic to automatically select the main 'Install Apps' option whenever the 'Bring Your Own Apps' option is selected.

This ensures the parent category is always active when a user opts to bring their own applications, preventing an inconsistent UI state. The logic is applied both when updating panel visibility and when restoring checkbox states.
2025-08-04 17:39:17 -07:00
rbalsleyMSFT ac7ef119e0 Feat: Add option to ignore non-zero application exit codes
Introduces a new feature allowing application installations to succeed regardless of their exit code. This is useful for installers that may return non-standard exit codes which should be treated as successful.

Changes include:
- A new checkbox in the UI to enable this option for an application.
- Updates to the application installation script to handle the new setting.
- Modifications to save and load this setting in the application list.
2025-08-04 17:21:34 -07:00
rbalsleyMSFT 03c8127bd3 Feat: Add support for custom success exit codes
Refactors the process invocation logic to use the .NET Process class for more robust output stream handling and to avoid temporary files.

Introduces a feature allowing users to specify additional success exit codes for applications in the UI. These codes are now considered successful during the installation process.

Adds a 'PAUSE' command to halt script execution and wait for user input.
2025-08-04 13:17:52 -07:00
rbalsleyMSFT eb001e59b3 Refine build cleanup and script variable handling
Updates the build process to remove any existing Apps.iso during cleanup, ensuring a fresh build without stale artifacts.

Clarifies in the app script template that configuration variables are treated as strings. The examples are updated to reflect this, preventing potential errors in custom scripts when checking for boolean-like values.
2025-08-01 19:42:04 -07:00
rbalsleyMSFT 3e46d4b280 Bumps script version to 2507.2
Updates the version number in the build and deployment scripts.
2025-08-01 14:44:12 -07:00
rbalsleyMSFT eae07fcad0 Improves ADK FWLink resolution for robustness
Refactors the ADK URL retrieval logic to let `Invoke-WebRequest` handle the forward link redirection directly. This approach is more reliable than manually parsing the redirect response.

Adds a try/catch block to provide better error handling and logging during the URL resolution process.
2025-08-01 12:36:45 -07:00
rbalsleyMSFT 41b65a76c1 Update README.md
Docs: Updated chapter spacing for better readability
2025-07-30 22:31:42 -07:00
rbalsleyMSFT 67c992806f Update README.md
Docs: Updated chapter spacing for better readability
2025-07-30 22:31:10 -07:00
18 changed files with 2203 additions and 305 deletions
@@ -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
}
File diff suppressed because it is too large Load Diff
+261 -12
View File
@@ -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
}
}
})
+16 -20
View File
@@ -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>
+128
View File
@@ -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) {
+278 -22
View File
@@ -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
+13
View File
@@ -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
}
}
+33 -14
View File
@@ -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 {
+33
View File
@@ -37,36 +37,69 @@ Here's a detailed overview of the new UI process.
[![Reimage Windows Fast with FFU Builder 2507.1 Preview](https://img.youtube.com/vi/oozG1aVcg9M/maxresdefault.jpg)](https://youtu.be/oozG1aVcg9M "Reimage Windows Fast with FFU Builder 2507.1 Preview")
Chapters:
[00:00](https://www.youtube.com/watch?v=oozG1aVcg9M&t=0s) Begin
[01:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=67s) Prereqs
[06:32](https://www.youtube.com/watch?v=oozG1aVcg9M&t=392s) Demo Begins
[07:16](https://www.youtube.com/watch?v=oozG1aVcg9M&t=436s) Running the BuildFFUVM_UI.ps1 script
[08:15](https://www.youtube.com/watch?v=oozG1aVcg9M&t=495s) UI Overview
[10:13](https://www.youtube.com/watch?v=oozG1aVcg9M&t=613s) Hyper-V Settings
[16:04](https://www.youtube.com/watch?v=oozG1aVcg9M&t=964s) Windows Settings
[22:35](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1355s) Updates
[24:49](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1489s) Applications
[29:39](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1779s) Install Winget Applications
[45:29](https://www.youtube.com/watch?v=oozG1aVcg9M&t=2729s) Bring Your Own Applications
[54:14](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3254s) Apps Script Variables
[57:43](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3463s) M365 Apps/Office
[59:01](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3541s) Drivers
[01:01:22](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3682s) Drivers.json example
[01:02:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3727s) DriverMapping.json explanation
[01:06:08](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3968s) Driver WIM Compression
[01:10:50](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4250s) Build
[01:12:41](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4361s) Build USB Drive
[01:20:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4807s) Monitor
[01:20:32](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4832s) Setting up the Demo Build
[01:24:10](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5050s) Save/Load Config Files
[01:25:11](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5111s) Kicking off the Demo Build/Going over the monitor tab
[01:32:26](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5546s) Demoing the new FFU Builder Orchestrator
[01:35:25](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5725s) New captureffu.ps1 console output
[01:42:29](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6149s) Demo Build Complete
[01:42:42](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6162s) How to configure a VM to test your newly built FFU
[01:48:58](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6538s) The moment of truth: What does the new deployment experience look like?
[01:53:13](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6793s) How to bypass OOBE using a provisioning package
[01:55:49](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6949s) Preview Focus Areas
[02:04:04](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7444s) Known Issues/Things to fix before GA
[02:05:38](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7538s) Providing Feedback
[02:06:43](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7603s) Thank you