Compare commits

..

26 Commits

Author SHA1 Message Date
rbalsleyMSFT dc024c9d99 Updates changelog for 2602.1 UI Preview
Adds release notes highlighting improved Surface auto driver matching, more resilient USB driver injection with better logging, and correct Windows index selection for non-English media.

Notes build execution shift to separate PowerShell process for reliable console output, fixes USB selection for identical drive names, and announces new documentation site.
2026-02-04 14:24:07 -08:00
rbalsleyMSFT 9f09dd06c9 Updates preview version to 2602.1
Keeps build and deployment scripts aligned with the latest preview release number.
2026-02-04 13:48:16 -08:00
rbalsleyMSFT 133e70ea89 Disables TOC and scrollspy on mobile viewports
Prevents mobile scroll “fighting” by limiting scrollspy and the page TOC to desktop-sized viewports.

Removes any existing TOC markup and related layout class when below the desktop breakpoint to avoid interfering with touch scrolling.
2026-02-03 23:23:12 -08:00
rbalsleyMSFT 3a4146e0c3 Fixes image zoom overlay over page TOC
Ensures zoomed images and their overlay stay on top of the right-side TOC so zoom interactions aren’t obscured on desktop layouts.
2026-02-03 20:50:42 -08:00
rbalsleyMSFT fd5603629f Improves docs layout to prevent TOC overlap
Prevents long paths, links, and inline code from overflowing into the page TOC.

Stabilizes the two-column layout by defining grid areas, keeping breadcrumbs full-width, and forcing content to respect column width so wide elements don’t render under the TOC.

Improves TOC readability by adding a background and stacking context when content still overflows.
2026-02-03 20:25:31 -08:00
rbalsleyMSFT 4c77c595c6 upload medium-zoom.min.js 2026-02-03 19:18:40 -08:00
rbalsleyMSFT 3f825e4375 Initial docs release 2026-02-03 19:06:07 -08:00
rbalsleyMSFT 2d6f6e5cb0 Silences Robocopy output during VHDX caching
Reduces build log noise by discarding Robocopy output when copying cached VHDX files, keeping logs focused on actionable messages.
2026-02-03 16:27:36 -08:00
rbalsleyMSFT 5580824ac9 Suppresses volume format output during USB setup
Reduces console noise by discarding formatting command output, improving script readability in logs and interactive runs
2026-02-03 16:25:19 -08:00
rbalsleyMSFT ed0266029a Improves USB drive selection for same-model drives
Preserves multiple selected drives that share the same model by storing an array of UniqueIds per model.

Updates drive discovery and UI restore logic to accept either a single UniqueId or a list, preventing missed selections and skipping duplicate additions.
2026-02-03 13:37:58 -08:00
rbalsleyMSFT 1feed40962 Runs builds in pwsh process for reliable cancel
Improves UI responsiveness and interactive behavior by running build/cleanup in a separate PowerShell process instead of background jobs.

Fixes cancellation reliability by terminating the full process tree (including child tools) and using process exit codes for success/failure reporting.

Reduces noisy output by suppressing type-add return values and standardizes cleanup argument passing to avoid switch/boolean binding issues.
2026-01-29 22:21:15 -08:00
rbalsleyMSFT b2a7ef5f41 Improves Windows image index selection
Updates selection to match images by language-independent edition metadata instead of localized names, reducing failures across ISO/ESD sources and languages.

Adds server Desktop Experience vs Core handling via installation type and prefers the best match deterministically, falling back to a user prompt only when needed with better logging.
2026-01-29 16:44:46 -08:00
rbalsleyMSFT 65e5ce0c63 Merge pull request #393 from JGehl99/UI
Replaced deprecated Get-WmiObject calls with Get-CimInstance.
2026-01-28 18:28:05 -08:00
rbalsleyMSFT 2de2d9ccb6 Merge pull request #394 from rbalsleyMSFT/SurfaceMapping
Surface mapping
2026-01-28 18:26:46 -08:00
rbalsleyMSFT 7231f620c8 Improve driver injection error handling and resilience
Enhances the driver installation process to be more resilient by allowing non-critical driver injection failures to not halt the entire deployment. Key improvements include:

- Adds `IgnoreExitCode` and `PassThruExitCode` parameters to the process invocation function, enabling callers to handle non-zero exit codes without throwing exceptions.

- Modifies driver injection logic (both WIM and folder-based) to capture exit codes and log warnings instead of failing the deployment when drivers fail to inject.

- Automatically collects and preserves diagnostic logs (dism.log and setupapi.offline.log) to the USB drive when driver installation encounters issues, aiding post-deployment troubleshooting.

- Wraps cleanup operations in try-catch blocks to ensure temporary resources are released even if unmounting or deletion fails.

- Fixes code formatting inconsistencies and indentation throughout the script for improved readability.

This approach prioritizes deployment completion while preserving critical diagnostic information when driver-related issues occur.
2026-01-28 18:10:18 -08:00
jgehl99 6df32b6b34 Replaced deprecated Get-WmiObject calls with Get-CimInstance. 2026-01-28 16:43:34 -05:00
rbalsleyMSFT 02e429d99d Adds TTL-based refresh for Surface cache
Treats driver index cache older than 7 days as stale to trigger re-downloads and avoid outdated metadata.

Improves resiliency by falling back to a refresh when the cache timestamp can’t be read, and adds clearer logging for cache age and refresh decisions.
2026-01-22 17:26:49 -08:00
rbalsleyMSFT 554964f57c Improves driver downloads by using cached links
Reduces unnecessary Download Center requests by preferring cached file details when available

Falls back to downloading/parsing the page only on cache miss, then backfills the cache for future runs

Adds error handling and logging around cache load/update to avoid download failures from cache issues
2026-01-22 17:10:50 -08:00
rbalsleyMSFT 866fa254f6 Improves Surface driver matching via System SKU
Adds best-effort Surface System SKU resolution and persists it into driver mappings to reduce model-name ambiguity during deployment.

Speeds up Microsoft model discovery by using a local cache and updates cached Download Center details during driver downloads to keep the UI responsive.

Prefers System SKU-based rule selection for Microsoft devices, falling back to legacy model-string matching when SKU data is unavailable.
2026-01-22 17:06:28 -08:00
rbalsleyMSFT 9d39ec8802 Updates changelog for 2601.1 UI preview
Highlights fixes for missing WinPE driver copies and long-path driver injection issues.

Notes improvements to winget app handling: prevents JSON corruption during parallel updates, enforces install order, and adds dependency/deduplication support.
2026-01-13 11:25:21 -08:00
rbalsleyMSFT e3a4634d3c Updates preview version to 2601.1
Keeps build and deployment scripts aligned on the current preview release for consistent output and logging.
2026-01-13 10:49:00 -08:00
rbalsleyMSFT ad35a0b7f9 Adds Winget Win32 dependency handling and ordering
Some apps (Camtasia) require dependency apps to be installed first. Winget will download said dependency apps. This commit will place those dependencies before the calling app in the WingetWin32Apps.json file.
2026-01-12 18:12:26 -08:00
rbalsleyMSFT b2352e338b Ensures winget installs follow AppList order
Adds post-processing to reorder and re-prioritize winget app entries so install order stays consistent with the configured list, even when parallel downloads append results in completion order.

Serializes updates with a named mutex and writes changes atomically to avoid races and partial writes, with logging around failure cases.
2026-01-09 18:15:51 -08:00
rbalsleyMSFT 53741632a4 Prevents JSON corruption during parallel app updates
Adds cross-process locking and atomic writes to avoid race conditions and partial writes when multiple runspaces update the app command metadata in parallel.

Improves resilience by backing up and rebuilding when existing JSON is malformed, ensuring the build continues safely.
2026-01-09 18:05:36 -08:00
rbalsleyMSFT e9652daba9 Improves driver injection for long paths
Adds a SUBST-based DISM injection loop to avoid path-length failures when adding large driver sets.

Improves INI/INF parsing reliability with Unicode API settings, a larger auto-growing buffer, and normalization of GUID values.

Hardens driver copying by using long-path prefixes and literal paths, reducing copy errors on deeply nested driver folders.
2026-01-09 10:44:22 -08:00
rbalsleyMSFT ed5b7f669f Improves PE driver copy reliability and logging
Fixes buffer truncation in Get-PrivateProfileSection by dynamically
growing the buffer when large INF sections are encountered.

Enhances Copy-Drivers with comprehensive error handling, file existence
checks, and detailed logging for each operation. Adds support for
architecture-specific SourceDisksFiles sections (amd64/arm64) and
provides a summary of matched, skipped, and copied files.

Fixes key-value parsing to handle values containing equals signs.
2026-01-06 17:00:42 -08:00
56 changed files with 5299 additions and 508 deletions
+62
View File
@@ -1,5 +1,67 @@
# Change Log # Change Log
# 2602.1 UI Preview
## What's Changed
### Improved Automatic Matching for Surface devices
To keep inline with HP, Dell, and Lenovo, added support for Surface devices to leverage the SystemSKU values from WMI when doing automatic driver matching during deployment. Check https://github.com/rbalsleyMSFT/FFU/pull/394 for more information. Long story short, there's a new `SurfaceDriverIndex.json` file that is created when getting the models which gathers the WMI information per model as well as the download links for each model. This info is used to generate the DriverMapping.json file for Surface to allow for better matching.
There'll be deeper documentation on the new [docs site](https://rbalsleymsft.github.io/FFU/)
### Improved driver injection error handling when deploying drivers via USB
When drivers failed to be added from the USB drive during deployment, ApplyFFU.ps1 would fail with an error message and the deployment wouldn't complete. ApplyFFU.ps1 will now continue on failure and log the error and capture the setupapi.offline.log to the USB drive for troubleshooting if needed.
### Fixed an issue with Windows image index for non-English media
In some cases non-English media would cause the end-user to have to select which Windows SKU to select due to parsing the image name output and assuming the output was in English. BuildFFUVM.ps1 will now parse the edition metadata for each index. This should improve the experience for those that are creating FFUs from non-English media.
### Run builds in separate pwsh process instead of background jobs
In https://github.com/rbalsleyMSFT/FFU/pull/393, by changing the deprecated Get-WmiObject calls to Get-CimInstance, this actually broke console output. Still don't fully understand why GWMI was allowing background jobs to output console output to the calling pwsh Window but get-ciminstance wouldn't (WinRM, PowerShell Remoting, etc), but this required changing to running the build in a separate pwsh process. Between this and https://github.com/rbalsleyMSFT/FFU/pull/393, this should fix those that might build their FFUs on Servers and still expect to see console output.
### Fixed an issue with USB drive selection for same-model USB drives
When using the UI and selecting specific USB drives to create, the UI would allow you to select multiple of the same name, but would only create one of the drives. You should now be able to multi-select multiple USB drives with the same name and they should build as expected.
### Created new docs site
[FFU Builder docs](https://rbalsleymsft.github.io/FFU/) are now available! I'm still working on adding more documentation, but the layout of the site, the prereqs, quick start, and UI overview are done. I still have some stuff to migrate from the old docx file and some deep dive stuff to write up (Drivers, Apps, FAQs, Troubleshooting, etc). It should work well on both mobile and desktop. It also has built-in search capabilities to make it easy to find what you're interested in.
## New Contributors
* @JGehl99 made their first contribution in https://github.com/rbalsleyMSFT/FFU/pull/393
**Full Changelog**: https://github.com/rbalsleyMSFT/FFU/compare/v2601.1Preview...v2602.1Preview
# 2601.1 UI Preview
## What's Changed
### Improved WinPE driver copy reliability and logging
Fixed a bug where some drivers weren't being copied into WinPE media when using **Use Drivers Folder as PE Drivers Source** option (`UseDriversAsPEDrivers` parameter)
### Improved driver injection for long driver folder paths
In some cases some drivers weren't being copied to the FFU or WinPE deployment media due to long paths. This required some significant refactoring. [See this post](https://github.com/rbalsleyMSFT/FFU/discussions/375) for more details on the changes that were made and the reasoning behind them.
### Fixed an issue with WingetWin32Apps.Json file corruption during parallel app updates
A code refactor that was done to consolidate some of the winget application download work that both the UI and BuildFFUVM.ps1 script caused an issue where parallel writes to the WingetWin32Apps.json file was causing the file to corrupt, resulting in apps not installing as expected.
### Winget App installs now follow Applist.json order
Winget application installs were installing in an indeterministic way when the WingetWin32Apps.json file was created. The order will now follow the order listed in the AppList.json file.
### Support added for Winget Win32 app dependency handling
Some apps (Camtasia) require dependency apps to be installed first. Winget will download said dependency apps. Dependent applications will now install before the calling application. There is also deduplication support added in the event multiple applications have the same dependencies.
**Full Changelog**: [https://github.com/rbalsleyMSFT/FFU/compare/v2512.1Preview...v2601.1Preview](https://github.com/rbalsleyMSFT/FFU/compare/v2512.1Preview...v2601.1Preview)
# 2512.1 UI Preview # 2512.1 UI Preview
## What's Changed ## What's Changed
+544 -75
View File
@@ -195,9 +195,11 @@ Path to a JSON file containing a list of user-defined applications to install. D
.PARAMETER USBDriveList .PARAMETER USBDriveList
A hashtable containing USB drives from win32_diskdrive where: A hashtable containing USB drives from win32_diskdrive where:
- Key: USB drive model name (partial match supported) - Key: USB drive model name (partial match supported)
- Value: USB drive serial number (trailing partial match supported due to some serial numbers ending with blank spaces) - Value: USB drive UniqueId string, or an array of UniqueIds (to support selecting multiple drives with the same model)
Example: @{ "SanDisk Ultra" = "1234567890"; "Kingston DataTraveler" = "0987654321" } Examples:
@{ "SanDisk Ultra" = "1234567890" }
@{ "SanDisk Ultra" = @("1234567890", "ABCDEFG"); "Kingston DataTraveler" = "0987654321" }
.PARAMETER MaxUSBDrives .PARAMETER MaxUSBDrives
Maximum number of USB drives to build in parallel. Default is 5. Set to 0 to process all discovered drives (or all selected drives when USBDriveList or selection is used). Actual throttle will never exceed the number of drives discovered. Maximum number of USB drives to build in parallel. Default is 5. Set to 0 to process all discovered drives (or all selected drives when USBDriveList or selection is used). Actual throttle will never exceed the number of drives discovered.
@@ -443,7 +445,7 @@ param(
[switch]$Cleanup [switch]$Cleanup
) )
$ProgressPreference = 'SilentlyContinue' $ProgressPreference = 'SilentlyContinue'
$version = '2512.1Preview' $version = '2602.1Preview'
# Remove any existing modules to avoid conflicts # Remove any existing modules to avoid conflicts
if (Get-Module -Name 'FFU.Common.Core' -ErrorAction SilentlyContinue) { if (Get-Module -Name 'FFU.Common.Core' -ErrorAction SilentlyContinue) {
@@ -568,7 +570,7 @@ class VhdxCacheItem {
#Support for ini reading #Support for ini reading
$definition = @' $definition = @'
[DllImport("kernel32.dll")] [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern uint GetPrivateProfileString( public static extern uint GetPrivateProfileString(
string lpAppName, string lpAppName,
string lpKeyName, string lpKeyName,
@@ -584,10 +586,10 @@ public static extern uint GetPrivateProfileSection(
uint nSize, uint nSize,
string lpFileName); string lpFileName);
'@ '@
Add-Type -MemberDefinition $definition -Namespace Win32 -Name Kernel32 -PassThru Add-Type -MemberDefinition $definition -Namespace Win32 -Name Kernel32 -PassThru | Out-Null
#Check if Hyper-V feature is installed (requires only checks the module) #Check if Hyper-V feature is installed (requires only checks the module)
$osInfo = Get-WmiObject -Class Win32_OperatingSystem $osInfo = Get-CimInstance -ClassName win32_OperatingSystem
$isServer = $osInfo.Caption -match 'server' $isServer = $osInfo.Caption -match 'server'
if ($isServer) { if ($isServer) {
@@ -2386,43 +2388,135 @@ function Get-Index {
[string]$WindowsSKU [string]$WindowsSKU
) )
# Get the available indexes in the WIM/ESD
# Get the available indexes using Get-WindowsImage
$imageIndexes = Get-WindowsImage -ImagePath $WindowsImagePath $imageIndexes = Get-WindowsImage -ImagePath $WindowsImagePath
# Get the ImageName of ImageIndex 1 if an ISO was specified, else use ImageIndex 4 - this is usually Home or Education SKU on ESD MCT media # Normalize SKU and determine if Desktop Experience is explicitly requested (Server only)
if ($ISOPath) { $normalizedWindowsSKU = $WindowsSKU.Trim()
if ($WindowsSKU -notmatch "Standard|Datacenter") { $isDesktopExperienceRequested = $normalizedWindowsSKU -match '\(Desktop Experience\)'
$imageIndex = $imageIndexes | Where-Object ImageIndex -eq 1 $normalizedWindowsSKU = $normalizedWindowsSKU -replace '\s*\(Desktop Experience\)\s*', ''
$WindowsImage = $imageIndex.ImageName.Substring(0, 10)
} # Map user-selected SKU to language-independent EditionId values
else { # Notes:
$imageIndex = $imageIndexes | Where-Object ImageIndex -eq 1 # - Client: EditionId values are stable across languages (e.g. Professional, Core, Education)
$WindowsImage = $imageIndex.ImageName.Substring(0, 19) # - Server: Desktop Experience vs Core is differentiated by InstallationType (EditionId is the same)
} $editionIdCandidates = switch ($normalizedWindowsSKU) {
} 'Home' { @('Core') }
else { 'Core' { @('Core') }
$imageIndex = $imageIndexes | Where-Object ImageIndex -eq 4
$WindowsImage = $imageIndex.ImageName.Substring(0, 10) 'Home N' { @('CoreN') }
'CoreN' { @('CoreN') }
'Home Single Language' { @('CoreSingleLanguage') }
'CoreSingleLanguage' { @('CoreSingleLanguage') }
'Education' { @('Education') }
'Education N' { @('EducationN') }
'EducationN' { @('EducationN') }
'Pro' { @('Professional') }
'Professional' { @('Professional') }
'Pro N' { @('ProfessionalN') }
'ProfessionalN' { @('ProfessionalN') }
'Pro Education' { @('ProfessionalEducation') }
'ProfessionalEducation' { @('ProfessionalEducation') }
'Pro Education N' { @('ProfessionalEducationN') }
'ProfessionalEducationN' { @('ProfessionalEducationN') }
'Pro for Workstations' { @('ProfessionalWorkstation') }
'ProfessionalWorkstation' { @('ProfessionalWorkstation') }
'Pro N for Workstations' { @('ProfessionalWorkstationN') }
'ProfessionalWorkstationN' { @('ProfessionalWorkstationN') }
'Enterprise' { @('Enterprise') }
'Enterprise N' { @('EnterpriseN') }
'EnterpriseN' { @('EnterpriseN') }
'Enterprise LTSC' { @('EnterpriseS') }
'Enterprise 2016 LTSB' { @('EnterpriseS') }
'EnterpriseS' { @('EnterpriseS') }
'Enterprise N LTSC' { @('EnterpriseSN') }
'Enterprise N 2016 LTSB' { @('EnterpriseSN') }
'EnterpriseSN' { @('EnterpriseSN') }
'IoT Enterprise LTSC' { @('IoTEnterpriseS') }
'IoTEnterpriseS' { @('IoTEnterpriseS') }
'IoT Enterprise N LTSC' { @('IoTEnterpriseSN') }
'IoTEnterpriseSN' { @('IoTEnterpriseSN') }
'Standard' { @('ServerStandard') }
'ServerStandard' { @('ServerStandard') }
'Datacenter' { @('ServerDatacenter') }
'ServerDatacenter' { @('ServerDatacenter') }
default { @() }
} }
# Concatenate $WindowsImage and $WindowsSKU (E.g. Windows 11 Pro) # Determine preferred InstallationType for Server images
$ImageNameToFind = "$WindowsImage $WindowsSKU" $preferredInstallationType = $null
if ($normalizedWindowsSKU -in @('Standard', 'Datacenter', 'ServerStandard', 'ServerDatacenter')) {
# Find the ImageName in all of the indexes in the image if ($isDesktopExperienceRequested) {
$matchingImageIndex = $imageIndexes | Where-Object ImageName -eq $ImageNameToFind $preferredInstallationType = 'Server'
# Return the index that matches exactly
if ($matchingImageIndex) {
return $matchingImageIndex.ImageIndex
} }
else { else {
$preferredInstallationType = 'Server Core'
}
}
# If we can map SKU -> EditionId, attempt a non-interactive match
if ($editionIdCandidates.Count -gt 0) {
# Build per-index metadata (EditionId, InstallationType) to match deterministically
$imageMetadata = @(foreach ($imageIndex in $imageIndexes) {
try {
$details = Get-WindowsImage -ImagePath $WindowsImagePath -Index $imageIndex.ImageIndex
[pscustomobject]@{
ImageIndex = $details.ImageIndex
ImageName = $details.ImageName
ImageSize = $details.ImageSize
EditionId = $details.EditionId
InstallationType = $details.InstallationType
}
}
catch {
$null
}
}) | Where-Object { $null -ne $_ }
# Match by EditionId first
$imageMatches = $imageMetadata | Where-Object { $_.EditionId -in $editionIdCandidates }
# If this is a Server SKU, prefer the requested InstallationType (Server vs Server Core)
if ($null -ne $preferredInstallationType -and $imageMatches.Count -gt 0) {
$preferredImageMatches = $imageMatches | Where-Object { $_.InstallationType -eq $preferredInstallationType }
if ($preferredImageMatches.Count -gt 0) {
$imageMatches = $preferredImageMatches
}
}
# If multiple matches remain, pick the largest image (Desktop Experience tends to be larger)
if ($imageMatches.Count -gt 0) {
$bestMatch = $imageMatches | Sort-Object -Property ImageSize -Descending | Select-Object -First 1
WriteLog "Selected Windows image index $($bestMatch.ImageIndex) (SKU='$WindowsSKU', EditionId='$($bestMatch.EditionId)', InstallationType='$($bestMatch.InstallationType)'): $($bestMatch.ImageName)"
return $bestMatch.ImageIndex
}
}
# Final fallback: prompt the user to select an ImageName
# Look for the numbers 10, 11, 2016, 2019, 2022+ in the ImageName # Look for the numbers 10, 11, 2016, 2019, 2022+ in the ImageName
$relevantImageIndexes = $imageIndexes | Where-Object { ($_.ImageName -match "(10|11|2016|2019|202\d)") } $relevantImageIndexes = $imageIndexes | Where-Object { ($_.ImageName -match "(10|11|2016|2019|202\d)") }
WriteLog "No matching image index found for SKU '$WindowsSKU' in '$WindowsImagePath'. Prompting user to select an ImageName."
while ($true) { while ($true) {
# Present list of ImageNames to the end user if no matching ImageIndex is found # Present list of ImageNames to the end user if no matching ImageIndex is found
Write-Host "No matching ImageIndex found for $ImageNameToFind. Please select an ImageName from the list below:" Write-Host "No matching ImageIndex found for Windows SKU '$WindowsSKU'. Please select an ImageName from the list below:"
$i = 1 $i = 1
$relevantImageIndexes | ForEach-Object { $relevantImageIndexes | ForEach-Object {
@@ -2437,6 +2531,7 @@ function Get-Index {
$selectedImage = $relevantImageIndexes[$inputValue - 1] $selectedImage = $relevantImageIndexes[$inputValue - 1]
if ($selectedImage) { if ($selectedImage) {
WriteLog "User selected Windows image index $($selectedImage.ImageIndex) (SKU='$WindowsSKU'): $($selectedImage.ImageName)"
return $selectedImage.ImageIndex return $selectedImage.ImageIndex
} }
else { else {
@@ -2444,7 +2539,6 @@ function Get-Index {
} }
} }
} }
}
#Create VHDX #Create VHDX
function New-ScratchVhdx { function New-ScratchVhdx {
@@ -2746,8 +2840,27 @@ function Get-PrivateProfileString {
[Parameter()] [Parameter()]
[string]$KeyName [string]$KeyName
) )
$sbuilder = [System.Text.StringBuilder]::new(1024)
[void][Win32.Kernel32]::GetPrivateProfileString($SectionName, $KeyName, "", $sbuilder, $sbuilder.Capacity, $FileName) # Read key from an INF/INI file. Use a larger buffer and allow it to grow if needed.
$bufferSize = 4096
$maxBufferSize = 65536
$sbuilder = $null
$charsCopied = 0
while ($true) {
$sbuilder = [System.Text.StringBuilder]::new($bufferSize)
$charsCopied = [Win32.Kernel32]::GetPrivateProfileString($SectionName, $KeyName, "", $sbuilder, [uint32]$sbuilder.Capacity, $FileName)
if ([int]$charsCopied -lt ($sbuilder.Capacity - 1)) {
break
}
if ($bufferSize -ge $maxBufferSize) {
break
}
$bufferSize = [Math]::Min(($bufferSize * 2), $maxBufferSize)
}
return $sbuilder.ToString() return $sbuilder.ToString()
} }
@@ -2759,21 +2872,228 @@ function Get-PrivateProfileSection {
[Parameter()] [Parameter()]
[string]$SectionName [string]$SectionName
) )
$buffer = [byte[]]::new(16384) # Read the requested section from an INF/INI file
[void][Win32.Kernel32]::GetPrivateProfileSection($SectionName, $buffer, $buffer.Length, $FileName) # Some INF sections can be large; grow the buffer to avoid truncated results
$keyValues = [System.Text.Encoding]::Unicode.GetString($buffer).TrimEnd("`0").Split("`0")
$hashTable = @{} $hashTable = @{}
$bufferSize = 16384
$buffer = $null
$charsCopied = 0
while ($true) {
$buffer = [byte[]]::new($bufferSize)
$charsCopied = [Win32.Kernel32]::GetPrivateProfileSection($SectionName, $buffer, $buffer.Length, $FileName)
# No section found or no content
if ($charsCopied -eq 0) {
return $hashTable
}
# If the returned data is close to the buffer size, assume truncation and retry bigger
if (($charsCopied -ge ($bufferSize - 2)) -and ($bufferSize -lt 1048576)) {
$bufferSize = $bufferSize * 2
continue
}
break
}
# Convert only the returned portion of the buffer (Unicode = 2 bytes per char)
$sectionText = [System.Text.Encoding]::Unicode.GetString($buffer, 0, ($charsCopied * 2))
$keyValues = $sectionText.TrimEnd("`0").Split("`0")
foreach ($keyValue in $keyValues) { foreach ($keyValue in $keyValues) {
if (![string]::IsNullOrEmpty($keyValue)) { if (![string]::IsNullOrEmpty($keyValue)) {
$parts = $keyValue -split "=" $parts = $keyValue -split "=", 2
if ($parts.Count -eq 2) {
$hashTable[$parts[0]] = $parts[1] $hashTable[$parts[0]] = $parts[1]
} }
} }
}
return $hashTable return $hashTable
} }
function Get-AvailableDriveLetter {
# Get an unused drive letter for temporary SUBST mappings
$usedLetters = (Get-PSDrive -PSProvider FileSystem).Name | ForEach-Object { $_.ToUpperInvariant() }
for ($ascii = [int][char]'Z'; $ascii -ge [int][char]'A'; $ascii--) {
$candidate = [char]$ascii
if ($usedLetters -notcontains $candidate) {
return $candidate
}
}
return $null
}
function New-DriverSubstMapping {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$SourcePath
)
# Map a long driver source folder to a short drive root using SUBST
$resolvedPath = (Resolve-Path -Path $SourcePath -ErrorAction Stop).Path
$driveLetter = Get-AvailableDriveLetter
if ($null -eq $driveLetter) {
throw 'No drive letters are available for SUBST mapping.'
}
$driveName = "$driveLetter`:"
$mappedPath = "$driveLetter`:\"
WriteLog "Mapping driver folder '$resolvedPath' to $driveName with SUBST."
$escapedPath = $resolvedPath -replace '"', '""'
$arguments = "/c subst $driveName `"$escapedPath`""
Invoke-Process -FilePath cmd.exe -ArgumentList $arguments
return [PSCustomObject]@{
DriveLetter = $driveLetter
DriveName = $driveName
DrivePath = $mappedPath
}
}
function Remove-DriverSubstMapping {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriveLetter
)
# Remove the temporary SUBST mapping
$driveName = "$DriveLetter`:"
WriteLog "Removing SUBST drive $driveName"
try {
$arguments = "/c subst $driveName /d"
Invoke-Process -FilePath cmd.exe -ArgumentList $arguments
}
catch {
WriteLog "Failed to remove SUBST drive $($driveName): $_"
}
}
function Invoke-DismDriverInjectionWithSubstLoop {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ImagePath,
[Parameter(Mandatory = $true)]
[string]$DriverRoot
)
# Resolve input paths
$resolvedImagePath = (Resolve-Path -Path $ImagePath -ErrorAction Stop).Path
$resolvedDriverRoot = (Resolve-Path -Path $DriverRoot -ErrorAction Stop).Path
# Discover INF files under the driver root
WriteLog "Scanning for INF files under: $resolvedDriverRoot"
$infFiles = Get-ChildItem -Path $resolvedDriverRoot -Filter '*.inf' -File -Recurse -ErrorAction SilentlyContinue
if ($null -eq $infFiles -or $infFiles.Count -eq 0) {
WriteLog "No INF files found under: $resolvedDriverRoot"
return
}
# Determine the deepest stable folders we can map with SUBST (SUBST has its own max path constraints)
# Strategy:
# - Start at the INF parent folder
# - If too long for SUBST, walk up until the path is short enough
# - Deduplicate and avoid redundant child folders when a parent already covers them via DISM /Recurse
$substTargetMaxLength = 240
$candidateDirs = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($infFile in $infFiles) {
$candidateDir = Split-Path -Path $infFile.FullName -Parent
while ($candidateDir.Length -gt $substTargetMaxLength) {
$parentDir = Split-Path -Path $candidateDir -Parent
if ([string]::IsNullOrWhiteSpace($parentDir) -or $parentDir -eq $candidateDir) {
break
}
$candidateDir = $parentDir
}
if ($candidateDir.Length -gt $substTargetMaxLength) {
WriteLog "Warning: Skipping INF folder due to SUBST length limit (len=$($candidateDir.Length)): $candidateDir"
continue
}
[void]$candidateDirs.Add($candidateDir)
}
$sortedCandidates = $candidateDirs | Sort-Object Length, @{ Expression = { $_ }; Ascending = $true }
$selectedDirs = [System.Collections.Generic.List[string]]::new()
foreach ($candidateDir in $sortedCandidates) {
$isCovered = $false
foreach ($selectedDir in $selectedDirs) {
if ($candidateDir.Equals($selectedDir, [System.StringComparison]::OrdinalIgnoreCase)) {
$isCovered = $true
break
}
$prefix = $selectedDir.TrimEnd('\') + '\'
if ($candidateDir.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) {
$isCovered = $true
break
}
}
if (-not $isCovered) {
[void]$selectedDirs.Add($candidateDir)
}
}
$infDirs = $selectedDirs | Sort-Object
WriteLog "Driver injection will process $($infDirs.Count) SUBST-safe folders (candidateFolders=$($candidateDirs.Count), INF total=$($infFiles.Count), substMaxLen=$substTargetMaxLength)."
# Use a single SUBST drive letter and reuse it in a loop (map -> dism -> unmap)
$driveLetter = Get-AvailableDriveLetter
if ($null -eq $driveLetter) {
throw 'No drive letters are available for SUBST mapping.'
}
$driveName = "$driveLetter`:"
$drivePath = "$driveLetter`:\"
WriteLog "Using SUBST drive $driveName for driver injection loop."
$currentIndex = 0
foreach ($infDir in $infDirs) {
$currentIndex++
$escapedPath = $infDir -replace '"', '""'
try {
WriteLog "[$currentIndex/$($infDirs.Count)] Mapping '$infDir' to $driveName with SUBST."
$mapArgs = "/c subst $driveName `"$escapedPath`""
Invoke-Process -FilePath cmd.exe -ArgumentList $mapArgs | Out-Null
# Inject drivers (do not use \\?\ with DISM)
$dismArgs = @(
"/Image:`"$resolvedImagePath`""
'/Add-Driver'
"/Driver:$drivePath"
'/Recurse'
)
WriteLog "dism.exe $($dismArgs -join ' ')"
Invoke-Process -FilePath dism.exe -ArgumentList $dismArgs | Out-Null
}
catch {
WriteLog "Warning: Driver injection failed for '$infDir': $($_.Exception.Message)"
}
finally {
try {
WriteLog "Removing SUBST drive $driveName"
$unmapArgs = "/c subst $driveName /d"
Invoke-Process -FilePath cmd.exe -ArgumentList $unmapArgs | Out-Null
}
catch {
WriteLog "Warning: Failed removing SUBST drive $($driveName): $($_.Exception.Message)"
}
}
}
WriteLog "Driver injection loop complete for $resolvedDriverRoot"
}
function Copy-Drivers { function Copy-Drivers {
param ( param (
[Parameter()] [Parameter()]
@@ -2791,54 +3111,183 @@ function Copy-Drivers {
# 745a17a0-74d3-11d0-b6fe-00a0c90f57da = Human Interface Devices # 745a17a0-74d3-11d0-b6fe-00a0c90f57da = Human Interface Devices
$filterGUIDs = @("{4D36E97D-E325-11CE-BFC1-08002BE10318}", "{4D36E97B-E325-11CE-BFC1-08002BE10318}", "{4d36e96b-e325-11ce-bfc1-08002be10318}", "{4d36e96f-e325-11ce-bfc1-08002be10318}", "{745a17a0-74d3-11d0-b6fe-00a0c90f57da}") $filterGUIDs = @("{4D36E97D-E325-11CE-BFC1-08002BE10318}", "{4D36E97B-E325-11CE-BFC1-08002BE10318}", "{4d36e96b-e325-11ce-bfc1-08002be10318}", "{4d36e96f-e325-11ce-bfc1-08002be10318}", "{745a17a0-74d3-11d0-b6fe-00a0c90f57da}")
$exclusionList = "wdmaudio.inf|Sound|Machine Learning|Camera|Firmware" $exclusionList = "wdmaudio.inf|Sound|Machine Learning|Camera|Firmware"
# Log start and validate paths
WriteLog "Copying PE drivers from '$Path' to '$Output' (WindowsArch: $WindowsArch)"
if (-not (Test-Path -Path $Path)) {
WriteLog "ERROR: Drivers source path not found: $Path"
return
}
[void](New-Item -Path $Output -ItemType Directory -Force)
$driverSourcePath = $Path
$pathLength = $Path.Length $pathLength = $Path.Length
# Determine common arch-specific SourceDisksFiles section names
# Many INFs use 'amd64' rather than 'x64' for 64-bit paths (e.g. SourceDisksFiles.amd64)
$sourceDisksFileSections = @("SourceDisksFiles")
if ($WindowsArch -eq 'x64') {
$sourceDisksFileSections += "SourceDisksFiles.amd64"
}
elseif ($WindowsArch -eq 'arm64') {
$sourceDisksFileSections += "SourceDisksFiles.arm64"
}
$infFiles = Get-ChildItem -Path $Path -Recurse -Filter "*.inf" $infFiles = Get-ChildItem -Path $Path -Recurse -Filter "*.inf"
WriteLog "Found $($infFiles.Count) INF files under: $driverSourcePath"
$matchedInfCount = 0
$skippedInfCount = 0
$copiedFileCount = 0
$errorCount = 0
for ($i = 0; $i -lt $infFiles.Count; $i++) { for ($i = 0; $i -lt $infFiles.Count; $i++) {
$infFullName = $infFiles[$i].FullName $infFullName = $infFiles[$i].FullName
# Add long path prefix to handle long paths
$longInfFullName = "\\?\$infFullName"
$infPath = Split-Path -Path $infFullName $infPath = Split-Path -Path $infFullName
$childPath = $infPath.Substring($pathLength) $childPath = $infPath.Substring($pathLength).TrimStart('\')
$targetPath = Join-Path -Path $Output -ChildPath $childPath $targetPath = Join-Path -Path $Output -ChildPath $childPath
if ((Get-PrivateProfileString -FileName $infFullName -SectionName "version" -KeyName "ClassGUID") -in $filterGUIDs) { # Log the INF files found
WriteLog "Examining PE driver INF ($($i + 1)/$($infFiles.Count)): $infFullName"
# Filter to known device classes
# Some INFs include trailing comments after the value (e.g. "{GUID} ; TODO: ..."), so normalize to the GUID token only.
$classGuidRaw = Get-PrivateProfileString -FileName $longInfFullName -SectionName "version" -KeyName "ClassGUID"
$classGuid = $classGuidRaw
if (-not [string]::IsNullOrWhiteSpace($classGuid)) {
# Remove any trailing ';' comment and trim whitespace
$classGuid = ($classGuid -split ';', 2)[0].Trim()
# Extract the GUID token if the value contains other text
if ($classGuid -match '\{[0-9A-Fa-f\-]{36}\}') {
$classGuid = $matches[0]
}
}
# WriteLog "ClassGUID: $classGuid"
if ($classGuid -notin $filterGUIDs) {
# WriteLog "Skipping PE driver INF due to GUID: $infFullName"
$skippedInfCount++
continue
}
# Avoid drivers that reference keywords from the exclusion list to keep the total size small # Avoid drivers that reference keywords from the exclusion list to keep the total size small
if (((Get-Content -Path $infFullName) -match $exclusionList).Length -eq 0) { if (((Get-Content -Path $infFullName) -match $exclusionList).Length -ne 0) {
$providerName = (Get-PrivateProfileString -FileName $infFullName -SectionName "Version" -KeyName "Provider").Trim("%") WriteLog "Skipping PE driver INF due to exclusion match: $infFullName"
$skippedInfCount++
continue
}
$matchedInfCount++
# Log the INF being processed
$providerName = (Get-PrivateProfileString -FileName $longInfFullName -SectionName "Version" -KeyName "Provider").Trim("%")
if ([string]::IsNullOrWhiteSpace($providerName)) {
$providerName = "Unknown Provider"
}
WriteLog "Processing PE driver INF: $infFullName"
WriteLog "Provider: $providerName | ClassGUID: $classGuid"
WriteLog "Target folder: $targetPath"
WriteLog "Copying PE drivers for $providerName"
WriteLog "Driver inf is: $infFullName"
[void](New-Item -Path $targetPath -ItemType Directory -Force) [void](New-Item -Path $targetPath -ItemType Directory -Force)
Copy-Item -Path $infFullName -Destination $targetPath -Force
$CatalogFileName = Get-PrivateProfileString -FileName $infFullName -SectionName "version" -KeyName "Catalogfile"
Copy-Item -Path "$infPath\$CatalogFileName" -Destination $targetPath -Force
$sourceDiskFiles = Get-PrivateProfileSection -FileName $infFullName -SectionName "SourceDisksFiles" # Copy the INF itself
foreach ($sourceDiskFile in $sourceDiskFiles.Keys) { try {
if (!$sourceDiskFiles[$sourceDiskFile].Contains(",")) { Copy-Item -LiteralPath "$infFullName" -Destination "$targetPath" -Force -ErrorAction Stop
Copy-Item -Path "$infPath\$sourceDiskFile" -Destination $targetPath -Force $copiedFileCount++
WriteLog "Copied: $infFullName -> $targetPath"
}
catch {
$errorCount++
WriteLog "ERROR: Failed to copy INF '$infFullName' to '$targetPath': $($_.Exception.Message)"
}
# Copy the catalog file (if specified)
$CatalogFileName = Get-PrivateProfileString -FileName $longInfFullName -SectionName "version" -KeyName "Catalogfile"
if (-not [string]::IsNullOrWhiteSpace($CatalogFileName)) {
$catalogSource = Join-Path -Path $infPath -ChildPath $CatalogFileName
if (Test-Path -Path $catalogSource) {
try {
Copy-Item -LiteralPath "$catalogSource" -Destination "$targetPath" -Force -ErrorAction Stop
$copiedFileCount++
WriteLog "Copied: $catalogSource -> $targetPath"
}
catch {
$errorCount++
WriteLog "ERROR: Failed to copy catalog '$catalogSource' to '$targetPath': $($_.Exception.Message)"
}
} }
else { else {
$subdir = ($sourceDiskFiles[$sourceDiskFile] -split ",")[1] $errorCount++
[void](New-Item -Path "$targetPath\$subdir" -ItemType Directory -Force) WriteLog "ERROR: Catalog file not found: $catalogSource (INF: $infFullName)"
Copy-Item -Path "$infPath\$subdir\$sourceDiskFile" -Destination "$targetPath\$subdir" -Force }
}
else {
WriteLog "WARNING: No CatalogFile entry found in INF: $infFullName"
}
# Copy all files referenced by SourceDisksFiles sections
foreach ($sectionName in $sourceDisksFileSections) {
$sourceDiskFiles = Get-PrivateProfileSection -FileName $longInfFullName -SectionName $sectionName
if ($sourceDiskFiles.Count -eq 0) {
continue
}
WriteLog "Copying files from INF section [$sectionName] ($($sourceDiskFiles.Count) entries)"
foreach ($sourceDiskFile in $sourceDiskFiles.Keys) {
# Determine if the file lives in a subfolder relative to the INF path
$rawValue = $sourceDiskFiles[$sourceDiskFile]
$subdir = ""
if (($null -ne $rawValue) -and ($rawValue.Contains(","))) {
$splitParts = $rawValue -split ","
if ($splitParts.Count -ge 2) {
$subdir = $splitParts[1]
} }
} }
#Arch specific files override the files specified in the universal section if ([string]::IsNullOrWhiteSpace($subdir)) {
$sourceDiskFiles = Get-PrivateProfileSection -FileName $infFullName -SectionName "SourceDisksFiles.$WindowsArch" $subdir = ""
foreach ($sourceDiskFile in $sourceDiskFiles.Keys) { }
if (!$sourceDiskFiles[$sourceDiskFile].Contains(",")) {
Copy-Item -Path "$infPath\$sourceDiskFile" -Destination $targetPath -Force # Build source and destination paths
if ([string]::IsNullOrEmpty($subdir)) {
$sourceFilePath = Join-Path -Path $infPath -ChildPath $sourceDiskFile
$destinationFolder = $targetPath
} }
else { else {
$subdir = ($sourceDiskFiles[$sourceDiskFile] -split ",")[1] $sourceFolder = Join-Path -Path $infPath -ChildPath $subdir
[void](New-Item -Path "$targetPath\$subdir" -ItemType Directory -Force) $sourceFilePath = Join-Path -Path $sourceFolder -ChildPath $sourceDiskFile
Copy-Item -Path "$infPath\$subdir\$sourceDiskFile" -Destination "$targetPath\$subdir" -Force $destinationFolder = Join-Path -Path $targetPath -ChildPath $subdir
} [void](New-Item -Path $destinationFolder -ItemType Directory -Force)
}
# Copy with logging and error handling
if (Test-Path -Path $sourceFilePath) {
try {
Copy-Item -LiteralPath "$sourceFilePath" -Destination "$destinationFolder" -Force -ErrorAction Stop
$copiedFileCount++
WriteLog "Copied: $sourceFilePath -> $destinationFolder"
}
catch {
$errorCount++
WriteLog "ERROR: Failed to copy '$sourceFilePath' to '$destinationFolder' (INF: $infFullName): $($_.Exception.Message)"
}
}
else {
$errorCount++
WriteLog "ERROR: Source file not found for [$sectionName] entry '$sourceDiskFile': $sourceFilePath (INF: $infFullName)"
} }
} }
} }
} }
# Final summary
WriteLog "PE driver copy summary: INF total=$($infFiles.Count) matched=$matchedInfCount skipped=$skippedInfCount filesCopied=$copiedFileCount errors=$errorCount"
} }
function New-PEMedia { function New-PEMedia {
@@ -2945,7 +3394,10 @@ function New-PEMedia {
WriteLog "Adding drivers to WinPE media" WriteLog "Adding drivers to WinPE media"
try { try {
Add-WindowsDriver -Path "$WinPEFFUPath\Mount" -Driver $PEDriversFolder -Recurse -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-null $WinPEMount = "$WinPEFFUPath\Mount"
# Inject drivers using deep SUBST mapping (reuse one drive letter and loop each INF folder)
Invoke-DismDriverInjectionWithSubstLoop -ImagePath $WinPEMount -DriverRoot $PEDriversFolder
} }
catch { catch {
WriteLog 'Some drivers failed to be added. This can be expected. Continuing.' WriteLog 'Some drivers failed to be added. This can be expected. Continuing.'
@@ -3247,7 +3699,8 @@ function New-FFU {
WriteLog 'Mounting complete' WriteLog 'Mounting complete'
WriteLog 'Adding drivers - This will take a few minutes, please be patient' WriteLog 'Adding drivers - This will take a few minutes, please be patient'
try { try {
Add-WindowsDriver -Path "$FFUDevelopmentPath\Mount" -Driver "$DriversFolder" -Recurse -ErrorAction SilentlyContinue -WarningAction SilentlyContinue | Out-null # Inject drivers using deep SUBST mapping (reuse one drive letter and loop each INF folder)
Invoke-DismDriverInjectionWithSubstLoop -ImagePath "$FFUDevelopmentPath\Mount" -DriverRoot "$DriversFolder"
} }
catch { catch {
WriteLog 'Some drivers failed to be added to the FFU. This can be expected. Continuing.' WriteLog 'Some drivers failed to be added to the FFU. This can be expected. Continuing.'
@@ -3406,7 +3859,7 @@ Function Get-USBDrive {
# Check if external hard disk media is allowed and user has not specified USB drives # Check if external hard disk media is allowed and user has not specified USB drives
If ($AllowExternalHardDiskMedia -and (-not($USBDriveList))) { If ($AllowExternalHardDiskMedia -and (-not($USBDriveList))) {
# Get all removable and external hard disk media drives # Get all removable and external hard disk media drives
[array]$USBDrives = (Get-WmiObject -Class Win32_DiskDrive -Filter "MediaType='Removable Media' OR MediaType='External hard disk media'") [array]$USBDrives = (Get-CimInstance -ClassName Win32_DiskDrive -Filter "MediaType='Removable Media' OR MediaType='External hard disk media'")
[array]$ExternalHardDiskDrives = $USBDrives | Where-Object { $_.MediaType -eq 'External hard disk media' } [array]$ExternalHardDiskDrives = $USBDrives | Where-Object { $_.MediaType -eq 'External hard disk media' }
$ExternalCount = $ExternalHardDiskDrives.Count $ExternalCount = $ExternalHardDiskDrives.Count
$USBDrivesCount = $USBDrives.Count $USBDrivesCount = $USBDrives.Count
@@ -3510,12 +3963,23 @@ Function Get-USBDrive {
} }
elseif ($USBDriveList) { elseif ($USBDriveList) {
# Log the count of specified USB drives # Log the count of specified USB drives
$USBDriveListCount = $USBDriveList.Count # USBDriveList values can be a single UniqueId string, or an array of UniqueIds (multiple same-model drives)
$USBDriveListCount = 0
foreach ($model in $USBDriveList.Keys) {
$USBDriveListCount += @($USBDriveList[$model]).Count
}
WriteLog "Looking for $USBDriveListCount USB drives from USB Drive List" WriteLog "Looking for $USBDriveListCount USB drives from USB Drive List"
# Get only the specified USB drives based on model and UniqueId # Get only the specified USB drives based on model and UniqueId
$USBDrives = @() $USBDrives = @()
foreach ($model in $USBDriveList.Keys) { foreach ($model in $USBDriveList.Keys) {
$configUniqueId = $USBDriveList[$model] $configUniqueIds = @($USBDriveList[$model])
foreach ($configUniqueId in $configUniqueIds) {
if ([string]::IsNullOrWhiteSpace([string]$configUniqueId)) {
continue
}
WriteLog "Looking for USB drive model $model with UniqueId $configUniqueId" WriteLog "Looking for USB drive model $model with UniqueId $configUniqueId"
# First get candidate drives by model and media type # First get candidate drives by model and media type
$candidateDrives = Get-CimInstance -ClassName Win32_DiskDrive -Filter "Model LIKE '%$model%' AND (MediaType='Removable Media' OR MediaType='External hard disk media')" $candidateDrives = Get-CimInstance -ClassName Win32_DiskDrive -Filter "Model LIKE '%$model%' AND (MediaType='Removable Media' OR MediaType='External hard disk media')"
@@ -3539,19 +4003,25 @@ Function Get-USBDrive {
} }
} }
if ($foundDrive) { if ($foundDrive) {
if ($USBDrives.Index -notcontains $foundDrive.Index) {
WriteLog "Found USB drive model $($foundDrive.Model) with UniqueId $configUniqueId" WriteLog "Found USB drive model $($foundDrive.Model) with UniqueId $configUniqueId"
$USBDrives += $foundDrive $USBDrives += $foundDrive
} }
else {
WriteLog "USB drive model $($foundDrive.Model) with UniqueId $configUniqueId was already added. Skipping duplicate."
}
}
else { else {
WriteLog "USB drive model $model with UniqueId $configUniqueId not found" WriteLog "USB drive model $model with UniqueId $configUniqueId not found"
} }
} }
}
$USBDrivesCount = $USBDrives.Count $USBDrivesCount = $USBDrives.Count
WriteLog "Found $USBDrivesCount of $USBDriveListCount USB drives from USB Drive List" WriteLog "Found $USBDrivesCount of $USBDriveListCount USB drives from USB Drive List"
} }
else { else {
# Get only removable media drives # Get only removable media drives
[array]$USBDrives = (Get-WmiObject -Class Win32_DiskDrive -Filter "MediaType='Removable Media'") [array]$USBDrives = (Get-CimInstance -ClassName Win32_DiskDrive -Filter "MediaType='Removable Media'")
$USBDrivesCount = $USBDrives.Count $USBDrivesCount = $USBDrives.Count
WriteLog "Found $USBDrivesCount Removable USB drives" WriteLog "Found $USBDrivesCount Removable USB drives"
} }
@@ -3671,8 +4141,8 @@ Function New-DeploymentUSB {
$BootPartition = $Disk | New-Partition -Size 2GB -IsActive -AssignDriveLetter $BootPartition = $Disk | New-Partition -Size 2GB -IsActive -AssignDriveLetter
$DeployPartition = $Disk | New-Partition -UseMaximumSize -AssignDriveLetter $DeployPartition = $Disk | New-Partition -UseMaximumSize -AssignDriveLetter
Format-Volume -Partition $BootPartition -FileSystem FAT32 -NewFileSystemLabel "TempBoot" -Confirm:$false Format-Volume -Partition $BootPartition -FileSystem FAT32 -NewFileSystemLabel "TempBoot" -Confirm:$false | Out-Null
Format-Volume -Partition $DeployPartition -FileSystem NTFS -NewFileSystemLabel "TempDeploy" -Confirm:$false Format-Volume -Partition $DeployPartition -FileSystem NTFS -NewFileSystemLabel "TempDeploy" -Confirm:$false | Out-Null
$BootPartitionDriveLetter = "$($BootPartition.DriveLetter):\" $BootPartitionDriveLetter = "$($BootPartition.DriveLetter):\"
$DeployPartitionDriveLetter = "$($DeployPartition.DriveLetter):\" $DeployPartitionDriveLetter = "$($DeployPartition.DriveLetter):\"
@@ -6068,7 +6538,7 @@ try {
WriteLog 'Using cached VHDX file to speed up build process' WriteLog 'Using cached VHDX file to speed up build process'
WriteLog "VHDX file is: $($cachedVHDXInfo.VhdxFileName)" WriteLog "VHDX file is: $($cachedVHDXInfo.VhdxFileName)"
Robocopy.exe $($VHDXCacheFolder) $($VMPath) $($cachedVHDXInfo.VhdxFileName) /E /COPY:DAT /R:5 /W:5 /J Robocopy.exe $($VHDXCacheFolder) $($VMPath) $($cachedVHDXInfo.VhdxFileName) /E /COPY:DAT /R:5 /W:5 /J | Out-Null
$VHDXPath = Join-Path $($VMPath) $($cachedVHDXInfo.VhdxFileName) $VHDXPath = Join-Path $($VMPath) $($cachedVHDXInfo.VhdxFileName)
$vhdxDisk = Get-VHD -Path $VHDXPath | Mount-VHD -Passthru | Get-Disk $vhdxDisk = Get-VHD -Path $VHDXPath | Mount-VHD -Passthru | Get-Disk
@@ -6098,7 +6568,7 @@ try {
WriteLog 'Copying to cache dir' WriteLog 'Copying to cache dir'
#Assuming there are now name collisons #Assuming there are now name collisons
Robocopy.exe $($VMPath) $($VHDXCacheFolder) $("$VMName.vhdx") /E /COPY:DAT /R:5 /W:5 /J Robocopy.exe $($VMPath) $($VHDXCacheFolder) $("$VMName.vhdx") /E /COPY:DAT /R:5 /W:5 /J | Out-Null
#Only create new instance if not created during patching #Only create new instance if not created during patching
if ($null -eq $cachedVHDXInfo) { if ($null -eq $cachedVHDXInfo) {
@@ -6243,7 +6713,6 @@ try {
New-FFU $FFUVM.Name New-FFU $FFUVM.Name
} }
else { else {
Set-Progress -Percentage 81 -Message "Starting FFU capture from VHDX..."
#Shorten Windows SKU for use in FFU file name to remove spaces and long names #Shorten Windows SKU for use in FFU file name to remove spaces and long names
WriteLog "Shortening Windows SKU: $WindowsSKU for FFU file name" WriteLog "Shortening Windows SKU: $WindowsSKU for FFU file name"
$shortenedWindowsSKU = Get-ShortenedWindowsSKU -WindowsSKU $WindowsSKU $shortenedWindowsSKU = Get-ShortenedWindowsSKU -WindowsSKU $WindowsSKU
+112 -102
View File
@@ -45,6 +45,7 @@ $script:uiState = [PSCustomObject]@{
logData = $null; logData = $null;
logStreamReader = $null; logStreamReader = $null;
pollTimer = $null; pollTimer = $null;
currentBuildProcess = $null;
lastConfigFilePath = $null lastConfigFilePath = $null
}; };
Flags = @{ Flags = @{
@@ -148,7 +149,7 @@ $script:uiState.Controls.btnRun.Add_Click({
if ($script:uiState.Flags.isBuilding -and -not $script:uiState.Flags.isCleanupRunning) { if ($script:uiState.Flags.isBuilding -and -not $script:uiState.Flags.isCleanupRunning) {
$btnRun.IsEnabled = $false $btnRun.IsEnabled = $false
$script:uiState.Controls.txtStatus.Text = "Cancel requested. Stopping build..." $script:uiState.Controls.txtStatus.Text = "Cancel requested. Stopping build..."
WriteLog "Cancel requested by user. Stopping background build job." WriteLog "Cancel requested by user. Stopping background build process."
# Stop the timer # Stop the timer
if ($null -ne $script:uiState.Data.pollTimer) { if ($null -ne $script:uiState.Data.pollTimer) {
@@ -163,27 +164,12 @@ $script:uiState.Controls.btnRun.Add_Click({
$script:uiState.Data.logStreamReader = $null $script:uiState.Data.logStreamReader = $null
} }
# Stop and remove the running build job # Stop the running build process
$jobToStop = $script:uiState.Data.currentBuildJob $processToStop = $script:uiState.Data.currentBuildProcess
$script:uiState.Data.currentBuildJob = $null $script:uiState.Data.currentBuildProcess = $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 if ($null -ne $processToStop) {
try { # Recursively terminate the build process and any children (DISM, setup tools, etc.)
$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 { function Stop-ProcessTree {
param([int]$parentPid) param([int]$parentPid)
$children = Get-CimInstance Win32_Process -Filter "ParentProcessId=$parentPid" -ErrorAction SilentlyContinue $children = Get-CimInstance Win32_Process -Filter "ParentProcessId=$parentPid" -ErrorAction SilentlyContinue
@@ -192,11 +178,14 @@ $script:uiState.Controls.btnRun.Add_Click({
} }
try { Stop-Process -Id $parentPid -Force -ErrorAction SilentlyContinue } catch {} try { Stop-Process -Id $parentPid -Force -ErrorAction SilentlyContinue } catch {}
} }
Stop-ProcessTree -parentPid $jobProcId
} try {
Stop-ProcessTree -parentPid $processToStop.Id
WriteLog "Background build process stopped (PID: $($processToStop.Id))."
} }
catch { catch {
WriteLog "Error terminating job process tree: $($_.Exception.Message)" WriteLog "Error terminating build process tree: $($_.Exception.Message)"
}
} }
# Safety net: kill any active DISM capture still running # Safety net: kill any active DISM capture still running
@@ -242,15 +231,6 @@ $script:uiState.Controls.btnRun.Add_Click({
WriteLog "Error stopping Office setup.exe processes: $($_.Exception.Message)" 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 # Start cleanup using the same BuildFFUVM.ps1 via -Cleanup short-circuit
$lastConfigPath = $script:uiState.Data.lastConfigFilePath $lastConfigPath = $script:uiState.Data.lastConfigFilePath
if ([string]::IsNullOrWhiteSpace($lastConfigPath)) { if ([string]::IsNullOrWhiteSpace($lastConfigPath)) {
@@ -289,13 +269,39 @@ $script:uiState.Controls.btnRun.Add_Click({
CleanupCurrentRunDownloads = $removeCurrentRunToo CleanupCurrentRunDownloads = $removeCurrentRunToo
} }
$cleanupScriptBlock = { # Start cleanup in a separate pwsh process so the UI stays responsive
param($buildParams, $PSScriptRoot) $pwshPath = Join-Path -Path $PSHOME -ChildPath 'pwsh.exe'
& "$PSScriptRoot\BuildFFUVM.ps1" @buildParams if (-not (Test-Path -Path $pwshPath)) {
$pwshPath = 'pwsh'
} }
# Start cleanup job $cleanupScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'BuildFFUVM.ps1'
$script:uiState.Data.currentBuildJob = Start-Job -ScriptBlock $cleanupScriptBlock -ArgumentList @($cleanupParams, $PSScriptRoot)
# Build argument list for cleanup.
# -Cleanup is a [switch] in BuildFFUVM.ps1, so do not pass a value after it.
# Use -Param:$true/$false syntax for boolean parameters to avoid argument transformation errors.
$cleanupArgs = @(
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-File', $cleanupScriptPath,
'-ConfigFile', $cleanupParams.ConfigFile,
'-Cleanup',
"-RemoveApps:$($cleanupParams.RemoveApps)",
"-RemoveUpdates:$($cleanupParams.RemoveUpdates)",
"-CleanupDrivers:$($cleanupParams.CleanupDrivers)",
"-CleanupCurrentRunDownloads:$($cleanupParams.CleanupCurrentRunDownloads)"
)
$startCleanupParams = @{
FilePath = $pwshPath
ArgumentList = $cleanupArgs
PassThru = $true
}
if ($Host.Name -eq 'ConsoleHost') {
$startCleanupParams['NoNewWindow'] = $true
}
$script:uiState.Data.currentBuildProcess = Start-Process @startCleanupParams
# Wait for log file to appear (or open immediately if it exists) # Wait for log file to appear (or open immediately if it exists)
$logWaitTimeout = 60 $logWaitTimeout = 60
@@ -315,14 +321,14 @@ $script:uiState.Controls.btnRun.Add_Click({
WriteLog "Warning: Main log file not found at $mainLogPath after waiting. Monitor tab will not update during cleanup." 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 # Create a timer to poll the cleanup process
$script:uiState.Data.pollTimer = New-Object System.Windows.Threading.DispatcherTimer $script:uiState.Data.pollTimer = New-Object System.Windows.Threading.DispatcherTimer
$script:uiState.Data.pollTimer.Interval = [TimeSpan]::FromSeconds(1) $script:uiState.Data.pollTimer.Interval = [TimeSpan]::FromSeconds(1)
$script:uiState.Flags.isCleanupRunning = $true $script:uiState.Flags.isCleanupRunning = $true
$script:uiState.Data.pollTimer.Add_Tick({ $script:uiState.Data.pollTimer.Add_Tick({
param($sender, $e) param($sender, $e)
$currentJob = $script:uiState.Data.currentBuildJob $currentProcess = $script:uiState.Data.currentBuildProcess
# Read new lines from log # Read new lines from log
if ($null -ne $script:uiState.Data.logStreamReader) { if ($null -ne $script:uiState.Data.logStreamReader) {
@@ -335,13 +341,13 @@ $script:uiState.Controls.btnRun.Add_Click({
} }
} }
if ($null -eq $currentJob -or $null -eq $script:uiState.Data.pollTimer) { if ($null -eq $currentProcess -or $null -eq $script:uiState.Data.pollTimer) {
if ($null -ne $sender) { $sender.Stop() } if ($null -ne $sender) { $sender.Stop() }
$script:uiState.Data.pollTimer = $null $script:uiState.Data.pollTimer = $null
return return
} }
if ($currentJob.State -in 'Completed', 'Failed', 'Stopped') { if ($currentProcess.HasExited) {
if ($null -ne $sender) { $sender.Stop() } if ($null -ne $sender) { $sender.Stop() }
$script:uiState.Data.pollTimer = $null $script:uiState.Data.pollTimer = $null
@@ -364,10 +370,8 @@ $script:uiState.Controls.btnRun.Add_Click({
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed' $script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
$script:uiState.Controls.pbOverallProgress.Value = 0 $script:uiState.Controls.pbOverallProgress.Value = 0
# Receive and remove cleanup job # Clear cleanup process state
$currentJob | Receive-Job -ErrorAction SilentlyContinue | Out-Null $script:uiState.Data.currentBuildProcess = $null
Remove-Job -Job $currentJob -Force
$script:uiState.Data.currentBuildJob = $null
# Reset flags and button # Reset flags and button
$script:uiState.Flags.isCleanupRunning = $false $script:uiState.Flags.isCleanupRunning = $false
@@ -425,33 +429,44 @@ $script:uiState.Controls.btnRun.Add_Click({
$txtStatus.Text = "Executing BuildFFUVM.ps1 in the background..." $txtStatus.Text = "Executing BuildFFUVM.ps1 in the background..."
WriteLog "Executing BuildFFUVM.ps1 in the background..." WriteLog "Executing BuildFFUVM.ps1 in the background..."
# Prepare parameters for splatting # Start BuildFFUVM.ps1 in a separate pwsh process.
$buildParams = @{ # This keeps the UI responsive and restores console interaction (Write-Host / Read-Host) when available.
ConfigFile = $configFilePath $pwshPath = Join-Path -Path $PSHOME -ChildPath 'pwsh.exe'
if (-not (Test-Path -Path $pwshPath)) {
$pwshPath = 'pwsh'
} }
$buildScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'BuildFFUVM.ps1'
$pwshArgs = @(
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-File', $buildScriptPath,
'-ConfigFile', $configFilePath
)
if ($config.Verbose) { if ($config.Verbose) {
$buildParams['Verbose'] = $true $pwshArgs += '-Verbose'
} }
# Define the script block to run in the background job # Delete the old log file before starting the build process to ensure we don't read stale content.
$scriptBlock = {
param($buildParams, $PSScriptRoot)
# This script runs in a new process. BuildFFUVM.ps1 is expected to handle its own module imports.
& "$PSScriptRoot\BuildFFUVM.ps1" @buildParams
}
# Delete the old log file before starting the build job to ensure we don't read stale content.
$mainLogPath = Join-Path $config.FFUDevelopmentPath "FFUDevelopment.log" $mainLogPath = Join-Path $config.FFUDevelopmentPath "FFUDevelopment.log"
if (Test-Path $mainLogPath) { if (Test-Path $mainLogPath) {
WriteLog "Removing old FFUDevelopment.log file." WriteLog "Removing old FFUDevelopment.log file."
Remove-Item -Path $mainLogPath -Force Remove-Item -Path $mainLogPath -Force
} }
# Start the job and store it in the shared state object $startBuildParams = @{
$script:uiState.Data.currentBuildJob = Start-Job -ScriptBlock $scriptBlock -ArgumentList @($buildParams, $PSScriptRoot) FilePath = $pwshPath
ArgumentList = $pwshArgs
PassThru = $true
}
if ($Host.Name -eq 'ConsoleHost') {
$startBuildParams['NoNewWindow'] = $true
}
# Wait for the new log file to be created by the background job. # Start the build process and store it in the shared state object
$script:uiState.Data.currentBuildProcess = Start-Process @startBuildParams
# Wait for the new log file to be created by the background process.
$logWaitTimeout = 15 # seconds $logWaitTimeout = 15 # seconds
$watch = [System.Diagnostics.Stopwatch]::StartNew() $watch = [System.Diagnostics.Stopwatch]::StartNew()
while (-not (Test-Path $mainLogPath) -and $watch.Elapsed.TotalSeconds -lt $logWaitTimeout) { while (-not (Test-Path $mainLogPath) -and $watch.Elapsed.TotalSeconds -lt $logWaitTimeout) {
@@ -476,7 +491,7 @@ $script:uiState.Controls.btnRun.Add_Click({
$script:uiState.Data.pollTimer.Add_Tick({ $script:uiState.Data.pollTimer.Add_Tick({
param($sender, $e) param($sender, $e)
# This scriptblock runs on the UI thread, so it can safely access script-scoped variables # This scriptblock runs on the UI thread, so it can safely access script-scoped variables
$currentJob = $script:uiState.Data.currentBuildJob $currentProcess = $script:uiState.Data.currentBuildProcess
# Read from log stream # Read from log stream
if ($null -ne $script:uiState.Data.logStreamReader) { if ($null -ne $script:uiState.Data.logStreamReader) {
@@ -500,8 +515,8 @@ $script:uiState.Controls.btnRun.Add_Click({
} }
} }
# If job is somehow null or the timer has been nulled out, stop the timer # If process is somehow null or the timer has been nulled out, stop the timer
if ($null -eq $currentJob -or $null -eq $script:uiState.Data.pollTimer) { if ($null -eq $currentProcess -or $null -eq $script:uiState.Data.pollTimer) {
if ($null -ne $sender) { if ($null -ne $sender) {
$sender.Stop() $sender.Stop()
} }
@@ -509,8 +524,8 @@ $script:uiState.Controls.btnRun.Add_Click({
return return
} }
# Check if the job has reached a terminal state # Check if the build process has exited
if ($currentJob.State -in 'Completed', 'Failed', 'Stopped') { if ($currentProcess.HasExited) {
# Stop the timer, we're done polling # Stop the timer, we're done polling
if ($null -ne $sender) { if ($null -ne $sender) {
$sender.Stop() $sender.Stop()
@@ -546,42 +561,26 @@ $script:uiState.Controls.btnRun.Add_Click({
$script:uiState.Data.logStreamReader = $null $script:uiState.Data.logStreamReader = $null
} }
# Determine final status based on job result and whether cleanup was running (should be false here) $exitCode = $currentProcess.ExitCode
# Determine final status based on process exit code
$finalStatusText = "FFU build completed successfully." $finalStatusText = "FFU build completed successfully."
if ($currentJob.State -eq 'Failed') { if ($exitCode -ne 0) {
$reason = $null
Receive-Job -Job $currentJob -Keep -ErrorVariable jobErrors -ErrorAction SilentlyContinue | Out-Null
if ($null -ne $jobErrors -and $jobErrors.Count -gt 0) {
$reason = ($jobErrors | Select-Object -Last 1).ToString()
}
if ([string]::IsNullOrWhiteSpace($reason) -and $currentJob.JobStateInfo.Reason) {
$reason = $currentJob.JobStateInfo.Reason.Message
}
if ([string]::IsNullOrWhiteSpace($reason)) {
$reason = "An unknown error occurred. The job failed without a specific reason."
}
$finalStatusText = "FFU build failed. Check FFUDevelopment.log for details." $finalStatusText = "FFU build failed. Check FFUDevelopment.log for details."
WriteLog "BuildFFUVM.ps1 job failed. Reason: $reason" WriteLog "BuildFFUVM.ps1 process failed with exit code: $exitCode"
[System.Windows.MessageBox]::Show("The build process failed. Please check the $FFUDevelopmentPath\FFUDevelopment.log file for details.`n`nError: $reason", "Build Error", "OK", "Error") | Out-Null [System.Windows.MessageBox]::Show("The build process failed. Please check the $FFUDevelopmentPath\FFUDevelopment.log file for details.`n`nExit code: $exitCode", "Build Error", "OK", "Error") | Out-Null
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed' $script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
} }
else { else {
WriteLog "BuildFFUVM.ps1 job completed successfully." WriteLog "BuildFFUVM.ps1 process completed successfully."
$script:uiState.Controls.pbOverallProgress.Value = 100 $script:uiState.Controls.pbOverallProgress.Value = 100
} }
# Update UI elements # Update UI elements
$script:uiState.Controls.txtStatus.Text = $finalStatusText $script:uiState.Controls.txtStatus.Text = $finalStatusText
# Receive & remove job and clear state # Clear process state
$currentJob | Receive-Job -ErrorAction SilentlyContinue | Out-Null $script:uiState.Data.currentBuildProcess = $null
Remove-Job -Job $currentJob -Force
$script:uiState.Data.currentBuildJob = $null
# Reset button and flags for next run # Reset button and flags for next run
$script:uiState.Flags.isBuilding = $false $script:uiState.Flags.isBuilding = $false
@@ -640,9 +639,9 @@ $window.Add_SourceInitialized({
# Register cleanup to reclaim memory and revert LongPathsEnabled setting when the UI window closes # Register cleanup to reclaim memory and revert LongPathsEnabled setting when the UI window closes
$window.Add_Closed({ $window.Add_Closed({
# Stop any running build job if the window is closed # Stop any running build process if the window is closed
if ($null -ne $script:uiState.Data.currentBuildJob) { if ($null -ne $script:uiState.Data.currentBuildProcess) {
WriteLog "UI closing, stopping background build job." WriteLog "UI closing, stopping background build process."
# Stop the timer # Stop the timer
if ($null -ne $script:uiState.Data.pollTimer) { if ($null -ne $script:uiState.Data.pollTimer) {
@@ -657,17 +656,28 @@ $window.Add_Closed({
$script:uiState.Data.logStreamReader = $null $script:uiState.Data.logStreamReader = $null
} }
# Stop and remove the job $processToStop = $script:uiState.Data.currentBuildProcess
$jobToStop = $script:uiState.Data.currentBuildJob $script:uiState.Data.currentBuildProcess = $null
$script:uiState.Data.currentBuildJob = $null # Clear it from state first
try { try {
Stop-Job -Job $jobToStop # Terminate the build process and any children
Remove-Job -Job $jobToStop function Stop-ProcessTree {
WriteLog "Background job stopped and removed." 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 {}
}
if ($null -ne $processToStop -and -not $processToStop.HasExited) {
Stop-ProcessTree -parentPid $processToStop.Id
}
WriteLog "Background process stopped."
} }
catch { catch {
WriteLog "Error stopping or removing background job: $($_.Exception.Message)" WriteLog "Error stopping background build process: $($_.Exception.Message)"
} }
} }
@@ -0,0 +1,591 @@
<#
.SYNOPSIS
Common Microsoft/Surface driver helpers (cache index, SKU mapping).
.DESCRIPTION
This module contains Microsoft/Surface-specific functions used by the UI and scripts
to map Surface driver packs to System SKU values using:
- Source A: Surface System SKU reference (Learn)
- Source B: Support page model list
- Source C: Download Center details (window.__DLCDetails__)
#>
# --------------------------------------------------------------------------
# SECTION: Microsoft Surface Driver Index Cache (Sources A/B/C)
# --------------------------------------------------------------------------
function Get-SurfaceDriverIndexCachePath {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder
)
# Store the cache under Drivers\Microsoft so it travels with the driver content
$microsoftDriversFolder = Join-Path -Path $DriversFolder -ChildPath 'Microsoft'
if (-not (Test-Path -Path $microsoftDriversFolder -PathType Container)) {
New-Item -Path $microsoftDriversFolder -ItemType Directory -Force | Out-Null
}
return (Join-Path -Path $microsoftDriversFolder -ChildPath 'SurfaceDriverIndex.json')
}
function Import-SurfaceDriverIndexCache {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder
)
$cachePath = Get-SurfaceDriverIndexCachePath -DriversFolder $DriversFolder
# Surface cache TTL (7 days): treat stale caches as missing so we re-download Sources A/B/C as needed.
$cacheTtlDays = 7
if (-not (Test-Path -Path $cachePath -PathType Leaf)) {
return [pscustomobject]@{
ModelIndex = @()
SkuIndex = @()
DownloadCenterDetails = @()
}
}
try {
$cacheAgeDays = ((Get-Date) - (Get-Item -Path $cachePath -ErrorAction Stop).LastWriteTime).TotalDays
if ($cacheAgeDays -ge $cacheTtlDays) {
WriteLog "Surface cache: Cache file '$cachePath' is older than $cacheTtlDays days ($([math]::Round($cacheAgeDays, 1)) days). Refreshing."
return [pscustomobject]@{
ModelIndex = @()
SkuIndex = @()
DownloadCenterDetails = @()
}
}
WriteLog "Surface cache: Loading cached SurfaceDriverIndex.json from '$cachePath' (age: $([math]::Round($cacheAgeDays, 1)) days)."
}
catch {
WriteLog "Surface cache: Failed to read cache timestamp for '$cachePath'. Refreshing. Error: $($_.Exception.Message)"
return [pscustomobject]@{
ModelIndex = @()
SkuIndex = @()
DownloadCenterDetails = @()
}
}
try {
$cache = Get-Content -Path $cachePath -Raw | ConvertFrom-Json -ErrorAction Stop
}
catch {
WriteLog "Warning: Could not read Surface driver cache '$cachePath'. Creating a new cache. Error: $($_.Exception.Message)"
return [pscustomobject]@{
ModelIndex = @()
SkuIndex = @()
DownloadCenterDetails = @()
}
}
if ($null -eq $cache) {
return [pscustomobject]@{
ModelIndex = @()
SkuIndex = @()
DownloadCenterDetails = @()
}
}
# Ensure expected properties exist (backward compatible with earlier cache shapes)
if (-not $cache.PSObject.Properties['ModelIndex']) {
$cache | Add-Member -NotePropertyName ModelIndex -NotePropertyValue @()
}
if (-not $cache.PSObject.Properties['SkuIndex']) {
$cache | Add-Member -NotePropertyName SkuIndex -NotePropertyValue @()
}
if (-not $cache.PSObject.Properties['DownloadCenterDetails']) {
$cache | Add-Member -NotePropertyName DownloadCenterDetails -NotePropertyValue @()
}
return $cache
}
function Save-SurfaceDriverIndexCache {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[psobject]$Cache,
[Parameter(Mandatory = $true)]
[string]$DriversFolder
)
$cachePath = Get-SurfaceDriverIndexCachePath -DriversFolder $DriversFolder
$Cache | ConvertTo-Json -Depth 10 | Set-Content -Path $cachePath -Encoding UTF8
}
function ConvertTo-SurfaceComparableName {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Text
)
# Normalize Surface marketing strings into a comparable family key.
# This intentionally strips consumer/commercial/processor qualifiers so we can join Sources A/B/C.
$value = [System.Net.WebUtility]::HtmlDecode($Text)
if ([string]::IsNullOrWhiteSpace($value)) {
return $null
}
$value = $value.Trim()
$value = $value -replace '\(', ' '
$value = $value -replace '\)', ' '
$value = $value -replace ',', ' '
# Normalize punctuation that frequently differs between Support/Learn pages
# (e.g. WiFi unicode hyphen, AT&T, Y!mobile)
$value = $value -replace '[-\u2010\u2011\u2012\u2013\u2014\u2212]', ' '
$value = $value -replace '&', ' '
$value = $value -replace '!', ' '
$value = $value -replace '™', ' '
$value = $value -replace '(?i)\bMicrosoft\b', ''
$value = $value -replace '(?i)\bfor\s+Business\b', ''
$value = $value -replace '(?i)\bConsumer\b', ''
$value = $value -replace '(?i)\bCommercial\b', ''
# Strip processor/connection qualifiers that cause mismatches between WMI, Learn, and Support naming.
$value = $value -replace '(?i)\bwith\s+Intel\b', ''
$value = $value -replace '(?i)\bIntel\s+processor\b', ''
$value = $value -replace '(?i)\bIntel\b', ''
$value = $value -replace '(?i)\bSnapdragon\s+processor\b', ''
$value = $value -replace '(?i)\bSnapdragon\b', ''
$value = $value -replace '(?i)\bwith\s+5G\b', ''
$value = $value -replace '(?i)\bLTE\b', ''
$value = $value -replace '(?i)\b4G\b', ''
$value = $value -replace '(?i)\bprocessor\b', ''
# Cleanup: remove orphaned "with" left behind by earlier removals (e.g., "Surface Pro 9 with Intel Processor")
$value = $value -replace '(?i)\bwith\b', ''
$value = $value -replace '\s+', ' '
return $value.Trim().ToUpperInvariant()
}
function Get-SurfaceSystemSkuReferenceIndex {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder
)
# Source A: Learn page with authoritative Device / System Model / System SKU table
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
if ($cache.SkuIndex -and $cache.SkuIndex.Count -gt 0) {
return @($cache.SkuIndex)
}
$url = 'https://learn.microsoft.com/en-us/surface/surface-system-sku-reference'
WriteLog "Surface cache: Downloading System SKU reference table from $url"
$headers = @{ 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }
$webContent = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $headers
$html = $webContent.Content
$skuRows = [System.Collections.Generic.List[pscustomobject]]::new()
$rowMatches = [regex]::Matches($html, '<tr[^>]*>(.*?)</tr>', [System.Text.RegularExpressions.RegexOptions]::Singleline)
foreach ($rowMatch in $rowMatches) {
$rowContent = $rowMatch.Groups[1].Value
$cellMatches = [regex]::Matches($rowContent, '<td[^>]*>\s*(?:<p[^>]*>)?(.*?)(?:</p>)?\s*</td>', [System.Text.RegularExpressions.RegexOptions]::Singleline)
if ($cellMatches.Count -lt 3) { continue }
$device = ([System.Net.WebUtility]::HtmlDecode(($cellMatches[0].Groups[1].Value).Trim()))
$systemModel = ([System.Net.WebUtility]::HtmlDecode(($cellMatches[1].Groups[1].Value).Trim()))
$systemSkuRaw = ([System.Net.WebUtility]::HtmlDecode(($cellMatches[2].Groups[1].Value).Trim()))
if ([string]::IsNullOrWhiteSpace($device) -or [string]::IsNullOrWhiteSpace($systemSkuRaw)) { continue }
$skuList = @($systemSkuRaw)
foreach ($sku in $skuList) {
if ([string]::IsNullOrWhiteSpace($sku)) { continue }
$skuRows.Add([pscustomobject]@{
Device = $device
SystemModel = $systemModel
SystemSku = $sku.Trim().ToUpperInvariant()
})
}
}
$cache.SkuIndex = @($skuRows)
Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder
WriteLog "Surface cache: Stored $($skuRows.Count) SKU entries."
return @($skuRows)
}
function Get-SurfaceDownloadCenterDetails {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder,
[Parameter(Mandatory = $true)]
[string]$ModelLink,
[Parameter()]
[string]$ModelName = $null
)
# Source C: Download Center details page (window.__DLCDetails__) containing file names + direct URLs
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
$existing = @($cache.DownloadCenterDetails | Where-Object { $_.Link -eq $ModelLink } | Select-Object -First 1)
if ($existing.Count -gt 0 -and $existing[0].Files -and $existing[0].Files.Count -gt 0) {
# Backfill Model into cache when available
if (-not [string]::IsNullOrWhiteSpace($ModelName)) {
if (-not $existing[0].PSObject.Properties['Model'] -or [string]::IsNullOrWhiteSpace($existing[0].Model)) {
try {
$existing[0] | Add-Member -NotePropertyName Model -NotePropertyValue $ModelName -Force
$newDetails = [System.Collections.Generic.List[pscustomobject]]::new()
foreach ($item in @($cache.DownloadCenterDetails)) {
if ($null -ne $item -and $item.PSObject.Properties['Link'] -and $item.Link -ne $ModelLink) {
$newDetails.Add($item)
}
}
$newDetails.Add($existing[0])
$cache.DownloadCenterDetails = @($newDetails)
Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder
}
catch {
WriteLog "Surface cache: Failed to backfill Model for DownloadCenterDetails entry '$ModelLink'. Error: $($_.Exception.Message)"
}
}
}
return @($existing[0].Files)
}
WriteLog "Surface cache: Downloading Download Center details from $ModelLink"
$headers = @{ 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }
$downloadPageContent = Invoke-WebRequest -Uri $ModelLink -UseBasicParsing -Headers $headers
$scriptPattern = '<script>window.__DLCDetails__={(.*?)}<\/script>'
$scriptMatch = [regex]::Match($downloadPageContent.Content, $scriptPattern)
if (-not $scriptMatch.Success) {
WriteLog "Surface cache: Could not find window.__DLCDetails__ on $ModelLink"
return @()
}
$scriptContent = $scriptMatch.Groups[1].Value
$downloadFilePattern = '"name":"([^"]+\.(?:msi|zip))",[^}]*?"url":"(.*?)"'
$downloadFileMatches = [regex]::Matches($scriptContent, $downloadFilePattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
$files = [System.Collections.Generic.List[pscustomobject]]::new()
foreach ($downloadFile in $downloadFileMatches) {
$currentFileName = $downloadFile.Groups[1].Value
$fileUrl = $downloadFile.Groups[2].Value
if ([string]::IsNullOrWhiteSpace($currentFileName) -or [string]::IsNullOrWhiteSpace($fileUrl)) { continue }
$files.Add([pscustomobject]@{
Name = $currentFileName
Url = $fileUrl
})
}
# Persist into cache
if ($files.Count -gt 0) {
$detailsEntry = [pscustomobject][ordered]@{
Model = $ModelName
Link = $ModelLink
Files = @($files)
}
$newDetails = [System.Collections.Generic.List[pscustomobject]]::new()
foreach ($item in @($cache.DownloadCenterDetails)) {
if ($null -ne $item -and $item.PSObject.Properties['Link'] -and $item.Link -ne $ModelLink) {
$newDetails.Add($item)
}
}
$newDetails.Add($detailsEntry)
$cache.DownloadCenterDetails = @($newDetails)
Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder
}
return @($files)
}
function Get-SurfaceSystemSkuListForMicrosoftDriver {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder,
[Parameter(Mandatory = $true)]
[string]$ModelName,
[Parameter(Mandatory = $true)]
[string]$ModelLink
)
$skuIndex = Get-SurfaceSystemSkuReferenceIndex -DriversFolder $DriversFolder
if ($null -eq $skuIndex -or $skuIndex.Count -eq 0) {
return @()
}
$files = Get-SurfaceDownloadCenterDetails -DriversFolder $DriversFolder -ModelLink $ModelLink -ModelName $ModelName
$fileNames = @($files | ForEach-Object { $_.Name })
# Infer architecture hints from the MSI naming convention (best-effort)
$archHint = $null
if ($fileNames -match '(?i)_ARM_') {
$archHint = 'ARM64'
}
elseif ($fileNames -match '(?i)withIntel|_Intel_|Intel') {
$archHint = 'x64'
}
elseif ($ModelName -match '(?i)\bSQ3\b|\bSnapdragon\b') {
$archHint = 'ARM64'
}
elseif ($ModelName -match '(?i)with Intel') {
$archHint = 'x64'
}
# Surface Pro (generic) is ambiguous in the SKU table because Surface Pro (5th Gen) and
# Surface Pro with LTE Advanced (5th Gen) both reuse SystemModel="Surface Pro".
# The "Surface Pro" driver pack does not have a unique SystemSKU value on the Learn page.
if ($ModelName.Trim() -match '(?i)^Surface\s+Pro$') {
return @()
}
# Build multiple candidate keys for models that contain multiple variants in one string
# Example: "Surface Pro 7+ and Surface Pro 7+ LTE"
$familyKeyCandidates = [System.Collections.Generic.List[string]]::new()
$familyKeySet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$primaryKey = ConvertTo-SurfaceComparableName -Text $ModelName
if (-not [string]::IsNullOrWhiteSpace($primaryKey) -and $familyKeySet.Add($primaryKey)) {
$familyKeyCandidates.Add($primaryKey) | Out-Null
}
$parts = [regex]::Split($ModelName, '(?i)\s+and\s+')
# Track when the model text contains both LTE and non-LTE variants (e.g. "Surface Go 2 and Surface Go 2 LTE")
$hasLtePart = (@($parts | Where-Object { $_ -match '(?i)\bLTE\b' }).Count -gt 0)
$hasNonLtePart = (@($parts | Where-Object { $_ -notmatch '(?i)\bLTE\b' }).Count -gt 0)
foreach ($part in @($parts)) {
if ([string]::IsNullOrWhiteSpace($part)) { continue }
$candidate = ConvertTo-SurfaceComparableName -Text $part
if (-not [string]::IsNullOrWhiteSpace($candidate) -and $familyKeySet.Add($candidate)) {
$familyKeyCandidates.Add($candidate) | Out-Null
}
}
if ($familyKeyCandidates.Count -eq 0) {
return @()
}
# Surface 3 has multiple carrier/region variants that share the same SystemModel ("Surface 3").
# Add a base key so we can match all Surface 3 SKU rows, then refine down to the correct variant.
if ($ModelName -match '(?i)^Surface\s+3\b') {
$surface3BaseKey = 'SURFACE 3'
if ($familyKeySet.Add($surface3BaseKey)) {
$familyKeyCandidates.Add($surface3BaseKey) | Out-Null
}
}
# Surface Go variants share the same SystemModel ("Surface Go") in the SKU table.
# Use a generation-aware base key so we don't cross-match Go vs Go 2/3/4 SKU rows.
if ($ModelName -match '(?i)^Surface\s+Go\s+2\b') {
$surfaceGoBaseKey = 'SURFACE GO 2'
if ($familyKeySet.Add($surfaceGoBaseKey)) {
$familyKeyCandidates.Add($surfaceGoBaseKey) | Out-Null
}
}
elseif ($ModelName -match '(?i)^Surface\s+Go\s+3\b') {
$surfaceGoBaseKey = 'SURFACE GO 3'
if ($familyKeySet.Add($surfaceGoBaseKey)) {
$familyKeyCandidates.Add($surfaceGoBaseKey) | Out-Null
}
}
elseif ($ModelName -match '(?i)^Surface\s+Go\s+4\b') {
$surfaceGoBaseKey = 'SURFACE GO 4'
if ($familyKeySet.Add($surfaceGoBaseKey)) {
$familyKeyCandidates.Add($surfaceGoBaseKey) | Out-Null
}
}
elseif ($ModelName -match '(?i)^Surface\s+Go\b') {
$surfaceGoBaseKey = 'SURFACE GO'
if ($familyKeySet.Add($surfaceGoBaseKey)) {
$familyKeyCandidates.Add($surfaceGoBaseKey) | Out-Null
}
}
# Surface Pro 9 with 5G: the SKU table rows use SystemModel "Surface Pro 9".
# Add a base key so we can match the Pro 9 SKU rows, then refine down to the 5G rows.
if (($ModelName -match '(?i)^Surface\s+Pro\s+9\b') -and ($ModelName -match '(?i)\b5G\b')) {
$surfacePro9BaseKey = 'SURFACE PRO 9'
if ($familyKeySet.Add($surfacePro9BaseKey)) {
$familyKeyCandidates.Add($surfacePro9BaseKey) | Out-Null
}
}
# Surface Pro with LTE Advanced maps to the "Surface Pro with LTE Advanced (5th Gen)" SKU table row.
# Add a base key so we can match Surface Pro rows, then refine to the LTE Advanced SKU.
if ($ModelName -match '(?i)^Surface\s+Pro\s+with\s+LTE\s+Advanced\b') {
$surfaceProBaseKey = 'SURFACE PRO'
if ($familyKeySet.Add($surfaceProBaseKey)) {
$familyKeyCandidates.Add($surfaceProBaseKey) | Out-Null
}
}
# Surface Laptop (1st Gen) maps to the base "Surface Laptop" SKU table row.
if (($ModelName -match '(?i)^Surface\s+Laptop\b') -and ($ModelName -match '(?i)\bGen\b')) {
$surfaceLaptopBaseKey = 'SURFACE LAPTOP'
if ($familyKeySet.Add($surfaceLaptopBaseKey)) {
$familyKeyCandidates.Add($surfaceLaptopBaseKey) | Out-Null
}
}
# Surface Studio (1st Gen) maps to the base "Surface Studio" SKU table row.
if (($ModelName -match '(?i)^Surface\s+Studio\b') -and ($ModelName -match '(?i)\bGen\b')) {
$surfaceStudioBaseKey = 'SURFACE STUDIO'
if ($familyKeySet.Add($surfaceStudioBaseKey)) {
$familyKeyCandidates.Add($surfaceStudioBaseKey) | Out-Null
}
}
# Surface Laptop 3/4 AMD/Intel packs map to the "Surface Laptop 3/4" SystemModel rows in the SKU table.
if ($ModelName -match '(?i)^Surface\s+Laptop\s+(3|4)\b' -and $ModelName -match '(?i)\b(AMD|Intel)\b') {
$generationMatch = [regex]::Match($ModelName, '(?i)^Surface\s+Laptop\s+(3|4)\b')
if ($generationMatch.Success) {
$surfaceLaptopGenBaseKey = "SURFACE LAPTOP $($generationMatch.Groups[1].Value)"
if ($familyKeySet.Add($surfaceLaptopGenBaseKey)) {
$familyKeyCandidates.Add($surfaceLaptopGenBaseKey) | Out-Null
}
}
}
# Match by any candidate key against the SKU table
$skuMatches = @($skuIndex | Where-Object {
$deviceKey = ConvertTo-SurfaceComparableName -Text $_.Device
$modelKey = ConvertTo-SurfaceComparableName -Text $_.SystemModel
foreach ($candidateKey in $familyKeyCandidates) {
if ($deviceKey -eq $candidateKey -or $modelKey -eq $candidateKey) {
return $true
}
}
return $false
})
# Surface Hub 2 driver packs cover Surface Hub 2S + Surface Hub 3 devices.
# The System SKU table does not have a "Surface Hub 2" row, so map Hub 2 to all Hub SKUs.
if ($ModelName -match '(?i)^Surface\s+Hub\s+2\b') {
$hubSkuRows = @($skuIndex | Where-Object { $_.Device -match '(?i)^Surface\s+Hub' })
if ($hubSkuRows.Count -gt 0) {
$skuMatches = @($hubSkuRows)
}
}
# Surface 3: refine down to the correct SKU row based on the model variant text
# Use normalized text so punctuation/Unicode differences don't drop matches to zero.
if ($ModelName -match '(?i)^Surface\s+3\b') {
$modelNorm = ConvertTo-SurfaceComparableName -Text $ModelName
if ($modelNorm -match '(?i)\bWI\s+FI\b') {
$skuMatches = @($skuMatches | Where-Object { (ConvertTo-SurfaceComparableName -Text $_.Device) -match '(?i)\bWI\s+FI\b' })
}
elseif ($modelNorm -match '(?i)\bVERIZON\b') {
$skuMatches = @($skuMatches | Where-Object { (ConvertTo-SurfaceComparableName -Text $_.Device) -match '(?i)\bVERIZON\b' })
}
elseif ($modelNorm -match '(?i)\bOUTSIDE\s+OF\s+NORTH\s+AMERICA\b|\bY\s+MOBILE\b') {
$skuMatches = @($skuMatches | Where-Object { (ConvertTo-SurfaceComparableName -Text $_.Device) -match '(?i)\bOUTSIDE\s+OF\s+NORTH\s+AMERICA\b|\bY\s+MOBILE\b' })
}
elseif ($modelNorm -match '(?i)\bNORTH\s+AMERICA\b') {
# "North America (non-AT&T)" should map to the North America row (not AT&T/Verizon/outside-of-North-America)
$skuMatches = @($skuMatches | Where-Object {
$deviceNorm = ConvertTo-SurfaceComparableName -Text $_.Device
($deviceNorm -match '(?i)\bNORTH\s+AMERICA\b') -and
($deviceNorm -notmatch '(?i)\bOUTSIDE\b|\bY\s+MOBILE\b') -and
($deviceNorm -notmatch '(?i)\bAT\s+T\b|\bVERIZON\b')
})
}
elseif (($modelNorm -match '(?i)\bAT\s+T\b') -and ($modelNorm -notmatch '(?i)\bNON\s+AT\s+T\b')) {
$skuMatches = @($skuMatches | Where-Object { (ConvertTo-SurfaceComparableName -Text $_.Device) -match '(?i)\bAT\s+T\b' })
}
}
# Surface Go: keep LTE SKU only for LTE-only models; exclude LTE SKU for non-LTE-only models.
# If the model name includes BOTH LTE and non-LTE variants (joined with "and"), do not filter.
# Surface Go 3 driver packs are treated as covering LTE + non-LTE unless explicitly labeled otherwise.
if ($ModelName -match '(?i)^Surface\s+Go\b') {
$isSurfaceGo3Base = ($ModelName -match '(?i)^Surface\s+Go\s+3\b') -and ($ModelName -notmatch '(?i)\bLTE\b')
if (-not $isSurfaceGo3Base) {
if (-not ($hasLtePart -and $hasNonLtePart)) {
if ($ModelName -match '(?i)\bLTE\b') {
$skuMatches = @($skuMatches | Where-Object { $_.Device -match '(?i)\bLTE\b' })
}
else {
$skuMatches = @($skuMatches | Where-Object { $_.Device -notmatch '(?i)\bLTE\b' })
}
}
}
}
# Surface Pro 9 with 5G (SQ3): keep only the 5G SKU rows (U.S. + outside of U.S.).
if (($ModelName -match '(?i)^Surface\s+Pro\s+9\b') -and ($ModelName -match '(?i)\b5G\b')) {
$skuMatches = @($skuMatches | Where-Object { $_.Device -match '(?i)\b5G\b' })
}
# Surface Pro 10: split non-5G vs 5G SKU rows so the two driver packs don't share the same SystemSKUs.
if ($ModelName -match '(?i)^Surface\s+Pro\s+10\b') {
if ($ModelName -match '(?i)\b5G\b') {
$skuMatches = @($skuMatches | Where-Object {
($_.SystemSku -match '^SURFACE_PRO_10_WITH_5G_FOR_BUSINESS_') -or
($_.Device -match '(?i)\bwith\s+5G\b')
})
}
else {
$skuMatches = @($skuMatches | Where-Object { $_.SystemSku -eq 'SURFACE_PRO_10_FOR_BUSINESS_2079' })
}
}
# Surface Pro with LTE Advanced: restrict to the LTE Advanced (5th Gen) SKU.
if ($ModelName -match '(?i)^Surface\s+Pro\s+with\s+LTE\s+Advanced\b') {
$skuMatches = @($skuMatches | Where-Object { $_.SystemSku -eq 'SURFACE_PRO_1807' })
}
# Surface Laptop 3/4: filter to AMD vs Intel rows (prevents AMD packs from inheriting Intel SKUs and vice-versa).
if ($ModelName -match '(?i)^Surface\s+Laptop\s+(3|4)\b') {
if ($ModelName -match '(?i)\bAMD\b') {
$skuMatches = @($skuMatches | Where-Object { $_.Device -match '(?i)\bAMD\b' })
}
elseif ($ModelName -match '(?i)\bIntel\b') {
$skuMatches = @($skuMatches | Where-Object { $_.Device -match '(?i)\bIntel\b' })
}
}
# Apply architecture filtering when we can infer it
if ($archHint -eq 'ARM64') {
# ARM variants are typically called out as Snapdragon / SQ3 / 5G in the Learn table
$skuMatches = @($skuMatches | Where-Object {
($_.Device -match '(?i)Snapdragon|SQ3|with 5G') -or
($_.SystemModel -match '(?i)Snapdragon|SQ3|with 5G')
})
}
elseif ($archHint -eq 'x64') {
# x64 variants are often NOT labeled "Intel" in the Learn table (e.g. Surface Pro 9).
# Treat "not Snapdragon/SQ3/5G" as the x64 bucket.
$skuMatches = @($skuMatches | Where-Object {
($_.Device -notmatch '(?i)Snapdragon|SQ3|with 5G') -and
($_.SystemModel -notmatch '(?i)Snapdragon|SQ3|with 5G')
})
}
$skus = @($skuMatches | ForEach-Object { $_.SystemSku } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
return $skus
}
Export-ModuleMember -Function `
Get-SurfaceDriverIndexCachePath, `
Import-SurfaceDriverIndexCache, `
Save-SurfaceDriverIndexCache, `
ConvertTo-SurfaceComparableName, `
Get-SurfaceSystemSkuReferenceIndex, `
Get-SurfaceDownloadCenterDetails, `
Get-SurfaceSystemSkuListForMicrosoftDriver
@@ -276,6 +276,20 @@ function Update-DriverMappingJson {
} }
} }
# Microsoft Surface: resolve System SKU list (best-effort) using Sources A + C and cached results
$surfaceSystemSkuList = @()
if ($driver.Make -eq 'Microsoft') {
if ($driver.PSObject.Properties['Link'] -and -not [string]::IsNullOrWhiteSpace($driver.Link)) {
try {
$surfaceSystemSkuList = Get-SurfaceSystemSkuListForMicrosoftDriver -DriversFolder $DriversFolder -ModelName $driver.Model -ModelLink $driver.Link
}
catch {
WriteLog "Warning: Failed to resolve Surface SystemSku list for '$($driver.Model)'. Error: $($_.Exception.Message)"
$surfaceSystemSkuList = @()
}
}
}
$existingEntry = $mappingList | Where-Object { $_.Manufacturer -eq $driver.Make -and $_.Model -eq $driver.Model } | Select-Object -First 1 $existingEntry = $mappingList | Where-Object { $_.Manufacturer -eq $driver.Make -and $_.Model -eq $driver.Model } | Select-Object -First 1
if ($null -ne $existingEntry) { if ($null -ne $existingEntry) {
@@ -316,6 +330,26 @@ function Update-DriverMappingJson {
} }
} }
if ($driver.Make -eq 'Microsoft' -and $surfaceSystemSkuList -and $surfaceSystemSkuList.Count -gt 0) {
$desiredSkus = @($surfaceSystemSkuList | Sort-Object -Unique)
if ($existingEntry.PSObject.Properties['SystemSku']) {
$currentSkus = @($existingEntry.SystemSku)
$currentNormalized = @($currentSkus | ForEach-Object { if ($null -ne $_) { $_.ToString().Trim().ToUpperInvariant() } }) | Sort-Object -Unique
$desiredNormalized = @($desiredSkus | ForEach-Object { if ($null -ne $_) { $_.ToString().Trim().ToUpperInvariant() } }) | Sort-Object -Unique
if (($currentNormalized -join '|') -ne ($desiredNormalized -join '|')) {
WriteLog "Updating SystemSku list for 'Microsoft - $($driver.Model)'."
$existingEntry.SystemSku = $desiredSkus
$entryUpdated = $true
}
}
else {
WriteLog "Adding SystemSku list for 'Microsoft - $($driver.Model)'."
$existingEntry | Add-Member -NotePropertyName SystemSku -NotePropertyValue $desiredSkus
$entryUpdated = $true
}
}
if ($entryUpdated) { if ($entryUpdated) {
$updatedCount++ $updatedCount++
} }
@@ -333,6 +367,9 @@ function Update-DriverMappingJson {
if ($driver.Make -eq 'Lenovo' -and -not [string]::IsNullOrWhiteSpace($machineTypeValue)) { if ($driver.Make -eq 'Lenovo' -and -not [string]::IsNullOrWhiteSpace($machineTypeValue)) {
$newEntry | Add-Member -NotePropertyName MachineType -NotePropertyValue $machineTypeValue $newEntry | Add-Member -NotePropertyName MachineType -NotePropertyValue $machineTypeValue
} }
if ($driver.Make -eq 'Microsoft' -and $surfaceSystemSkuList -and $surfaceSystemSkuList.Count -gt 0) {
$newEntry | Add-Member -NotePropertyName SystemSku -NotePropertyValue @($surfaceSystemSkuList | Sort-Object -Unique)
}
$mappingList.Add($newEntry) $mappingList.Add($newEntry)
WriteLog "Adding new mapping for '$($driver.Make) - $($driver.Model)' with path '$($driver.DriverPath)'." WriteLog "Adding new mapping for '$($driver.Make) - $($driver.Model)' with path '$($driver.DriverPath)'."
@@ -778,4 +815,8 @@ function Get-LenovoPSREFToken {
# SECTION: Module Export # SECTION: Module Export
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
Export-ModuleMember -Function Compress-DriverFolderToWim, Update-DriverMappingJson, Test-ExistingDriver, Get-LenovoPSREFToken Export-ModuleMember -Function `
Compress-DriverFolderToWim, `
Update-DriverMappingJson, `
Test-ExistingDriver, `
Get-LenovoPSREFToken
+489 -30
View File
@@ -221,12 +221,20 @@ function Get-Application {
WriteLog "$AppName moved to $NewAppPath" WriteLog "$AppName moved to $NewAppPath"
$result = 0 # Success for UWP app $result = 0 # Success for UWP app
} }
# If app is in Win32 folder, add the silent install command to the WinGetWin32Apps.json file # If app is in Win32 folder, add dependency entries (if any) and then add the parent silent install command
elseif ($appFolderPath -match 'Win32') { elseif ($appFolderPath -match 'Win32') {
if (-not $SkipWin32Json) { if (-not $SkipWin32Json) {
# Add dependency install commands first (de-duped). Fail if any dependency cannot be processed.
$depResult = Add-Win32DependencySilentInstallCommands -ParentAppName $AppName -ParentAppFolderPath $appFolderPath -OrchestrationPath $OrchestrationPath -SubFolder $subFolderForCommand
if ($depResult -ne 0) {
WriteLog "Dependency processing failed for '$AppName'. The app will not be added to WinGetWin32Apps.json."
$result = 5
}
else {
WriteLog "$AppName is a Win32 app. Adding silent install command to $OrchestrationPath\WinGetWin32Apps.json" 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 $result = Add-Win32SilentInstallCommand -AppFolder $AppName -AppFolderPath $appFolderPath -OrchestrationPath $OrchestrationPath -SubFolder $subFolderForCommand
} }
}
else { else {
WriteLog "$AppName is a Win32 app. Skipping WinGetWin32Apps.json generation (UI mode)." WriteLog "$AppName is a Win32 app. Skipping WinGetWin32Apps.json generation (UI mode)."
$result = 0 $result = 0
@@ -448,13 +456,43 @@ function Start-WingetAppDownloadTask {
$archFolders = Get-ChildItem -Path $appFolder -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -in @('x86', 'x64', 'arm64') } $archFolders = Get-ChildItem -Path $appFolder -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -in @('x86', 'x64', 'arm64') }
if ($archFolders) { if ($archFolders) {
foreach ($archFolder in $archFolders) { foreach ($archFolder in $archFolders) {
# Add dependencies first (fail if dependencies cannot be processed)
$depResult = Add-Win32DependencySilentInstallCommands -ParentAppName $sanitizedAppName -ParentAppFolderPath $archFolder.FullName -OrchestrationPath $OrchestrationPath -SubFolder $archFolder.Name
if ($depResult -ne 0) {
$status = "Error: Dependency manifests could not be processed for $sanitizedAppName ($($archFolder.Name))"
WriteLog $status
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 5 }
}
WriteLog "Adding silent install command for pre-downloaded $sanitizedAppName ($($archFolder.Name)) to $OrchestrationPath\WinGetWin32Apps.json" WriteLog "Adding silent install command for pre-downloaded $sanitizedAppName ($($archFolder.Name)) to $OrchestrationPath\WinGetWin32Apps.json"
Add-Win32SilentInstallCommand -AppFolder $sanitizedAppName -AppFolderPath $archFolder.FullName -OrchestrationPath $OrchestrationPath -SubFolder $archFolder.Name | Out-Null $addResult = Add-Win32SilentInstallCommand -AppFolder $sanitizedAppName -AppFolderPath $archFolder.FullName -OrchestrationPath $OrchestrationPath -SubFolder $archFolder.Name -SkipRemoveOnFailure
if ($addResult -ne 0) {
$status = "Error: Failed to generate silent install command for $sanitizedAppName ($($archFolder.Name))"
WriteLog $status
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = $addResult }
}
} }
} }
else { else {
# Add dependencies first (fail if dependencies cannot be processed)
$depResult = Add-Win32DependencySilentInstallCommands -ParentAppName $sanitizedAppName -ParentAppFolderPath $appFolder -OrchestrationPath $OrchestrationPath
if ($depResult -ne 0) {
$status = "Error: Dependency manifests could not be processed for $sanitizedAppName"
WriteLog $status
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 5 }
}
WriteLog "Adding silent install command for pre-downloaded $sanitizedAppName to $OrchestrationPath\WinGetWin32Apps.json" WriteLog "Adding silent install command for pre-downloaded $sanitizedAppName to $OrchestrationPath\WinGetWin32Apps.json"
Add-Win32SilentInstallCommand -AppFolder $sanitizedAppName -AppFolderPath $appFolder -OrchestrationPath $OrchestrationPath | Out-Null $addResult = Add-Win32SilentInstallCommand -AppFolder $sanitizedAppName -AppFolderPath $appFolder -OrchestrationPath $OrchestrationPath -SkipRemoveOnFailure
if ($addResult -ne 0) {
$status = "Error: Failed to generate silent install command for $sanitizedAppName"
WriteLog $status
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = $addResult }
}
} }
} }
else { else {
@@ -602,6 +640,8 @@ function Start-WingetAppDownloadTask {
2 { $status = "Silent install switch could not be found. Did not download." } 2 { $status = "Silent install switch could not be found. Did not download." }
3 { $status = "Error: Publisher does not support download" } 3 { $status = "Error: Publisher does not support download" }
4 { $status = "Skipped: Use 'msstore' source instead." } 4 { $status = "Skipped: Use 'msstore' source instead." }
5 { $status = "Error: Dependency manifest processing failed. Remove app or use BYO." }
6 { $status = "Error: Could not resolve installer from YAML. Remove app or use BYO." }
default { $status = "Downloaded with status: $resultCode" } default { $status = "Downloaded with status: $resultCode" }
} }
@@ -792,6 +832,9 @@ function Get-Apps {
if ($overrideMap.Count -gt 0) { if ($overrideMap.Count -gt 0) {
$winGetWin32Path = Join-Path -Path $OrchestrationPath -ChildPath 'WinGetWin32Apps.json' $winGetWin32Path = Join-Path -Path $OrchestrationPath -ChildPath 'WinGetWin32Apps.json'
if (Test-Path -Path $winGetWin32Path) { if (Test-Path -Path $winGetWin32Path) {
# Lock WinGetWin32Apps.json during override writes to avoid any unexpected concurrent access
$mutexName = Get-WinGetWin32AppsJsonMutexName -WinGetWin32AppsJsonPath $winGetWin32Path
Invoke-WithNamedMutex -MutexName $mutexName -TimeoutSeconds 60 -ScriptBlock {
[array]$appsDataUpdated = Get-Content -Path $winGetWin32Path -Raw | ConvertFrom-Json [array]$appsDataUpdated = Get-Content -Path $winGetWin32Path -Raw | ConvertFrom-Json
$changed = $false $changed = $false
foreach ($entry in $appsDataUpdated) { foreach ($entry in $appsDataUpdated) {
@@ -820,13 +863,15 @@ function Get-Apps {
} }
} }
if ($changed) { if ($changed) {
$appsDataUpdated | ConvertTo-Json -Depth 10 | Set-Content -Path $winGetWin32Path $jsonText = $appsDataUpdated | ConvertTo-Json -Depth 10
Set-FileContentAtomic -Path $winGetWin32Path -Content $jsonText
WriteLog "Applied AppList.json command overrides to WinGetWin32Apps.json" WriteLog "Applied AppList.json command overrides to WinGetWin32Apps.json"
} }
else { else {
WriteLog "No matching apps required command overrides." WriteLog "No matching apps required command overrides."
} }
} }
}
else { else {
WriteLog "WinGetWin32Apps.json not found; no overrides applied." WriteLog "WinGetWin32Apps.json not found; no overrides applied."
} }
@@ -835,6 +880,119 @@ function Get-Apps {
catch { catch {
WriteLog "Failed to apply AppList.json command overrides: $($_.Exception.Message)" WriteLog "Failed to apply AppList.json command overrides: $($_.Exception.Message)"
} }
# Post-processing: Ensure WinGetWin32Apps.json ordering matches AppList.json
# Parallel downloads can append entries in completion order. We reorder and re-prioritize
# so install order matches the ordering specified in AppList.json.
try {
$winGetWin32Path = Join-Path -Path $OrchestrationPath -ChildPath 'WinGetWin32Apps.json'
if (Test-Path -Path $winGetWin32Path) {
# Build desired order map from AppList.json (winget entries only)
$desiredOrderMap = @{}
$orderIndex = 0
foreach ($app in ($apps.apps | Where-Object { $_.source -eq 'winget' })) {
if (-not [string]::IsNullOrWhiteSpace($app.name) -and -not $desiredOrderMap.ContainsKey($app.name)) {
$desiredOrderMap[$app.name] = $orderIndex
$orderIndex++
}
}
# Only attempt reordering when we have a meaningful order map
if ($desiredOrderMap.Count -gt 0) {
# Lock WinGetWin32Apps.json to serialize reads/writes
$mutexName = Get-WinGetWin32AppsJsonMutexName -WinGetWin32AppsJsonPath $winGetWin32Path
Invoke-WithNamedMutex -MutexName $mutexName -TimeoutSeconds 60 -ScriptBlock {
# Load existing WinGetWin32Apps.json content
[array]$currentAppsData = Get-Content -Path $winGetWin32Path -Raw | ConvertFrom-Json
if ($null -eq $currentAppsData) {
$currentAppsData = @()
}
# Only reorder when there is more than one entry
if ($currentAppsData.Count -gt 1) {
# Capture original order for change detection
$originalNames = @($currentAppsData | ForEach-Object { $_.Name })
# Build sortable records that preserve stable ordering for ties
$indexed = @()
for ($i = 0; $i -lt $currentAppsData.Count; $i++) {
$entry = $currentAppsData[$i]
# If this is a dependency entry, order it with (and before) its parent app
$dependencyFor = $null
if ($entry.PSObject.Properties['DependencyFor']) {
$dependencyFor = $entry.DependencyFor
}
# Normalize entry names like "Foo (x64)" back to "Foo" for ordering
$baseName = $entry.Name
if (-not [string]::IsNullOrWhiteSpace($dependencyFor)) {
$baseName = $dependencyFor
}
if (-not [string]::IsNullOrWhiteSpace($baseName)) {
$baseName = ($baseName -replace '\s+\((x86|x64|arm64)\)$', '')
}
# Determine desired order; unknown entries are pushed to the end
$orderKey = [int]::MaxValue
if (-not [string]::IsNullOrWhiteSpace($baseName) -and $desiredOrderMap.ContainsKey($baseName)) {
$orderKey = [int]$desiredOrderMap[$baseName]
}
# Dependencies must install before the parent app within the same OrderKey
$isDependency = 1
if (-not [string]::IsNullOrWhiteSpace($dependencyFor)) {
$isDependency = 0
}
$indexed += [PSCustomObject]@{
OrderKey = $orderKey
IsDependency = $isDependency
OriginalIndex = $i
App = $entry
}
}
# Sort by desired AppList.json order, dependencies first, stable within same group using OriginalIndex
$sorted = $indexed | Sort-Object -Property OrderKey, IsDependency, OriginalIndex
$reorderedApps = @($sorted | ForEach-Object { $_.App })
# Detect whether priority needs to be rewritten (even if order is unchanged)
$priorityNeedsUpdate = $false
for ($p = 0; $p -lt $reorderedApps.Count; $p++) {
if ($reorderedApps[$p].PSObject.Properties['Priority'] -and $reorderedApps[$p].Priority -eq ($p + 1)) {
continue
}
$priorityNeedsUpdate = $true
break
}
# Detect whether the array order actually changed
$sortedNames = @($reorderedApps | ForEach-Object { $_.Name })
$orderNeedsUpdate = (($originalNames -join "`n") -ne ($sortedNames -join "`n"))
if ($orderNeedsUpdate -or $priorityNeedsUpdate) {
# Re-assign priority sequentially to match the ordering
for ($p = 0; $p -lt $reorderedApps.Count; $p++) {
$reorderedApps[$p].Priority = $p + 1
}
# Write updated JSON content atomically
$jsonText = $reorderedApps | ConvertTo-Json -Depth 10
Set-FileContentAtomic -Path $winGetWin32Path -Content $jsonText
WriteLog "Reordered and re-prioritized WinGetWin32Apps.json to match AppList.json ordering."
}
else {
WriteLog "WinGetWin32Apps.json is already ordered to match AppList.json; no reorder needed."
}
}
}
}
}
}
catch {
WriteLog "Failed to reorder WinGetWin32Apps.json: $($_.Exception.Message)"
}
} }
function Install-WinGet { function Install-WinGet {
param ( param (
@@ -909,27 +1067,244 @@ function Confirm-WinGetInstallation {
WriteLog "Installed WinGet version: $wingetVersion" WriteLog "Installed WinGet version: $wingetVersion"
} }
} }
function Add-Win32SilentInstallCommand { # --------------------------------------------------------------------------
# SECTION: WinGetWin32Apps.json File Locking Helpers
# --------------------------------------------------------------------------
function Get-WinGetWin32AppsJsonMutexName {
[CmdletBinding()]
param( param(
[string]$AppFolder, [Parameter(Mandatory = $true)]
[string]$AppFolderPath, [string]$WinGetWin32AppsJsonPath
)
# Create a stable, safe mutex name based on the full file path
# This prevents cross-runspace/cross-process corruption when multiple apps write the same JSON.
$normalizedPath = $WinGetWin32AppsJsonPath.ToLowerInvariant()
$sha256 = [System.Security.Cryptography.SHA256]::Create()
try {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($normalizedPath)
$hashBytes = $sha256.ComputeHash($bytes)
}
finally {
$sha256.Dispose()
}
$hash = -join ($hashBytes | ForEach-Object { $_.ToString('x2') })
return "WinGetWin32AppsJsonLock_$hash"
}
function Invoke-WithNamedMutex {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$MutexName,
[Parameter(Mandatory = $true)]
[scriptblock]$ScriptBlock,
[int]$TimeoutSeconds = 60
)
# Use a named mutex so all parallel runspaces serialize file access
$mutex = New-Object System.Threading.Mutex($false, $MutexName)
$lockTaken = $false
try {
$lockTaken = $mutex.WaitOne([TimeSpan]::FromSeconds($TimeoutSeconds))
if (-not $lockTaken) {
throw "Timed out waiting for mutex '$MutexName' after $TimeoutSeconds seconds."
}
& $ScriptBlock
}
finally {
if ($lockTaken) {
try {
$mutex.ReleaseMutex() | Out-Null
}
catch {
# Best-effort release; ignore release failures
}
}
$mutex.Dispose()
}
}
function Set-FileContentAtomic {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[string]$Content
)
# Write to a unique temp file in the same directory and then rename into place
# to reduce the chance of partial writes.
$parentPath = Split-Path -Path $Path -Parent
if (-not (Test-Path -Path $parentPath -PathType Container)) {
New-Item -Path $parentPath -ItemType Directory -Force | Out-Null
}
$tempPath = "$Path.$([guid]::NewGuid().ToString('N')).tmp"
Set-Content -Path $tempPath -Value $Content -Encoding UTF8
try {
# PowerShell 7+ (.NET) supports overwrite via File.Move overload
[System.IO.File]::Move($tempPath, $Path, $true)
}
catch {
# Fallback for environments where overwrite overload is unavailable
Move-Item -Path $tempPath -Destination $Path -Force
}
}
function Get-WinGetYamlScalarValue {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$YamlText,
[Parameter(Mandatory = $true)]
[string]$Key
)
# Extract a simple "Key: Value" scalar from a Winget YAML file
$regexOptions = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Multiline
$pattern = "^\s*$Key\s*:\s*(?<val>.+?)\s*$"
$m = [regex]::Match($YamlText, $pattern, $regexOptions)
if (-not $m.Success) {
return $null
}
$value = $m.Groups['val'].Value.Trim()
$value = $value.Trim("'").Trim('"')
return $value
}
function Add-Win32DependencySilentInstallCommands {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ParentAppName,
[Parameter(Mandatory = $true)]
[string]$ParentAppFolderPath,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$OrchestrationPath, [string]$OrchestrationPath,
[string]$SubFolder [string]$SubFolder
) )
$appName = $AppFolder
# Discover installer candidates (top-level files as before) # Discover WinGet dependency manifests under the downloaded Win32 app folder
$dependenciesFolderPath = Join-Path -Path $ParentAppFolderPath -ChildPath 'Dependencies'
if (-not (Test-Path -Path $dependenciesFolderPath -PathType Container)) {
return 0
}
WriteLog "Dependencies folder detected for '$ParentAppName': $dependenciesFolderPath"
# Require YAML manifests to generate silent install commands
$dependencyYamlFiles = Get-ChildItem -Path $dependenciesFolderPath -Filter "*.yaml" -File -ErrorAction SilentlyContinue
if (-not $dependencyYamlFiles -or $dependencyYamlFiles.Count -eq 0) {
WriteLog "Dependencies folder exists for '$ParentAppName' but no .yaml files were found. Cannot generate dependency install commands."
return 5
}
# Build the VM install base path for dependency payloads (matches D:\win32 layout)
$vmBasePath = "D:\win32\$ParentAppName"
if (-not [string]::IsNullOrEmpty($SubFolder)) {
$vmBasePath = "$vmBasePath\$SubFolder"
}
$vmDependenciesBasePath = "$vmBasePath\Dependencies"
# Process each dependency manifest and add it to WinGetWin32Apps.json
foreach ($yamlFile in $dependencyYamlFiles) {
WriteLog "Processing dependency manifest '$($yamlFile.Name)' for '$ParentAppName'"
try {
$yamlText = Get-Content -Path $yamlFile.FullName -Raw -ErrorAction Stop
$packageIdentifier = Get-WinGetYamlScalarValue -YamlText $yamlText -Key 'PackageIdentifier'
$packageName = Get-WinGetYamlScalarValue -YamlText $yamlText -Key 'PackageName'
if ([string]::IsNullOrWhiteSpace($packageIdentifier)) {
$packageIdentifier = $yamlFile.BaseName
}
if ([string]::IsNullOrWhiteSpace($packageName)) {
$packageName = $yamlFile.BaseName
}
# Add dependency entry (de-duped) and ensure it sorts before the parent app
$depResult = Add-Win32SilentInstallCommand -AppFolder $packageName -AppFolderPath $dependenciesFolderPath -OrchestrationPath $OrchestrationPath -YamlFilePath $yamlFile.FullName -BasePathOverride $vmDependenciesBasePath -PackageIdentifier $packageIdentifier -DependencyFor $ParentAppName -SkipRemoveOnFailure
if ($depResult -ne 0) {
WriteLog "Failed to generate dependency install command for '$packageName' (PackageIdentifier='$packageIdentifier') under '$ParentAppName'."
return 5
}
}
catch {
WriteLog "Failed to process dependency YAML '$($yamlFile.FullName)': $($_.Exception.Message)"
return 5
}
}
return 0
}
function Add-Win32SilentInstallCommand {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[string]$AppFolder,
[Parameter(Mandatory = $true)]
[string]$AppFolderPath,
[Parameter(Mandatory = $true)]
[string]$OrchestrationPath,
[string]$SubFolder,
[string]$YamlFilePath,
[string]$BasePathOverride,
[string]$PackageIdentifier,
[string]$DependencyFor,
[switch]$SkipRemoveOnFailure
)
$appName = $AppFolder
$appFolderPath = $AppFolderPath
# Discover installer candidates (top-level files only)
$installerCandidates = Get-ChildItem -Path "$appFolderPath\*" -Include "*.exe", "*.msi" -File -ErrorAction SilentlyContinue $installerCandidates = Get-ChildItem -Path "$appFolderPath\*" -Include "*.exe", "*.msi" -File -ErrorAction SilentlyContinue
if (-not $installerCandidates) { if (-not $installerCandidates) {
WriteLog "No win32 app installers were found. Skipping the inclusion of $AppFolder" WriteLog "No win32 app installers were found. Skipping the inclusion of $AppFolder"
# Avoid removing shared folders (ex: Dependencies) or pre-downloaded content when requested
if (-not $SkipRemoveOnFailure) {
Remove-Item -Path $AppFolderPath -Recurse -Force Remove-Item -Path $AppFolderPath -Recurse -Force
}
return 1 return 1
} }
# Read the exported WinGet YAML # Read the exported WinGet YAML (explicit file if provided; otherwise pick the first YAML found)
$yamlFile = Get-ChildItem -Path "$appFolderPath\*" -Include "*.yaml" -File -ErrorAction Stop $yamlFile = $null
$yamlText = Get-Content -Path $yamlFile -Raw if (-not [string]::IsNullOrWhiteSpace($YamlFilePath)) {
$yamlFile = Get-Item -LiteralPath $YamlFilePath -ErrorAction Stop
}
else {
$yamlFile = Get-ChildItem -Path "$appFolderPath\*" -Include "*.yaml" -File -ErrorAction Stop | Select-Object -First 1
}
$yamlText = Get-Content -Path $yamlFile.FullName -Raw
# When multiple installers exist in the folder (common for Dependencies), do NOT guess.
# WinGet exports use the same basename for installer and YAML, so select the installer by YAML basename.
if ($installerCandidates.Count -gt 1) {
$expectedInstallerBaseName = $yamlFile.BaseName
$matchedInstallers = $installerCandidates | Where-Object { $_.BaseName -ieq $expectedInstallerBaseName }
if ($matchedInstallers -and $matchedInstallers.Count -gt 0) {
$installerCandidates = $matchedInstallers
}
else {
WriteLog "Multiple installers found but none matched YAML basename '$expectedInstallerBaseName' in '$appFolderPath'."
if (-not $SkipRemoveOnFailure) {
Remove-Item -Path $appFolderPath -Recurse -Force
}
return 6
}
}
# Attempt to resolve the correct installer from YAML NestedInstallerFiles within the matching Architecture block # Attempt to resolve the correct installer from YAML NestedInstallerFiles within the matching Architecture block
$desiredArch = if (-not [string]::IsNullOrEmpty($SubFolder)) { $SubFolder } else { $null } $desiredArch = if (-not [string]::IsNullOrEmpty($SubFolder)) { $SubFolder } else { $null }
@@ -987,7 +1362,12 @@ function Add-Win32SilentInstallCommand {
} }
if (-not $silentInstallSwitch) { if (-not $silentInstallSwitch) {
WriteLog "Silent install switch for $appName could not be found. Skipping the inclusion of $appName." WriteLog "Silent install switch for $appName could not be found. Skipping the inclusion of $appName."
# Avoid removing shared folders (ex: Dependencies) or pre-downloaded content when requested
if (-not $SkipRemoveOnFailure) {
Remove-Item -Path $appFolderPath -Recurse -Force Remove-Item -Path $appFolderPath -Recurse -Force
}
return 2 return 2
} }
@@ -1031,19 +1411,27 @@ function Add-Win32SilentInstallCommand {
WriteLog "Multiple installers found. YAML not used. Falling back to single EXE: $resolvedRelativePath" WriteLog "Multiple installers found. YAML not used. Falling back to single EXE: $resolvedRelativePath"
} }
else { else {
$first = $installerCandidates | Select-Object -First 1 WriteLog "Multiple installers found and ambiguous for '$appName' in '$appFolderPath'."
$resolvedRelativePath = $first.Name if (-not $SkipRemoveOnFailure) {
$installerExt = $first.Extension Remove-Item -Path $appFolderPath -Recurse -Force
WriteLog "Multiple installers found and ambiguous. Selecting the first candidate: $resolvedRelativePath" }
return 6
} }
} }
} }
} }
# Build the VM install base path (matches D:\win32 layout)
$basePath = $null
if (-not [string]::IsNullOrWhiteSpace($BasePathOverride)) {
$basePath = $BasePathOverride
}
else {
$basePath = "D:\win32\$AppFolder" $basePath = "D:\win32\$AppFolder"
if (-not [string]::IsNullOrEmpty($SubFolder)) { if (-not [string]::IsNullOrEmpty($SubFolder)) {
$basePath = "$basePath\$SubFolder" $basePath = "$basePath\$SubFolder"
} }
}
# Build final command/arguments # Build final command/arguments
if ($installerExt -ieq ".exe") { if ($installerExt -ieq ".exe") {
@@ -1061,34 +1449,105 @@ function Add-Win32SilentInstallCommand {
# Path to the JSON file # Path to the JSON file
$wingetWin32AppsJson = "$OrchestrationPath\WinGetWin32Apps.json" $wingetWin32AppsJson = "$OrchestrationPath\WinGetWin32Apps.json"
# Serialize access to WinGetWin32Apps.json to prevent corruption when multiple apps are processed in parallel
$mutexName = Get-WinGetWin32AppsJsonMutexName -WinGetWin32AppsJsonPath $wingetWin32AppsJson
$addOutcome = Invoke-WithNamedMutex -MutexName $mutexName -TimeoutSeconds 60 -ScriptBlock {
# Initialize or load existing JSON data # Initialize or load existing JSON data
if (Test-Path -Path $wingetWin32AppsJson) {
[array]$appsData = Get-Content -Path $wingetWin32AppsJson -Raw | ConvertFrom-Json
# Get highest priority value
if ($appsData.Count -gt 0) {
$highestPriority = $appsData.Count + 1
}
}
else {
$appsData = @() $appsData = @()
$highestPriority = 1 if (Test-Path -Path $wingetWin32AppsJson) {
try {
[array]$appsData = Get-Content -Path $wingetWin32AppsJson -Raw | ConvertFrom-Json
if ($null -eq $appsData) {
$appsData = @()
} }
}
catch {
# Backup the corrupted file so the build can continue
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$backupPath = "$wingetWin32AppsJson.corrupt.$timestamp"
try {
Copy-Item -Path $wingetWin32AppsJson -Destination $backupPath -Force
WriteLog "WinGetWin32Apps.json could not be parsed. Backed up corrupt file to '$backupPath' and rebuilding."
}
catch {
WriteLog "WinGetWin32Apps.json could not be parsed and backup failed: $($_.Exception.Message). Rebuilding anyway."
}
$appsData = @()
}
}
# De-dupe dependencies and repeated entries across apps by PackageIdentifier first, then by command+args
$isDuplicate = $false
if (-not [string]::IsNullOrWhiteSpace($PackageIdentifier)) {
$existingById = $appsData | Where-Object { $_.PSObject.Properties['PackageIdentifier'] -and $_.PackageIdentifier -eq $PackageIdentifier } | Select-Object -First 1
if ($existingById) {
$isDuplicate = $true
}
}
if (-not $isDuplicate) {
$existingByCommand = $appsData | Where-Object {
$_.PSObject.Properties['CommandLine'] -and $_.PSObject.Properties['Arguments'] -and
$_.CommandLine -eq $silentInstallCommand -and $_.Arguments -eq $silentInstallSwitch
} | Select-Object -First 1
if ($existingByCommand) {
$isDuplicate = $true
}
}
if ($isDuplicate) {
WriteLog "Skipping duplicate Win32 install entry: Name='$appName' PackageIdentifier='$PackageIdentifier'"
return @{
Added = $false
Reason = 'Duplicate'
}
}
# Calculate next priority (always set, even if the file exists but is empty)
$highestPriority = if ($appsData.Count -gt 0) { $appsData.Count + 1 } else { 1 }
# Create new app entry # Create new app entry
$entryName = $appName
if ([string]::IsNullOrWhiteSpace($DependencyFor)) {
if (-not [string]::IsNullOrEmpty($SubFolder)) {
$entryName = "$appName ($SubFolder)"
}
}
$newApp = [PSCustomObject]@{ $newApp = [PSCustomObject]@{
Priority = $highestPriority Priority = $highestPriority
Name = if (-not [string]::IsNullOrEmpty($SubFolder)) { "$appName ($SubFolder)" } else { $appName } Name = $entryName
CommandLine = $silentInstallCommand CommandLine = $silentInstallCommand
Arguments = $silentInstallSwitch Arguments = $silentInstallSwitch
} }
# Add metadata for dependency ordering and dedupe tracking (ignored by installer script)
if (-not [string]::IsNullOrWhiteSpace($DependencyFor)) {
$newApp | Add-Member -NotePropertyName DependencyFor -NotePropertyValue $DependencyFor -Force
}
if (-not [string]::IsNullOrWhiteSpace($PackageIdentifier)) {
$newApp | Add-Member -NotePropertyName PackageIdentifier -NotePropertyValue $PackageIdentifier -Force
}
# Write the updated JSON file using a temp+rename to reduce partial-write risk
$appsData += $newApp $appsData += $newApp
$appsData | ConvertTo-Json -Depth 10 | Set-Content -Path $wingetWin32AppsJson $jsonText = $appsData | ConvertTo-Json -Depth 10
Set-FileContentAtomic -Path $wingetWin32AppsJson -Content $jsonText
WriteLog "Added $($newApp.Name) to WinGetWin32Apps.json with priority $highestPriority" return @{
Added = $true
App = $newApp
Priority = $highestPriority
}
}
# Return 0 for success if ($addOutcome -and $addOutcome.Added) {
WriteLog "Added $($addOutcome.App.Name) to WinGetWin32Apps.json with priority $($addOutcome.Priority)"
return 0
}
# Duplicate (or unexpected no-op) treated as success
return 0 return 0
} }
@@ -67,6 +67,7 @@ Description = 'Common functions shared between FFU Builder UI and the BuildFFUVM
# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess
NestedModules = @('FFU.Common.Drivers.psm1', NestedModules = @('FFU.Common.Drivers.psm1',
'FFU.Common.Drivers.Microsoft.psm1',
'FFU.Common.Drivers.Dell.psm1', 'FFU.Common.Drivers.Dell.psm1',
'FFU.Common.Winget.psm1', 'FFU.Common.Winget.psm1',
'FFU.Common.Parallel.psm1', 'FFU.Common.Parallel.psm1',
@@ -115,8 +115,27 @@ function Get-UIConfig {
} }
# Save selected USB drives using UniqueId for reliable identification # Save selected USB drives using UniqueId for reliable identification
# Multiple physical drives can share the same Model, so store an array of UniqueIds per Model.
$State.Controls.lstUSBDrives.Items | Where-Object { $_.IsSelected } | ForEach-Object { $State.Controls.lstUSBDrives.Items | Where-Object { $_.IsSelected } | ForEach-Object {
$config.USBDriveList[$_.Model] = $_.UniqueId $modelName = $_.Model
$uniqueId = $_.UniqueId
if ([string]::IsNullOrWhiteSpace($modelName) -or [string]::IsNullOrWhiteSpace($uniqueId)) {
return
}
# Ensure the hashtable value is always an array so multiple same-model drives are preserved
$existingUniqueIds = $config.USBDriveList[$modelName]
if ($null -eq $existingUniqueIds) {
$config.USBDriveList[$modelName] = @($uniqueId)
return
}
$existingUniqueIds = @($existingUniqueIds)
if (-not ($existingUniqueIds -contains $uniqueId)) {
$existingUniqueIds += $uniqueId
}
$config.USBDriveList[$modelName] = $existingUniqueIds
} }
# Additional FFU file selections # Additional FFU file selections
@@ -671,7 +690,19 @@ function Update-UIFromConfig {
} }
# Match USB drives by UniqueId instead of SerialNumber # Match USB drives by UniqueId instead of SerialNumber
if ($propertyExists -and ($propertyValue -eq $item.UniqueId)) { # USBDriveList values can be a single UniqueId (string) or an array of UniqueIds (multiple same-model drives)
$isMatch = $false
if ($propertyExists) {
if ($propertyValue -is [string]) {
$isMatch = ($propertyValue -eq $item.UniqueId)
}
else {
$propertyValueArray = @($propertyValue)
$isMatch = ($propertyValueArray -contains $item.UniqueId)
}
}
if ($isMatch) {
WriteLog "LoadConfig: Selecting USB Drive Model '$($item.Model)' with UniqueId '$($item.UniqueId)'." WriteLog "LoadConfig: Selecting USB Drive Model '$($item.Model)' with UniqueId '$($item.UniqueId)'."
$item.IsSelected = $true $item.IsSelected = $true
} }
@@ -10,12 +10,33 @@ function Get-MicrosoftDriversModelList {
[CmdletBinding()] [CmdletBinding()]
param( param(
[hashtable]$Headers, # Pass necessary headers [hashtable]$Headers, # Pass necessary headers
[string]$UserAgent # Pass UserAgent [string]$UserAgent, # Pass UserAgent
[Parameter(Mandatory = $true)]
[string]$DriversFolder
) )
$url = "https://support.microsoft.com/en-us/surface/download-drivers-and-firmware-for-surface-09bb2e09-2a4b-cb69-0951-078a7739e120" $url = "https://support.microsoft.com/en-us/surface/download-drivers-and-firmware-for-surface-09bb2e09-2a4b-cb69-0951-078a7739e120"
$models = @() $models = @()
# Load cached model list first (Source B) to keep the UI fast.
# The cache is refreshed automatically when missing or invalid.
try {
$cachePath = Get-SurfaceDriverIndexCachePath -DriversFolder $DriversFolder
if (Test-Path -Path $cachePath -PathType Leaf) {
$cacheAgeDays = ((Get-Date) - (Get-Item -Path $cachePath).LastWriteTime).TotalDays
if ($cacheAgeDays -lt 7) {
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
if ($cache.ModelIndex -and $cache.ModelIndex.Count -gt 0) {
WriteLog "Surface cache: Using cached Microsoft model list ($($cache.ModelIndex.Count) models)."
return @($cache.ModelIndex)
}
}
}
}
catch {
WriteLog "Surface cache: Failed to load cached Microsoft model list. Falling back to online parse. Error: $($_.Exception.Message)"
}
try { try {
WriteLog "Getting Surface driver information from $url" WriteLog "Getting Surface driver information from $url"
$OriginalVerbosePreference = $VerbosePreference $OriginalVerbosePreference = $VerbosePreference
@@ -70,6 +91,18 @@ function Get-MicrosoftDriversModelList {
} }
} }
WriteLog "Parsing complete. Found $($models.Count) models." WriteLog "Parsing complete. Found $($models.Count) models."
# Persist model list (Source B) into the local cache for fast UI population.
try {
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
$cache.ModelIndex = @($models)
Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder
WriteLog "Surface cache: Saved Microsoft model list to cache."
}
catch {
WriteLog "Surface cache: Failed to save Microsoft model list. Error: $($_.Exception.Message)"
}
return $models return $models
} }
catch { catch {
@@ -152,6 +185,47 @@ function Save-MicrosoftDriversTask {
### GET THE DOWNLOAD LINK ### GET THE DOWNLOAD LINK
$status = "Getting download link..." $status = "Getting download link..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status } if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Initialize Win10/Win11 link variables
$win10Link = $null
$win10FileName = $null
$win11Link = $null
$win11FileName = $null
# Prefer cached Download Center details (Source C) to avoid unnecessary internet calls and cache rewrites
$useCachedDownloadCenterDetails = $false
try {
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
$cachedDetails = @($cache.DownloadCenterDetails | Where-Object { $_.Link -eq $modelLink } | Select-Object -First 1)
if ($cachedDetails.Count -gt 0 -and $cachedDetails[0].Files -and $cachedDetails[0].Files.Count -gt 0) {
$useCachedDownloadCenterDetails = $true
WriteLog "Surface cache: Using cached Download Center details for $modelName from $modelLink"
foreach ($downloadFile in @($cachedDetails[0].Files)) {
if ($null -eq $downloadFile) { continue }
$currentFileName = $downloadFile.Name
$fileUrl = $downloadFile.Url
if ([string]::IsNullOrWhiteSpace($currentFileName) -or [string]::IsNullOrWhiteSpace($fileUrl)) { continue }
if ($currentFileName -match "Win10") {
$win10Link = $fileUrl
$win10FileName = $currentFileName
WriteLog "Found Win10 link (cached): $win10FileName"
}
elseif ($currentFileName -match "Win11") {
$win11Link = $fileUrl
$win11FileName = $currentFileName
WriteLog "Found Win11 link (cached): $win11FileName"
}
}
}
}
catch {
WriteLog "Surface cache: Failed loading cached Download Center details for '$modelName'. Error: $($_.Exception.Message)"
}
# Cache miss: download and parse the model's Download Center page (Source C), then backfill the cache
if (-not $useCachedDownloadCenterDetails) {
WriteLog "Getting download page content for $modelName from $modelLink" WriteLog "Getting download page content for $modelName from $modelLink"
$OriginalVerbosePreference = $VerbosePreference $OriginalVerbosePreference = $VerbosePreference
$VerbosePreference = 'SilentlyContinue' $VerbosePreference = 'SilentlyContinue'
@@ -172,12 +246,6 @@ function Save-MicrosoftDriversTask {
$downloadFilePattern = '"name":"([^"]+\.(?:msi|zip))",[^}]*?"url":"(.*?)"' $downloadFilePattern = '"name":"([^"]+\.(?:msi|zip))",[^}]*?"url":"(.*?)"'
$downloadFileMatches = [regex]::Matches($scriptContent, $downloadFilePattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) $downloadFileMatches = [regex]::Matches($scriptContent, $downloadFilePattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
$win10Link = $null
$win10FileName = $null
$win11Link = $null
$win11FileName = $null
# Iterate through all matches to find potential Win10 and Win11 links # Iterate through all matches to find potential Win10 and Win11 links
foreach ($downloadFile in $downloadFileMatches) { foreach ($downloadFile in $downloadFileMatches) {
$currentFileName = $downloadFile.Groups[1].Value $currentFileName = $downloadFile.Groups[1].Value
@@ -195,6 +263,45 @@ function Save-MicrosoftDriversTask {
} }
} }
# Update local cache with Download Center file details (Source C) for this model.
# This runs during download (not during Get Models) so it won't slow the listview population.
try {
$filesForCache = [System.Collections.Generic.List[pscustomobject]]::new()
if ($win10Link -and $win10FileName) {
$filesForCache.Add([pscustomobject]@{ Name = $win10FileName; Url = $win10Link })
}
if ($win11Link -and $win11FileName) {
$filesForCache.Add([pscustomobject]@{ Name = $win11FileName; Url = $win11Link })
}
if ($filesForCache.Count -gt 0) {
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
$detailsEntry = [pscustomobject][ordered]@{
Model = $modelName
Link = $modelLink
Files = @($filesForCache)
}
$newDetails = [System.Collections.Generic.List[pscustomobject]]::new()
foreach ($item in @($cache.DownloadCenterDetails)) {
if ($null -ne $item -and $item.PSObject.Properties['Link'] -and $item.Link -ne $modelLink) {
$newDetails.Add($item)
}
}
$newDetails.Add($detailsEntry)
$cache.DownloadCenterDetails = @($newDetails)
Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder
}
}
catch {
WriteLog "Surface cache: Failed updating Download Center details cache for '$modelName'. Error: $($_.Exception.Message)"
}
$useCachedDownloadCenterDetails = $true
}
}
if ($useCachedDownloadCenterDetails) {
# Decision logic to select the appropriate download link # Decision logic to select the appropriate download link
$downloadLink = $null $downloadLink = $null
$fileName = $null $fileName = $null
@@ -170,7 +170,7 @@ function Convert-DriverItemToJsonModel {
switch ($SelectedMake) { switch ($SelectedMake) {
'Microsoft' { 'Microsoft' {
$rawModels = Get-MicrosoftDriversModelList -Headers $Headers -UserAgent $UserAgent $rawModels = Get-MicrosoftDriversModelList -Headers $Headers -UserAgent $UserAgent -DriversFolder $localDriversFolder
} }
'Dell' { 'Dell' {
$rawModels = Get-DellDriversModelList -WindowsRelease $localWindowsRelease -DriversFolder $localDriversFolder -Make $SelectedMake $rawModels = Get-DellDriversModelList -WindowsRelease $localWindowsRelease -DriversFolder $localDriversFolder -Make $SelectedMake
@@ -969,6 +969,11 @@ function Invoke-DownloadSelectedDrivers {
Model = $modelName Model = $modelName
DriverPath = $driverPath DriverPath = $driverPath
} }
if ($driverMetadata.PSObject.Properties['Link'] -and -not [string]::IsNullOrWhiteSpace($driverMetadata.Link)) {
$driverRecord | Add-Member -NotePropertyName Link -NotePropertyValue $driverMetadata.Link
}
if ($driverMetadata.PSObject.Properties['SystemId'] -and -not [string]::IsNullOrWhiteSpace($driverMetadata.SystemId)) { if ($driverMetadata.PSObject.Properties['SystemId'] -and -not [string]::IsNullOrWhiteSpace($driverMetadata.SystemId)) {
$driverRecord | Add-Member -NotePropertyName SystemId -NotePropertyValue $driverMetadata.SystemId $driverRecord | Add-Member -NotePropertyName SystemId -NotePropertyValue $driverMetadata.SystemId
} }
+171 -13
View File
@@ -23,7 +23,7 @@ function Get-HardDrive() {
if ($manufacturer -eq 'Microsoft Corporation' -and $model -eq 'Virtual Machine') { if ($manufacturer -eq 'Microsoft Corporation' -and $model -eq 'Virtual Machine') {
WriteLog 'Running in a Hyper-V VM. Getting virtual disk on Index 0 and SCSILogicalUnit 0' WriteLog 'Running in a Hyper-V VM. Getting virtual disk on Index 0 and SCSILogicalUnit 0'
$diskDriveCandidates = @(Get-CimInstance -Class 'Win32_DiskDrive' | Where-Object { $_.MediaType -eq 'Fixed hard disk media' ` $diskDriveCandidates = @(Get-CimInstance -Class 'Win32_DiskDrive' | Where-Object { $_.MediaType -eq 'Fixed hard disk media' `
-and $_.Model -eq 'Microsoft Virtual Disk' -and $_.Model -eq 'Microsoft Virtual Disk' `
-and $_.Index -eq 0 ` -and $_.Index -eq 0 `
-and $_.SCSILogicalUnit -eq 0 -and $_.SCSILogicalUnit -eq 0
}) })
@@ -74,7 +74,13 @@ function Invoke-Process {
[Parameter()] [Parameter()]
[ValidateNotNullOrEmpty()] [ValidateNotNullOrEmpty()]
[string]$ArgumentList [string]$ArgumentList,
[Parameter()]
[switch]$IgnoreExitCode,
[Parameter()]
[switch]$PassThruExitCode
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
@@ -96,19 +102,39 @@ function Invoke-Process {
$cmd = Start-Process @startProcessParams $cmd = Start-Process @startProcessParams
$cmdOutput = Get-Content -Path $stdOutTempFile -Raw $cmdOutput = Get-Content -Path $stdOutTempFile -Raw
$cmdError = Get-Content -Path $stdErrTempFile -Raw $cmdError = Get-Content -Path $stdErrTempFile -Raw
if ($cmd.ExitCode -ne 0) { if ($cmd.ExitCode -ne 0) {
# Non-terminating mode: capture output to Scriptlog and continue
if ($IgnoreExitCode) {
if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) {
WriteLog $cmdOutput
}
if ([string]::IsNullOrEmpty($cmdError) -eq $false) {
WriteLog $cmdError
}
if ($PassThruExitCode) {
return $cmd.ExitCode
}
return
}
if ($cmdError) { if ($cmdError) {
throw $cmdError.Trim() throw $cmdError.Trim()
} }
if ($cmdOutput) { if ($cmdOutput) {
throw $cmdOutput.Trim() throw $cmdOutput.Trim()
} }
throw "Process failed. ExitCode = $($cmd.ExitCode)."
} }
else { else {
if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) { if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) {
WriteLog $cmdOutput WriteLog $cmdOutput
} }
} }
if ($PassThruExitCode) {
return $cmd.ExitCode
}
} }
} }
catch { catch {
@@ -558,6 +584,21 @@ function Find-DriverMappingRule {
return $null return $null
} }
'Microsoft' { 'Microsoft' {
# Prefer System SKU matching for Microsoft/Surface when available.
if (-not [string]::IsNullOrWhiteSpace($systemSkuNormalized)) {
foreach ($rule in $rulesForMake) {
if ($rule.PSObject.Properties['SystemSku'] -and $null -ne $rule.SystemSku) {
foreach ($sku in @($rule.SystemSku)) {
if (-not [string]::IsNullOrWhiteSpace($sku) -and $sku.Trim().ToUpperInvariant() -eq $systemSkuNormalized) {
WriteLog "DriverMapping: Microsoft SystemSku '$systemSkuNormalized' matched '$($rule.Model)'."
return $rule
}
}
}
}
}
# Fallback to model string comparison (legacy behavior).
foreach ($rule in $rulesForMake) { foreach ($rule in $rulesForMake) {
$ruleModelNorm = ConvertTo-ComparableModelName -Text $rule.Model $ruleModelNorm = ConvertTo-ComparableModelName -Text $rule.Model
if (-not [string]::IsNullOrWhiteSpace($ruleModelNorm) -and $ruleModelNorm -eq $normalizedModel) { if (-not [string]::IsNullOrWhiteSpace($ruleModelNorm) -and $ruleModelNorm -eq $normalizedModel) {
@@ -794,7 +835,7 @@ $LogFileName = 'ScriptLog.txt'
$USBDrive = Get-USBDrive $USBDrive = Get-USBDrive
New-item -Path $USBDrive -Name $LogFileName -ItemType "file" -Force | Out-Null New-item -Path $USBDrive -Name $LogFileName -ItemType "file" -Force | Out-Null
$LogFile = $USBDrive + $LogFilename $LogFile = $USBDrive + $LogFilename
$version = '2512.1Preview' $version = '2602.1Preview'
WriteLog 'Begin Logging' WriteLog 'Begin Logging'
WriteLog "Script version: $version" WriteLog "Script version: $version"
@@ -1562,65 +1603,182 @@ if ($null -ne $DriverSourcePath) {
Write-Host "Installing drivers from WIM: $DriverSourcePath" Write-Host "Installing drivers from WIM: $DriverSourcePath"
$TempDriverDir = "W:\TempDrivers" $TempDriverDir = "W:\TempDrivers"
try { try {
# Create working folder for WIM-based drivers
WriteLog "Creating temporary directory for drivers at $TempDriverDir" WriteLog "Creating temporary directory for drivers at $TempDriverDir"
New-Item -Path $TempDriverDir -ItemType Directory -Force | Out-Null New-Item -Path $TempDriverDir -ItemType Directory -Force | Out-Null
# Mount the driver WIM read-only so DISM can recurse the extracted INF tree
WriteLog "Mounting WIM contents to $TempDriverDir" WriteLog "Mounting WIM contents to $TempDriverDir"
Write-Host "Mounting WIM contents to $TempDriverDir" Write-Host "Mounting WIM contents to $TempDriverDir"
# For some reason can't use /mount-image with invoke-process, so using dism.exe directly # For some reason can't use /mount-image with invoke-process, so using dism.exe directly
dism.exe /Mount-Image /ImageFile:$DriverSourcePath /Index:1 /MountDir:$TempDriverDir /ReadOnly /optimize dism.exe /Mount-Image /ImageFile:$DriverSourcePath /Index:1 /MountDir:$TempDriverDir /ReadOnly /optimize
$mountExitCode = $LASTEXITCODE
if ($mountExitCode -ne 0) {
throw "DISM WIM mount failed. LastExitCode = $mountExitCode."
}
WriteLog "WIM mount successful." WriteLog "WIM mount successful."
# Inject drivers into the offline Windows image; failures here should not stop deployment
WriteLog "Injecting drivers from $TempDriverDir" WriteLog "Injecting drivers from $TempDriverDir"
Write-Host "Injecting drivers from $TempDriverDir" Write-Host "Injecting drivers from $TempDriverDir"
Write-Host "This may take a while, please be patient." Write-Host "This may take a while, please be patient."
Invoke-Process dism.exe "/image:W:\ /Add-Driver /Driver:""$TempDriverDir"" /Recurse" $driverInjectExitCode = Invoke-Process -FilePath dism.exe -ArgumentList "/image:W:\ /Add-Driver /Driver:""$TempDriverDir"" /Recurse" -IgnoreExitCode -PassThruExitCode
WriteLog "Driver injection from WIM succeeded." if ($driverInjectExitCode -ne 0) {
Write-Host "Driver injection from WIM succeeded." $warningMessage = "Warning: One or more drivers failed to inject from WIM. ExitCode = $driverInjectExitCode. Continuing deployment."
WriteLog $warningMessage
Write-Host $warningMessage -ForegroundColor Yellow
# Copy setupapi.offline.log to the USB drive when driver injection fails
$setupApiLogPath = 'W:\Windows\INF\setupapi.offline.log'
if (Test-Path -Path $setupApiLogPath) {
try {
Invoke-Process xcopy.exe """$setupApiLogPath"" ""$USBDrive"" /Y"
} }
catch { catch {
WriteLog "An error occurred during WIM driver installation: $_" WriteLog "Warning: Failed to copy setupapi.offline.log to $USBDrive. "
# Copy DISM log to USBDrive for debugging }
invoke-process xcopy.exe "X:\Windows\logs\dism\dism.log $USBDrive /Y" }
throw $_ else {
WriteLog "Warning: setupapi.offline.log not found at $setupApiLogPath"
}
}
else {
WriteLog "Driver injection from WIM succeeded."
Write-Host "Driver injection from WIM succeeded."
}
}
catch {
$warningMessage = "Warning: An error occurred during WIM driver installation. Continuing deployment."
WriteLog $warningMessage
Write-Host $warningMessage -ForegroundColor Yellow
# Copy troubleshooting logs to the USB drive when driver installation fails
try {
Invoke-Process cmd.exe "/c copy /Y ""X:\Windows\logs\dism\dism.log"" ""$($USBDrive)dism_driverinject.log"""
}
catch {
WriteLog "Warning: Failed to copy dism.log to $USBDrive."
}
$setupApiLogPath = 'W:\Windows\INF\setupapi.offline.log'
if (Test-Path -Path $setupApiLogPath) {
try {
Invoke-Process xcopy.exe """$setupApiLogPath"" ""$USBDrive"" /Y"
}
catch {
WriteLog "Warning: Failed to copy setupapi.offline.log to $USBDrive."
}
}
else {
WriteLog "Warning: setupapi.offline.log not found at $setupApiLogPath"
}
} }
finally { finally {
if (Test-Path -Path $TempDriverDir) { if (Test-Path -Path $TempDriverDir) {
# Always attempt to unmount and clean up; unmount failures should not stop deployment
WriteLog "Unmounting WIM from $TempDriverDir" WriteLog "Unmounting WIM from $TempDriverDir"
Write-Host "Unmounting WIM from $TempDriverDir" Write-Host "Unmounting WIM from $TempDriverDir"
try {
Invoke-Process dism.exe "/Unmount-Image /MountDir:""$TempDriverDir"" /Discard" Invoke-Process dism.exe "/Unmount-Image /MountDir:""$TempDriverDir"" /Discard"
WriteLog "Unmount successful." WriteLog "Unmount successful."
Write-Host "Unmount successful." Write-Host "Unmount successful."
}
catch {
$warningMessage = "Warning: Failed to unmount WIM from $TempDriverDir. Continuing cleanup."
WriteLog $warningMessage
Write-Host $warningMessage -ForegroundColor Yellow
}
WriteLog "Cleaning up temporary driver directory: $TempDriverDir" WriteLog "Cleaning up temporary driver directory: $TempDriverDir"
Write-Host "Cleaning up temporary driver directory: $TempDriverDir" Write-Host "Cleaning up temporary driver directory: $TempDriverDir"
try {
Remove-Item -Path $TempDriverDir -Recurse -Force Remove-Item -Path $TempDriverDir -Recurse -Force
WriteLog "Cleanup successful." WriteLog "Cleanup successful."
Write-Host "Cleanup successful." Write-Host "Cleanup successful."
} }
catch {
$warningMessage = "Warning: Failed to clean up temporary driver directory: $TempDriverDir."
WriteLog $warningMessage
Write-Host $warningMessage -ForegroundColor Yellow
}
}
} }
} }
elseif ($DriverSourceType -eq 'Folder') { elseif ($DriverSourceType -eq 'Folder') {
$substMapping = $null $substMapping = $null
try { try {
# Use SUBST to shorten long paths for DISM /Add-Driver
$substMapping = New-DriverSubstMapping -SourcePath $DriverSourcePath $substMapping = New-DriverSubstMapping -SourcePath $DriverSourcePath
$shortDriverPath = $substMapping.DrivePath $shortDriverPath = $substMapping.DrivePath
WriteLog "Injecting drivers from folder via SUBST. Source: $DriverSourcePath, Mapped: $($substMapping.DriveName)" WriteLog "Injecting drivers from folder via SUBST. Source: $DriverSourcePath, Mapped: $($substMapping.DriveName)"
Write-Host "Injecting drivers from folder: $shortDriverPath" Write-Host "Injecting drivers from folder: $shortDriverPath"
Write-Host "This may take a while, please be patient." Write-Host "This may take a while, please be patient."
Invoke-Process dism.exe "/image:W:\ /Add-Driver /Driver:$shortDriverPath /Recurse"
# Inject drivers into the offline Windows image; failures here should not stop deployment
$driverInjectExitCode = Invoke-Process -FilePath dism.exe -ArgumentList "/image:W:\ /Add-Driver /Driver:$shortDriverPath /Recurse" -IgnoreExitCode -PassThruExitCode
if ($driverInjectExitCode -ne 0) {
$warningMessage = "Warning: One or more drivers failed to inject from folder. ExitCode = $driverInjectExitCode. Continuing deployment."
WriteLog $warningMessage
Write-Host $warningMessage -ForegroundColor Yellow
# Copy setupapi.offline.log to the USB drive when driver injection fails
$setupApiLogPath = 'W:\Windows\INF\setupapi.offline.log'
if (Test-Path -Path $setupApiLogPath) {
try {
Invoke-Process xcopy.exe """$setupApiLogPath"" ""$USBDrive"" /Y"
}
catch {
WriteLog "Warning: Failed to copy setupapi.offline.log to $USBDrive. "
}
}
else {
WriteLog "Warning: setupapi.offline.log not found at $setupApiLogPath"
}
}
else {
WriteLog "Driver injection from folder succeeded." WriteLog "Driver injection from folder succeeded."
Write-Host "Driver injection from folder succeeded." Write-Host "Driver injection from folder succeeded."
} }
}
catch { catch {
WriteLog "An error occurred during folder driver installation: $_" $warningMessage = "Warning: An error occurred during folder driver installation. Continuing deployment."
WriteLog $warningMessage
Write-Host $warningMessage -ForegroundColor Yellow
# Copy troubleshooting logs to the USB drive when driver installation fails
try {
Invoke-Process xcopy.exe "X:\Windows\logs\dism\dism.log $USBDrive /Y" Invoke-Process xcopy.exe "X:\Windows\logs\dism\dism.log $USBDrive /Y"
throw $_ }
catch {
WriteLog "Warning: Failed to copy dism.log to $USBDrive."
}
$setupApiLogPath = 'W:\Windows\INF\setupapi.offline.log'
if (Test-Path -Path $setupApiLogPath) {
try {
Invoke-Process xcopy.exe """$setupApiLogPath"" ""$USBDrive"" /Y"
}
catch {
WriteLog "Warning: Failed to copy setupapi.offline.log to $USBDrive."
}
}
else {
WriteLog "Warning: setupapi.offline.log not found at $setupApiLogPath"
}
} }
finally { finally {
# Always attempt to remove SUBST mapping; failures here should not stop deployment
if ($null -ne $substMapping) { if ($null -ne $substMapping) {
try {
Remove-DriverSubstMapping -DriveLetter $substMapping.DriveLetter Remove-DriverSubstMapping -DriveLetter $substMapping.DriveLetter
} }
catch {
$warningMessage = "Warning: Failed to remove SUBST mapping $($substMapping.DriveLetter). Continuing deployment."
WriteLog $warningMessage
Write-Host $warningMessage -ForegroundColor Yellow
}
}
} }
} }
} }
+69
View File
@@ -0,0 +1,69 @@
---
title: M365 Apps/Office
nav_order: 8
prev_url: /appsscriptvariables.html
prev_label: Apps Script Variables
next_url: /drivers.html
next_label: Drivers
parent: UI Overview
---
# M365 Apps/Office
![1760378889283](image/appsscriptvariablescopy/1760378889283.png)
FFU Builder uses the Office Deployment Toolkit (ODT) to install Office. In the `.\FFUDevelopment\Apps\Office` folder you'll find two files:
* `DownloadFFU.xml`
* `DeployFFU.xml`
## DownloadFFU.xml
`DownloadFFU.xml` is responsible for the download of Office. It's invoked by `setup.exe /download .\DownloadFFU.xml` during the build process. It defaults to downloading the current channel 64-bit version of Office matching the current OS language to `C:\FFUDevelopment\Apps\Office`.
`DownloadFFU.xml` contents:
```
<Configuration ID="efa6df21-a106-428e-8eaa-d89a5dda6030">
<Add SourcePath="C:\FFUDevelopment\Apps\Office" OfficeClientEdition="64" Channel="Current">
<Product ID="O365ProPlusRetail">
<Language ID="MatchOS" />
</Product>
</Add>
</Configuration>
```
If you want to modify the language, you'll need to change the language ID to the language you wish to download and install.
For more information about deploying languages see: [Overview of deploying languages for Microsoft 365 Apps - Microsoft 365 Apps Microsoft Learn](https://learn.microsoft.com/en-us/microsoft-365-apps/deploy/overview-deploying-languages-microsoft-365-apps)
## DeployFFU.xml
`DeployFFU.xml` is responsible for customizing the installation of Office. If you don't provide a custom XML, it will default to using what's in `DeployFFU.xml`. The default configuration will install the 64-bit current channel version of Office with Word, Excel, Powerpoint. Below is what's currently in `DeployFFU.xml`:
```
<Configuration ID="efa6df21-a106-428e-8eaa-d89a5dda6030">
<Add OfficeClientEdition="64" Channel="Current">
<Product ID="O365ProPlusRetail">
<Language ID="MatchOS" />
<ExcludeApp ID="Access" />
<ExcludeApp ID="Lync" />
<ExcludeApp ID="Publisher" />
<ExcludeApp ID="Bing" />
<ExcludeApp ID="Teams" />
<ExcludeApp ID="Outlook" />
</Product>
</Add>
<Property Name="SharedComputerLicensing" Value="0" />
<Property Name="FORCEAPPSHUTDOWN" Value="FALSE" />
<Property Name="DeviceBasedLicensing" Value="0" />
<Property Name="SCLCacheOverride" Value="0" />
<Updates Enabled="TRUE" />
<Display Level="None" AcceptEULA="TRUE" />
</Configuration>
```
## Copy Office Configuration XML
If you want to include your own custom XML file for office, check **Copy Office Configuration XML** and browse to the location of your custom XML file. The path to your custom Office configuration XML file is stored in the `OfficeConfigXMLFile` parameter. This file gets added to the `.\FFUDevelopment\Apps\Office` folder and is referenced in the `.\FFUDevelopment\Apps\Orchestration\Install-Office.ps1` file.
{% include page_nav.html %}
+23
View File
@@ -0,0 +1,23 @@
title: FFU Builder
description: Build and deploy Windows FFU images
remote_theme: just-the-docs/just-the-docs@v0.10.1
plugins:
- jekyll-remote-theme
- jekyll-seo-tag
- jekyll-sitemap
search_enabled: true
# Because youll publish as a project site at /FFU
baseurl: "/FFU"
callouts:
note:
title: Note
color: purple
tip:
title: Tip
color: green
warning:
title: Warning
color: yellow
+199
View File
@@ -0,0 +1,199 @@
<!-- docs/_includes/head_custom.html -->
<style>
/* Layout: remove Just-the-Docs "centered narrow" constraints on wide screens */
@media (min-width: 50rem) {
.main {
max-width: none !important;
}
}
@media (min-width: 66.5rem) {
.side-bar {
width: 16.5rem !important;
min-width: 16.5rem !important;
}
.side-bar+.main {
margin-left: 16.5rem !important;
}
}
/* Readability: wider column + slightly larger, less-thin text */
@media (min-width: 66.5rem) {
.main-content {
max-width: 1100px;
}
}
@media (min-width: 90rem) {
.main-content {
max-width: 1280px;
}
}
/* Typography: approximate Microsoft Learn (Segoe UI Variable + regular body + semibold headings) */
body,
.main-content {
font-family: "Segoe UI Variable Text", "Segoe UI Variable", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
.main-content {
margin-right: auto;
margin-left: auto;
font-size: 1rem;
/* 16px-ish, closer to Learn */
line-height: 1.6;
font-weight: 400;
/* Just-the-Docs defaults body text to a mid-grey; make it closer to Learn */
color: #242424;
}
.main-content p,
.main-content li {
line-height: 1.65;
}
.main-content h1,
.main-content h2,
.main-content h3 {
font-weight: 600;
letter-spacing: -0.01em;
}
.main-content code,
.main-content pre code {
font-size: 0.95em;
}
/* Wrapping: prevent long code/paths from overflowing into the page TOC */
.main-content {
overflow-wrap: anywhere;
word-break: break-word;
}
.main-content :not(pre) > code {
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.main-content a {
overflow-wrap: anywhere;
word-break: break-word;
}
/* Images: make it obvious they're zoomable (opt-out via class="no-zoom") */
.main-content img:not(.no-zoom) {
cursor: zoom-in;
}
/* Image zoom: ensure the zoom overlay sits above the right TOC */
.medium-zoom-overlay {
z-index: 9999 !important;
}
.medium-zoom-image--opened {
z-index: 10000 !important;
}
/* Right-side page TOC (desktop only) */
@media (min-width: 66.5rem) {
.main-content-wrap.has-page-toc {
display: grid;
grid-template-columns: minmax(0, 1fr) 16rem;
grid-template-rows: auto 1fr;
grid-template-areas:
"breadcrumb breadcrumb"
"content toc";
column-gap: 2rem;
align-items: start;
}
/* Breadcrumbs (when present) always span full width */
.main-content-wrap.has-page-toc .breadcrumb-nav {
grid-area: breadcrumb;
}
/* Main content always stays in the left column */
.main-content-wrap.has-page-toc .main-content {
grid-area: content;
/* Prevent wide tables/code from forcing overlap */
min-width: 0;
/* Force the content to respect the grid column width (no centering/max-width overflow) */
width: 100%;
max-width: 100%;
margin-left: 0;
margin-right: 0;
justify-self: stretch;
/* Safety net: if anything still overflows, don't let it render under the TOC */
overflow-x: hidden;
}
/* TOC always stays in the right column */
.page-toc {
grid-area: toc;
position: sticky;
top: 5.5rem;
max-height: calc(100vh - 6.5rem);
overflow: auto;
padding-left: 1rem;
border-left: 1px solid #eeebee;
font-size: 0.875rem;
/* Ensure the TOC doesnt visually blend with overflowing content */
background-color: #fff;
z-index: 1;
}
.page-toc__title {
font-weight: 600;
color: #27262b;
margin-bottom: 0.75rem;
}
.page-toc__list {
list-style: none;
padding-left: 0;
margin: 0;
}
.page-toc__item {
margin: 0.4rem 0;
}
.page-toc__item--h3 {
padding-left: 0.75rem;
}
.page-toc__link {
color: inherit;
text-decoration: none;
display: block;
padding: 0.125rem 0 0.125rem 0.75rem;
border-left: 3px solid transparent;
}
.page-toc__link:hover {
text-decoration: underline;
}
.page-toc__link.is-active {
font-weight: 600;
color: #1a1a1a;
border-left-color: #2563eb;
}
}
</style>
<meta name="ffu-right-toc" content="{% if page.right_toc == false %}false{% else %}true{% endif %}">
<script src="{{ '/assets/js/vendor/medium-zoom.min.js' | relative_url }}" defer></script>
<script src="{{ '/assets/js/image-zoom.js' | relative_url }}" defer></script>
<script src="{{ '/assets/js/page-toc.js' | relative_url }}" defer></script>
+17
View File
@@ -0,0 +1,17 @@
<!-- docs/_includes/page_nav.html -->
<div class="d-flex flex-justify-between mt-6">
{% assign prev_url = include.prev_url | default: page.prev_url %}
{% assign prev_label = include.prev_label| default: page.prev_label | default: 'Home' %}
{% assign next_url = include.next_url | default: page.next_url %}
{% assign next_label = include.next_label| default: page.next_label | default: 'Next' %}
{% if prev_url %}
<a class="btn btn-outline" href="{{ prev_url | relative_url }}">← {{ prev_label }}</a>
{% else %}
<span></span>
{% endif %}
{% if next_url %}
<a class="btn btn-blue" href="{{ next_url | relative_url }}">{{ next_label }} →</a>
{% endif %}
</div>
+21
View File
@@ -0,0 +1,21 @@
---
title: Applications
nav_order: 4
prev_url: /updates.html
prev_label: Updates
next_url: /winget.html
next_label: Install Winget Applications
parent: UI Overview
has_toc: false
---
# Applications
![1759881364454](image/updatescopy/1759881364454.png)
Applications can be installed in three different ways:
* Winget (using an AppList.json file)
* Bring Your Own Applications (using files you provide - can also be used to run command lines with or without content)
* Apps Script Variables (key/value pairs used in conjunction with a PowerShell script to install custom applications)
{% include page_nav.html %}
+138
View File
@@ -0,0 +1,138 @@
---
title: Apps Script Variables
nav_order: 7
prev_url: /byoapps.html
prev_label: BYO Applications
next_url: /M365appsoffice.html
next_label: M365 Apps Office
parent: Applications
grand_parent: UI Overview
---
# Apps Script Variables
![1760135511234](image/appsscriptvariables/1760135511234.png)
Apps Script Variables are key value pairs that are used to create a hashtable that is passed to the `BuildFFUVM.ps1` script (stored in the `$AppScriptVariables` parameter as a hashtable). At build time, `BuildFFUVM.ps1` will export the `$AppsScriptVariables` hashtable to an `AppsScriptVariables.json` file in the `$OrchestrationPath` folder (`$AppsPath\Orchestration`). You can also manually create your own `AppsScriptVariables.json `file and place it in the `$AppsPath\Orchestration` folder.
In the VM, the `Orchestrator.ps1` file will call `Invoke-AppsScript.ps1` if `AppsScriptVariables.json` exists. `Invoke-AppsScript.ps1` must be modified to handle your variables.
`Invoke-AppsScript.ps1` has the following commented example of how to modify the file:
```
# 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"
# }
# else {
# Write-Host "Foo would not have installed"
# }
# 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 {
# Write-Host "Teams would not have been installed"
# }
```
## Why use Apps Script Variables?
This allows for you to create a dynamic task sequence via a PowerShell script with simple if statements to run apps, commands, etc. This is all driven by your `FFUConfig.json` file. For example, the following `FFUConfig.json` file contains an AppsScriptVariables hashtable of foo and vmwaretools like the screenshot above. If you build servers that require vmware tools, you may set the value to true. However there may be situations where you don't need vmwaretools installed. If that's the case, you set vmwaretools to false. This allows for your `Invoke-AppsScript.ps1` file to stay the same and all you have to do is adjust the variables.
```
{
"AdditionalFFUFiles": [],
"AllowExternalHardDiskMedia": false,
"AllowVHDXCaching": false,
"AppListPath": "C:\\FFUDevelopment\\Apps\\AppList.json",
"AppsPath": "C:\\FFUDevelopment\\Apps",
"AppsScriptVariables": {
"foo": "bar",
"vmwaretools": "true"
},
"BuildUSBDrive": false,
"CleanupAppsISO": true,
"CleanupCaptureISO": true,
"CleanupDeployISO": true,
"CleanupDrivers": false,
"CompactOS": true,
"CompressDownloadedDriversToWim": false,
"CopyAdditionalFFUFiles": false,
"CopyAutopilot": false,
"CopyDrivers": false,
"CopyOfficeConfigXML": false,
"CopyPEDrivers": false,
"CopyPPKG": false,
"CopyUnattend": false,
"CreateCaptureMedia": true,
"CreateDeploymentMedia": true,
"CustomFFUNameTemplate": "{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}",
"Disksize": 53687091200,
"DownloadDrivers": false,
"DriversFolder": "C:\\FFUDevelopment\\Drivers",
"DriversJsonPath": "C:\\FFUDevelopment\\Drivers\\Drivers.json",
"FFUCaptureLocation": "C:\\FFUDevelopment\\FFU",
"FFUDevelopmentPath": "C:\\FFUDevelopment",
"FFUPrefix": "_FFU",
"InjectUnattend": false,
"InstallApps": true,
"InstallDrivers": false,
"InstallOffice": false,
"InstallWingetApps": false,
"ISOPath": "",
"LogicalSectorSizeBytes": 512,
"MaxUSBDrives": 5,
"MediaType": "Consumer",
"Memory": 4294967296,
"OfficeConfigXMLFile": "",
"OfficePath": "C:\\FFUDevelopment\\Apps\\Office",
"Optimize": true,
"OptionalFeatures": "",
"OrchestrationPath": "C:\\FFUDevelopment\\Apps\\Orchestration",
"PEDriversFolder": "C:\\FFUDevelopment\\PEDrivers",
"Processors": 4,
"ProductKey": "",
"PromptExternalHardDiskMedia": true,
"RemoveApps": false,
"RemoveFFU": false,
"RemoveUpdates": false,
"ShareName": "FFUCaptureShare",
"Threads": 5,
"UpdateADK": true,
"UpdateEdge": true,
"UpdateLatestCU": true,
"UpdateLatestDefender": true,
"UpdateLatestMicrocode": false,
"UpdateLatestMSRT": true,
"UpdateLatestNet": true,
"UpdateOneDrive": true,
"UpdatePreviewCU": false,
"USBDriveList": {},
"UseDriversAsPEDrivers": false,
"UserAppListPath": "C:\\FFUDevelopment\\Apps\\UserAppList.json",
"Username": "ffu_user",
"Verbose": false,
"VMHostIPAddress": "192.168.1.169",
"VMLocation": "C:\\FFUDevelopment\\VM",
"VMSwitchName": "External",
"WindowsArch": "x64",
"WindowsLang": "en-us",
"WindowsRelease": 11,
"WindowsSKU": "Pro",
"WindowsVersion": "25H2"
}
```
Example command line to run with vmwaretools set to false and foo set to foo. This will create the `AppsScriptVariables.json` file in the Orchestration folder with the updated values of `foo=foo` and `vmwaretools=false` without the need to modify the config file.
`.\BuildFFUVM.ps1 -configFile 'C:\FFUDevelopment\config\FFUConfig.json' -appsScriptVariables @{foo='foo'; vmwaretools='false'}`
{% include page_nav.html %}
+22
View File
@@ -0,0 +1,22 @@
(function () {
'use strict';
function InitImageZoom() {
if (window.mediumZoom === undefined) {
return;
}
window.mediumZoom('.main-content img:not(.no-zoom):not([src$=".svg"])', {
margin: 24,
background: 'rgba(0,0,0,0.80)',
scrollOffset: 0
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', InitImageZoom);
return;
}
InitImageZoom();
})();
+286
View File
@@ -0,0 +1,286 @@
(function () {
'use strict';
function IsRightTocEnabled() {
var meta = document.querySelector('meta[name="ffu-right-toc"]');
if (meta && meta.content && meta.content.toLowerCase() === 'false') {
return false;
}
return true;
}
function IsDesktopViewport() {
try {
return window.matchMedia && window.matchMedia('(min-width: 66.5rem)').matches;
} catch (e) {
return false;
}
}
function GetHeadings(container) {
var headings = container.querySelectorAll('h2, h3');
var results = [];
for (var i = 0; i < headings.length; i++) {
var heading = headings[i];
if (heading.classList.contains('no_toc')) {
continue;
}
var id = heading.getAttribute('id');
if (!id) {
continue;
}
var text = (heading.textContent || '').trim();
if (!text) {
continue;
}
results.push({
level: heading.tagName.toLowerCase(),
id: id,
text: text
});
}
return results;
}
function BuildToc(headings) {
var nav = document.createElement('nav');
nav.className = 'page-toc';
nav.setAttribute('aria-label', 'On this page');
var title = document.createElement('div');
title.className = 'page-toc__title';
title.textContent = 'In this article';
nav.appendChild(title);
var list = document.createElement('ul');
list.className = 'page-toc__list';
for (var i = 0; i < headings.length; i++) {
var item = headings[i];
var li = document.createElement('li');
li.className = 'page-toc__item page-toc__item--' + item.level;
var a = document.createElement('a');
a.className = 'page-toc__link';
a.href = '#' + item.id;
a.textContent = item.text;
li.appendChild(a);
list.appendChild(li);
}
nav.appendChild(list);
return nav;
}
function SetActiveTocLink(toc, activeId) {
if (!toc) {
return;
}
var links = toc.querySelectorAll('.page-toc__link');
for (var i = 0; i < links.length; i++) {
var link = links[i];
var href = link.getAttribute('href') || '';
var isActive = ('#' + activeId) === href;
if (isActive) {
link.classList.add('is-active');
/* Keep the active item visible inside the TOC panel */
try {
link.scrollIntoView({ block: 'nearest' });
} catch (e) {
link.scrollIntoView();
}
} else {
link.classList.remove('is-active');
}
}
}
function SetupScrollSpy(main, toc, headings) {
if (!main || !toc || !headings || headings.length < 1) {
return;
}
/* Scrollspy is desktop-only; on mobile it can cause "fighting" scroll behavior */
if (!IsDesktopViewport()) {
return;
}
var headingElements = [];
for (var i = 0; i < headings.length; i++) {
var el = document.getElementById(headings[i].id);
if (el) {
headingElements.push(el);
}
}
if (headingElements.length < 1) {
return;
}
var activeId = null;
var ticking = false;
var lockActiveUntilMs = 0;
function IsNearBottomOfPage() {
var thresholdPx = 24;
var scrollY = window.scrollY || window.pageYOffset || 0;
var viewportBottom = scrollY + window.innerHeight;
var pageHeight = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight);
return viewportBottom >= (pageHeight - thresholdPx);
}
function GetCurrentHeadingId() {
/* If we're at the bottom, force the last heading active (Learn-like behavior) */
if (IsNearBottomOfPage()) {
return headingElements[headingElements.length - 1].getAttribute('id');
}
/* Choose the heading closest to the top "activation line" */
var activationLine = 16;
var current = null;
for (var i = 0; i < headingElements.length; i++) {
var rectTop = headingElements[i].getBoundingClientRect().top;
if (rectTop <= activationLine) {
current = headingElements[i];
continue;
}
if (null === current) {
current = headingElements[i];
}
break;
}
if (null === current) {
current = headingElements[0];
}
return current.getAttribute('id');
}
function Update() {
ticking = false;
if (Date.now() < lockActiveUntilMs) {
return;
}
var currentId = GetCurrentHeadingId();
if (!currentId || currentId === activeId) {
return;
}
activeId = currentId;
SetActiveTocLink(toc, activeId);
}
function OnScrollOrResize() {
if (ticking) {
return;
}
ticking = true;
window.requestAnimationFrame(Update);
}
window.addEventListener('scroll', OnScrollOrResize, { passive: true });
window.addEventListener('resize', OnScrollOrResize);
/* Update immediately and also when clicking TOC links */
toc.addEventListener('click', function (evt) {
var target = evt.target;
if (!target || !target.classList || !target.classList.contains('page-toc__link')) {
return;
}
var href = target.getAttribute('href') || '';
if (href.charAt(0) !== '#') {
return;
}
var id = href.substring(1);
if (!id) {
return;
}
/* Prevent scrollspy from immediately overriding the clicked section */
lockActiveUntilMs = Date.now() + 800;
activeId = id;
SetActiveTocLink(toc, activeId);
});
Update();
}
function InitRightToc() {
if (!IsRightTocEnabled()) {
return;
}
/* Desktop-only TOC: on mobile it interferes with scrolling */
if (!IsDesktopViewport()) {
var existingWrap = document.querySelector('.main-content-wrap');
if (existingWrap) {
var existingToc = existingWrap.querySelector('.page-toc');
if (existingToc) {
existingToc.remove();
}
existingWrap.classList.remove('has-page-toc');
}
return;
}
var main = document.querySelector('.main-content main');
if (!main) {
return;
}
var headings = GetHeadings(main);
if (headings.length < 2) {
return;
}
var wrap = document.querySelector('.main-content-wrap');
var content = document.querySelector('.main-content');
if (!wrap || !content) {
return;
}
if (wrap.querySelector('.page-toc')) {
return;
}
wrap.classList.add('has-page-toc');
var toc = BuildToc(headings);
wrap.appendChild(toc);
SetupScrollSpy(main, toc, headings);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', InitRightToc);
return;
}
InitRightToc();
})();
File diff suppressed because one or more lines are too long
+822
View File
@@ -0,0 +1,822 @@
---
title: Build
nav_order: 9
prev_url: /drivers.html
prev_label: Drivers
next_url: /monitor.html
next_label: Monitor
parent: UI Overview
---
# Build
![FFU Builder Build tab](image/build/1764362222174.png "FFU Builder Build tab")
The Build tab is where the magic happens
## FFU Development Path
The FFU Development path (`$FFUDevelopmentPath`) is the root path of where most other paths are derived. The default is `$PSScriptRoot`, which is the location the script is currently running from and can be changed to another location from within the UI.
If you want to download and test new releases, or want to create a new FFUDevelopment folder without modifying your existing one, you can always download the source files and put them in another location.
The recommendation is to run from `C:\FFUDevelopment` and in most cases the path shouldn't need to be changed.
## Custom FFU Name Template
Controls the `-CustomFFUNameTemplate` parameter. This allows you to define a custom naming convention for the captured FFU file using placeholders that are replaced at build time.
If left blank, the default FFU naming convention is used.
### Available Placeholders
| Placeholder | Description | Example |
| -------------------- | ---------------------- | ------------------------------------------------------------------------------ |
| `{WindowsRelease}` | Windows release number | `10`, `11`, `2016`, `2019`, `2022`, `2025` |
| `{WindowsVersion}` | Windows version | `1607`, `1809`, `21h2`, `22h2`, `23h2`, `24h2` |
| `{SKU}` | Windows edition | `Home`, `Pro`, `Enterprise`, `Education`, `Standard`, `Datacenter` |
| `{BuildDate}` | Month and year | `Nov2025` |
| `{yyyy}` | 4-digit year | `2025` |
| `{MM}` | 2-digit month | `11` (for November) |
| `{dd}` | 2-digit day | `28` |
| `{HH}` | Hour in 24-hour format | `14` (for 2 PM) |
| `{hh}` | Hour in 12-hour format | `02` (for 2 PM) |
| `{mm}` | 2-digit minute | `09` |
| `{tt}` | AM/PM designator | `AM` or `PM` |
### Examples
**Basic template with date and time:**
```
{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}
```
Result: `Win11_24h2_Pro_2025-11-28_1425.ffu`
**Template with static text (e.g., indicating Office is installed):**
```
{WindowsRelease}_{WindowsVersion}_{SKU}_Office_{yyyy}-{MM}-{dd}_{HH}{mm}
```
Result: `Win11_24h2_Pro_Office_2025-11-28_1425.ffu`
**Simple template with build date:**
```
{WindowsRelease}_{WindowsVersion}_{SKU}_{BuildDate}
```
Result: `Win11_24h2_Pro_Nov2025.ffu`
{: .note-title}
> Note
>
> The `.ffu` extension is automatically appended if not included in the template.
## FFU Capture Location
The FFU Capture Location sets the `-FFUCaptureLocation` parameter that determines where completed `.ffu` images are written. By default it points to `$FFUDevelopmentPath\FFU`, and the build script creates the folder automatically if it does not already exist.
When apps are installed in a VM, the host converts this folder into a temporary SMB share using the **Share Name** and **Username** fields. The capture WinPE environment maps that share as drive `W:` and streams the captured image directly into this folder. When the build finishes, the share and local account are removed, but the FFU files remain unless a cleanup option deletes them.
Choose a path on fast storage with plenty of free space—the directory must be local to the host running `BuildFFUVM.ps1`, and large captures can easily exceed 2530 GB. This location also feeds other options such as **Copy Additional FFU Files**, **Build USB Drive**, and **Remove FFU**, so keeping all finished images here keeps those workflows simple.
## Share Name
The Share Name sets the `-ShareName` parameter that defines the name of the temporary SMB share created during the FFU capture process. The default is `FFUCaptureShare`.
During the build, the host creates an SMB share that points to the **FFU Capture Location** and grants access to the temporary local user account defined in **Username**. The capture WinPE environment maps this share as drive `W:` using `net use` and streams the captured FFU image directly to it.
When the build completes, the share is automatically removed along with the temporary user account, leaving only the captured FFU files behind in the FFU Capture Location.
## Username
The Username field sets the `-Username` parameter that `BuildFFUVM.ps1` uses when creating the temporary SMB share user. The value becomes a local standard user account that is granted Full Control on the **FFU Capture Location** share (default C:\FFUDevelopment\FFU) so the capture WinPE session can copy the FFU over `net use `. The default `ffu_user` account name works for most scenarios, but you can supply any other local account name that meets your organization's policies.
When the build starts, the script ensures the account exists, rotates its password to a randomly generated GUID, and grants it access to the share. The Capture WinPE environment maps drive `W:` with those credentials, then writes the captured image directly into the FFU Capture Location.
After the build finishes, the share is removed and the temporary account is deleted, leaving only the FFU files stored in the capture folder.
## Threads
Controls the `-Threads` parameter, which sets the number of parallel threads used for concurrent operations throughout FFU Builder. The default value is **5**.
### Operations Affected by Threads
The Threads value applies to the following parallel operations:
| Operation | Description |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| **Winget Application Downloads** | When downloading multiple Winget applications, each application download runs as a parallel task |
| **BYO Application Copy** | When copying multiple Bring Your Own (BYO) applications to the Apps folder, each copy operation runs in parallel |
| **Driver Downloads** | When downloading drivers for multiple device models, each driver download and extraction runs as a parallel task |
### Recommended Values
| Threads | Use Case |
| -------------- | ------------------------------------------------------------------------- |
| **1** | Minimal system impact; useful for troubleshooting or low-resource systems |
| **5** | Default; balanced performance for most systems |
| **8-10** | Higher concurrency for systems with fast storage and network connections |
{: .note-title}
> Note
>
> Setting a higher thread count may improve download times but will increase resource utilization. If you experience stability issues or resource constraints, try reducing the thread count.
### Validation
The UI validates that the Threads value is a valid integer greater than or equal to 1. If an invalid value is entered, it automatically resets to **1**.
## BITS Priority
Controls the `-BitsPriority` parameter, which determines the priority level for Background Intelligent Transfer Service (BITS) downloads. The default value is **Normal**.
If you want faster downloads, change the priority to Foreground. Normal priority will significantly slow down downloads since BITS treats non-Foreground downloads as synchronous and queues each download. This means multiple driver or winget application downloads will go much slower than using Foreground. Normal is default as per Microsoft best practice guidance for using BITS.
## Build USB Drive
The following sub-options control how the USB drive is created
### Allow External Hard Disk Media
Controls the `-AllowExternalHardDiskMedia` parameter. When checked, allows the use of drives identified as "External hard disk media" via the WMI class `Win32_DiskDrive`. The default is **unchecked**.
Most USB thumb drives are identified by Windows as "Removable Media" and work with the default settings. However, faster USB drives—such as portable SSDs or high-speed USB 3.x drives—may be identified as "External hard disk media" instead. If you want to use these faster drives for imaging, enable this option.
{: .warning-title}
> Warning
>
> Enabling this option may expose external hard drives attached to your machine to the USB imaging process. To prevent accidental data loss, use the **Prompt for External Hard Disk Media** option (enabled by default when this option is checked) to confirm which drive to use before formatting.
### Prompt for External Hard Disk Media
Controls the `-PromptExternalHardDiskMedia` parameter. When checked, prompts for user confirmation before using any drive identified as "External hard disk media". The default is **checked** when **Allow External Hard Disk Media** is enabled.
This option is only available when **Allow External Hard Disk Media** is checked.
When enabled, the build process will:
1. Display a table listing all detected external hard disk media drives, including drive name, serial number, partition style, and status.
2. Prompt you to select which drive to use for imaging.
3. Only create a USB drive on the selected drive.
When disabled, the script will not prompt and can use multiple external hard disk drives simultaneously, similar to how removable USB drives function. This is useful for automated or batch imaging scenarios but increases the risk of accidental data loss.
{: .note-title}
> Note
>
> If you do not want to be prompted each time, you can disable this option after verifying that only your intended imaging drives are connected.
### Select Specific USB Drives
When checked, enables manual selection of specific USB drives for imaging. The default is **unchecked**.
This option is only available when **Build USB Drive** is checked.
When enabled, a **Check USB drives** button and a list view appear. Click **Check USB drives** to scan for connected USB drives. The list displays all detected drives with the following information:
| Column | Description |
| ------------------- | ----------------------------------------------------- |
| **Select** | Checkbox to include or exclude the drive from imaging |
| **Model** | The model name of the USB drive |
| **Unique ID** | A unique identifier for the drive |
| **Size (GB)** | The total capacity of the drive in gigabytes |
Select one or more drives by checking the checkbox in the **Select** column. Only selected drives will be formatted and used for imaging when the build completes.
Use the **Select All** checkbox in the column header to quickly select or deselect all drives.
{: .note-title}
> Note
>
> If **Select Specific USB Drives** is unchecked, the build process will automatically use all discovered USB drives.
### Copy Autopilot Profile
Controls the `-CopyAutopilot` parameter. When checked, copies the contents of the `.\FFUDevelopment\Autopilot` folder to the `Autopilot` folder on the Deployment partition of the USB drive. The default is **unchecked**.
This option is only available when **Build USB Drive** is checked.
This leverages the Autopilot for existing devices json file. It's not recommended to use this method any longer as devices enrolled via this method are enrolled as personal instead of corporate.
### Copy Unattend.xml
Controls the `-CopyUnattend` parameter. When checked, copies the architecture-appropriate unattend XML file from `.\FFUDevelopment\Unattend` to an `Unattend` folder on the Deployment partition of the USB drive. The default is **unchecked**.
This option is only available when **Build USB Drive** is checked.
When enabled, the build process copies:
- **unattend_x64.xml** (for x64 builds) or **unattend_arm64.xml** (for arm64 builds) → renamed to **Unattend.xml** on the USB drive
- **prefixes.txt** (if present) → copied alongside the unattend file
During deployment, `ApplyFFU.ps1` detects the `Unattend` folder and uses these files to customize the device name and apply other Windows settings during OOBE.
#### Device Naming
Device naming can be done from PE. The way this works is by leveraging an unattend.xml file to either take input from the user at imaging time or read a list of prefix values and append the serial number of the device. There are some major benefits to doing this:
1. Total deployment time is reduced if naming is set at FFU deployment time since there is no additional reboot done during OOBE.
2. Reduces the need for multiple provisioning packages or autopilot profiles. This means you can use a single PPKG or autopilot profile.
#### Prompt for Device Name
If you want to be prompted for the device name, simply check **Copy Unattend.xml.** This tells the build script to copy the appropriate architecture unattend_arch.xml file from the `C:\FFUDevelopment\Unattend` folder to the `.\unattend` folder on the deploy partition of the USB drive.
#### Device Naming with prefixes.txt
If a `prefixes.txt` file exists in the `Unattend` folder and there are multiple prefixes in the file, the deployment script prompts the technician to select a prefix from the file. The prefix is combined with the device's serial number to create the computer name. If there is a single prefix, the technician is not prompted and the script will automatically select that prefix.
For example, with a prefix of `CORP-` and a serial number of `ABC123`, the resulting computer name would be `CORP-ABC123` (truncated to 15 characters if necessary).
Sample `prefixes.txt` content:
```plaintext
CORP-
STORE-
KIOSK-
```
{: .warning-title}
> Warning
>
> If using a provisioning package or autopilot json file, DO NOT specify a name in either of these. They will overwrite the name you have specified in the unattend.xml.
#### Creating Your Unattend Files
The `.\FFUDevelopment\Unattend` folder includes sample files you can customize:
| File | Description |
| -------------------------------- | ------------------------------------------ |
| **SampleUnattend_x64.xml** | Example unattend file for x64 systems |
| **unattend_x64.xml** | Active unattend file used for x64 builds |
| **unattend_arm64.xml** | Active unattend file used for arm64 builds |
| **SamplePrefixes.txt** | Example prefixes file for device naming |
Copy and customize the sample files to create your own `unattend_x64.xml`, `unattend_arm64.xml`, and `prefixes.txt` files.
{: .note-title}
> Note
>
> The unattend file must contain a `<ComputerName>` element in the `Microsoft-Windows-Shell-Setup` component for device naming to work. See the sample files for the correct structure.
### Copy Provisioning Package
Controls the `-CopyPPKG` parameter. When checked, copies the contents of the `.\FFUDevelopment\PPKG` folder to the `PPKG` folder on the Deployment partition of the USB drive. The default is **unchecked**.
This option is only available when **Build USB Drive** is checked.
#### How It Works
1. **During Build**: The build process copies all `.ppkg` files from `.\FFUDevelopment\PPKG` to the USB drive.
2. **During Deployment**: When `ApplyFFU.ps1` runs, it detects the `PPKG` folder and the provisioning packages within it.
- If **multiple** `.ppkg` files are found, the technician is prompted to select which package to apply.
- If **one** `.ppkg` file is found, it is automatically selected.
3. **Application**: The selected provisioning package is copied to the root of the USB drive, where Windows picks it up during OOBE and applies the settings.
### Copy Additional FFU Files
Controls the `-CopyAdditionalFFUFiles` parameter. When checked, allows you to select existing FFU files from the FFU Capture Location to copy to the USB drive alongside the newly built FFU. The default is **unchecked**.
This option is only available when **Build USB Drive** is checked.
#### How It Works
When enabled, an **Additional FFU Files** panel appears below the checkbox with the following controls:
| Control | Description |
| ----------------------- | ----------------------------------------------------------------------------------------------- |
| **Refresh** | Scans the FFU Capture Location folder for existing `.ffu` files and populates the list |
| **FFU Name** | The filename of the FFU file |
| **Last Modified** | The date and time the FFU file was last modified, useful for identifying the most recent builds |
The list displays all `.ffu` files found in the FFU Capture Location (default `.\FFUDevelopment\FFU`). Click on individual rows to select which FFU files you want to include on the USB drive. Selected files are highlighted in the list.
#### Use Cases
- **Multiple device configurations**: Copy different FFU files for different windows/application configurations (e.g., different versions of windows, different application stacks) to a single USB drive, allowing technicians to choose during deployment.
- **Previous builds**: Include a known-good FFU from a previous build alongside the new build as a fallback option.
- **Multi-architecture imaging**: Include both x64 and arm64 FFU files on the same USB drive for mixed-architecture environments.
#### Command Line Usage
When running `BuildFFUVM.ps1` from the command line with `-CopyAdditionalFFUFiles $true` and no `-AdditionalFFUFiles` parameter specified, the script displays an interactive prompt listing all available FFU files in the capture folder. You can:
- Enter numbers separated by commas (e.g., `1,3,5`) to select specific files
- Enter `A` to select all available files
- Press **Enter** to skip and not include any additional files
Example command line usage with pre-selected files:
```powershell
.\BuildFFUVM.ps1 -configFile .\config\FFUConfig.json -CopyAdditionalFFUFiles $true -AdditionalFFUFiles @("C:\FFUDevelopment\FFU\Win11_24h2_Pro_Nov2025.ffu", "C:\FFUDevelopment\FFU\Win11_24h2_Enterprise_Nov2025.ffu")
```
{: .note-title}
> Note
>
> The newly captured FFU from the current build is always copied to the USB drive. Additional FFU files selected here are copied in addition to the new FFU.
### Max USB Drives
Controls the `-MaxUSBDrives` parameter, which sets the maximum number of USB drives to build in parallel. The default value is **5**.
This option is only available when **Build USB Drive** is checked.
When building USB drives, the script processes multiple drives concurrently to speed up imaging. This setting controls how many drives are formatted and copied to simultaneously.
## Compact OS
Controls the `-CompactOS` parameter. When checked, the Windows image is applied using compressed files. The default is **checked**.
### How It Works
When enabled, the build script uses the `-Compact` switch with `Expand-WindowsImage` when applying the Windows image to the OS partition. This compresses Windows system files using Compact OS compression, which reduces the disk footprint of the operating system. On an x64 image, space savings is ~3.5-4GB.
### Benefits
| Benefit | Description |
| ------------------------------- | ---------------------------------------------------------------------------------------------------- |
| **Reduced Disk Space** | Windows files are stored in a compressed state, saving several gigabytes of storage |
| **Smaller FFU Size** | The captured FFU file is smaller because the OS partition contains compressed files |
| **Faster Deployment** | Smaller FFU files transfer more quickly to USB drives and deploy faster to target devices |
| **No Performance Impact** | Modern CPUs decompress files faster than they can be read from storage, so performance is maintained |
### When to Disable
You may want to disable Compact OS in the following scenarios:
- **Windows Server builds**: The script automatically disables Compact OS for Windows Server operating systems because the Windows Overlay Filter (wof.sys) is not included in Server SKUs
- **Troubleshooting**: If you experience issues with specific applications that are incompatible with compressed files
- **Maximum performance requirements**: In rare cases where every CPU cycle matters
{: .note-title}
> Note
>
> Compact OS is automatically disabled when building Windows Server images, regardless of this setting. The script detects Server operating systems and applies the Windows image without compression.
## Update ADK
Controls the `-UpdateADK` parameter. When checked, the script checks for and installs or updates to the latest Windows ADK and WinPE add-on before starting the build. The default is **checked**.
### How It Works
When enabled, the build process performs the following checks before starting:
1. **Version Check**: Queries the [Microsoft ADK installation page](https://learn.microsoft.com/en-us/windows-hardware/get-started/adk-install) to determine the latest available ADK version
2. **Compare Versions**: Compares the installed ADK and WinPE add-on versions (if present) against the latest available version
3. **Update if Needed**: If an older version is detected:
- Uninstalls the existing Windows ADK
- Uninstalls the existing WinPE add-on
- Downloads and installs the latest Windows ADK with Deployment Tools feature
- Downloads and installs the latest WinPE add-on
### Features Installed
When installing or updating the ADK, the following features are included:
| Component | Feature ID | Description |
| ---------------------------------- | ---------------------------------------------- | --------------------------------------------------------------- |
| **Windows Deployment Tools** | `OptionId.DeploymentTools` | Includes DISM, Oscdimg, and other deployment-related tools |
| **WinPE Environment** | `OptionId.WindowsPreinstallationEnvironment` | Windows Preinstallation Environment used for capture and deploy |
### Installation Location
The ADK is installed to the default location: `C:\Program Files (x86)\Windows Kits\10`
### When to Disable
You may want to disable Update ADK in the following scenarios:
- **Offline or air-gapped environments**: When internet access is not available to download the latest ADK
- **Controlled ADK versions**: When you need to maintain a specific ADK version for compatibility or compliance reasons
- **Faster builds**: When you have already verified you are running the latest ADK version and want to skip the version check
{: .warning-title}
> Warning
>
> If Update ADK is disabled and the Windows ADK or WinPE add-on is not installed, the build will fail. Ensure you have manually installed the required components before disabling this option.
### Manual ADK Installation
If you prefer to manually install the ADK, visit:
[Download and install the Windows ADK](https://learn.microsoft.com/en-us/windows-hardware/get-started/adk-install)
You must install both:
- Windows Assessment and Deployment Kit (with Deployment Tools feature)
- Windows PE add-on for the Windows ADK
## Optimize
Controls the `-Optimize` parameter. When enabled, FFU Builder runs the Windows ADK version of DISM to optimize the captured `.ffu` file:
- `DISM /Optimize-FFU /ImageFile:<path-to-ffu>`
This post-processing step typically takes a few minutes and is intended to make FFU images faster to deploy and easier to deploy to differently-sized disks (by allowing the Windows partition to expand or shrink during apply).
**Default:** Enabled (`-Optimize $true`)
### When to Disable
You may want to disable Optimize (`-Optimize $false`) if you are troubleshooting, or if you want to skip the extra post-processing time.
{: .warning-title}
> Warning
>
> If you plan to deploy the same FFU to devices with different storage sizes (especially smaller disks), keep `-Optimize` enabled. Non-optimized FFUs are more likely to require additional partition management during deployment.
{: .note-title}
> Note
>
> FFU Builder also performs a separate “optimize VHDX before capture” step. That VHDX optimization is independent of `-Optimize`, so you may still see “Optimizing VHDX before capture…” even when `-Optimize` is disabled.
## Allow VHDX Caching
Controls the `-AllowVHDXCaching` parameter. When enabled, FFU Builder caches the base VHDX it creates in `$FFUDevelopmentPath\VHDXCache` and writes a matching `*_config.json` file alongside it. On later builds, if a cached VHDX exists that matches your selected Windows settings and update set, the script reuses it to avoid re-applying the base image and integrating updates again.
**Default:** Disabled (`-AllowVHDXCaching $false`)
### Cache Matching
A cached VHDX is reused only when the cache metadata matches your current build inputs, including:
- Windows release, version, and SKU
- Logical sector size (512 vs 4096)
- Optional features selection
- The exact set of update payload file names downloaded for that run (SSU/CU/.NET/etc.)
### Disk Usage and Cleanup
VHDX caching trades disk space for speed. The `VHDXCache` folder can grow over time as you build different combinations. Periodically check the folder and remove old cached vhdx and config json files as necessary.
{: .note-title}
> Note
>
> To force a full rebuild, delete the contents of `$FFUDevelopmentPath\VHDXCache` (or disable **Allow VHDX Caching**) and run the build again.
## Create Capture Media
Controls the `-CreateCaptureMedia` parameter.
When enabled, FFU Builder creates WinPE capture media that is used during VM-based builds (when apps are installed in the VM). FFU Builder attaches this media to the VM and adjusts boot order so the VM can reboot into WinPE and automatically capture the FFU to your **FFU Capture Location**.
The capture media uses the parameter values from `VMHostIPAddress`, `ShareName`, `UserName`, and `CustomFFUNameTemplate` and inserts them into `CaptureFFU.ps1` which is what is responsible for capturing the FFU from the guest VM to the Host.
**Default:** Enabled (`-CreateCaptureMedia $true`)
{: .note-title}
> Note
>
> This option is only relevant when **Install Apps** is enabled. If **Install Apps** is enabled, the build forces `-CreateCaptureMedia` to `$true` because capture media is required to capture an FFU from the VM.
{: .tip-title}
> Tip
>
> If you just need to re-create media, you can use the `Create-PEMedia.ps1` script to regenerate the capture or deploy ISO using `Create-PEMedia.ps1 -Capture $true` or `CreatePEMedia.ps1 -Deploy $true`.
## Create Deployment Media
Controls the `-CreateDeploymentMedia` parameter.
When enabled, FFU Builder creates WinPE deployment media that is used to deploy an FFU image to a physical device. This media contains the WinPE environment and deployment scripts needed to boot a target machine and apply the FFU image.
The deployment media is saved as an ISO file at `$FFUDevelopmentPath\WinPE_FFU_Deploy_x64.iso` (or `WinPE_FFU_Deploy_arm64.iso` for ARM64 builds). This ISO can then be used with the **Build USB Drive** option to create bootable USB media for physical deployments.
**Default:** Enabled (`-CreateDeploymentMedia $true`)
{: .note-title}
> Note
>
> If you only need to capture FFUs from VMs and do not plan to deploy to physical devices, you can disable this option to save time during the build process. However, most scenarios require deployment media for the final step of applying the FFU to target hardware.
{: .tip-title}
> Tip
>
> If you just need to re-create media, you can use the `Create-PEMedia.ps1` script to regenerate the capture or deploy ISO using `Create-PEMedia.ps1 -Capture $true` or `CreatePEMedia.ps1 -Deploy $true`.
## Inject Unattend.xml
Controls the `-InjectUnattend` parameter. When checked, copies the architecture-specific unattend XML file from `.\FFUDevelopment\unattend` into the Apps ISO so it's baked into the FFU during the VM build process. The default is **unchecked**.
This option is only available when **Install Apps** is checked.
### How It Works
When enabled, the build process:
1. Determines the correct unattend file based on the target architecture:
* **unattend_x64.xml** for x64 builds
* **unattend_arm64.xml** for arm64 builds
2. Creates an `Unattend` folder inside `.\FFUDevelopment\Apps` if it doesn't exist
3. Copies the architecture-specific unattend file to `.\FFUDevelopment\Apps\Unattend\Unattend.xml`
4. Includes the unattend file in the Apps ISO, making it available to sysprep during the VM build
The unattend file is then used by sysprep during the specialize phase and/or other OOBE phases when the FFU is deployed.
### Creating Your Unattend Files
Modify the architecture-specific unattend file in the `.\FFUDevelopment\unattend` folder:
| File | Description |
| ---------------------------- | ----------------------------------- |
| **unattend_x64.xml** | Unattend file used for x64 builds |
| **unattend_arm64.xml** | Unattend file used for arm64 builds |
{: .warning-title}
> Important
>
> Keep the file names with the architecture suffix (e.g., unattend_x64.xml). The script handles renaming the file to `Unattend.xml` when copying it to the Apps folder.
### When to Use This Option
This option is primarily intended for scenarios where:
* You are **not using the USB drive** to deploy the FFU and use other deployment methods (e.g., network deployment, disk cloning, etc)
* You want the unattend configuration **baked directly into the FFU** rather than applied at deployment time
### Limitations
| Limitation | Description |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **No prefixes.txt support** | Unlike the**Copy Unattend** option for USB drives, this method does not support `prefixes.txt` for dynamic device naming based on serial numbers |
| **Fixed configuration** | The unattend settings are baked into the FFU at build time and cannot be changed at deployment time |
| **Requires VM to be built** | This option only works when**Install Apps** is `$true` because the unattend file is included in the Apps ISO |
{: .note-title}
> Note
>
> Most users should continue using the **Copy Unattend** option via the USB drive, which provides more flexibility including support for `prefixes.txt` device naming. Use **Inject Unattend.xml** only when you won't be using the USB drive for deployment.
{: .tip-title}
> Tip
>
> If you're using this option, you can disable **Build Deploy ISO** to save time during the build process since the deployment ISO is not needed when you're not using the USB drive method.
## Verbose
Controls the `-Verbose` common parameter. When checked, enables detailed verbose output during the build process. The default is **unchecked**.
In prior builds it was necessary to enable `-verbose` output to track in real-time the build process if you didn't have `cmtrace.exe` or some other log-monitoring tool. With the UI, you can now watch the build in real-time using the monitor tab. Enabling verbose shouldn't be necessary but is available for those who wish to use it.
# Post-Build Cleanup
## Cleanup Apps ISO
Controls the `-CleanupAppsISO` parameter. When checked, the Apps ISO file is automatically deleted after the FFU has been successfully captured. The default is **checked**.
During the build process, when apps are being installed, the script creates an `Apps.iso` file in the FFU Development Path (e.g., `.\FFUDevelopment\Apps.iso`). This ISO contains the contents of the `.\FFUDevelopment\Apps` folder—including application installers, Office deployment files, and orchestration scripts—and is mounted to the VM during the build to install applications.
### When to Disable
You may want to disable Cleanup Apps ISO in the following scenarios:
* **Debugging app installations**: When troubleshooting application installation issues and you want to manually inspect the ISO contents
* **Multiple builds with same apps**: If you're running consecutive builds with identical app configurations and want to reuse the existing ISO to save time (the script will recreate it if missing)
* **Archival purposes**: When you need to retain a copy of the exact Apps ISO used for a specific FFU build
{: .note-title}
> Note
>
> The Apps ISO is only created when applications are configured for installation. If no apps are being installed in the FFU, this option has no effect. Keeping this option enabled helps conserve disk space by removing temporary build artifacts.
## Cleanup Capture ISO
Controls the `-CleanupCaptureISO` parameter. When checked, the WinPE capture ISO file is automatically deleted after the FFU has been successfully captured. The default is **checked**.
It's recommended to keep this checked as each new build re-creates the local username account (e.g. `ffu_user`) and its password. If you were to retain the capture ISO from a previous build, it'd be using an old password and the capture would fail.
## Cleanup Deploy ISO
Controls the `-CleanupDeployISO` parameter. When checked, the WinPE deployment ISO file is automatically deleted after the FFU has been successfully captured. The default is **checked**.
During the build process, when **Build Deploy ISO** is enabled, the script creates a `WinPE_FFU_Deploy.iso` file (e.g., `.\FFUDevelopment\WinPE_FFU_Deploy.iso`). This ISO contains a customized Windows PE environment used to deploy captured FFU images to target devices. The deployment ISO is typically copied to a bootable USB drive along with the FFU files for field deployment.
### When to Disable
You may want to disable Cleanup Deploy ISO in the following scenarios:
* **Creating deployment media separately**: When you want to create USB deployment drives at a later time (e.g. using `.\FFUDevelopment\USBImagingToolCreator.ps1`)
* **Testing in Hyper-V**: When deploying FFU images to Hyper-V VMs for testing, you can attach the deploy ISO directly to a VM as a DVD drive
## Cleanup Drivers
Controls the `-CleanupDrivers` parameter. When checked, the contents of the Drivers folder are automatically deleted after the FFU has been successfully captured. The default is **unchecked**.
During the build process, when drivers are configured for installation, the script downloads and extracts driver packages into manufacturer-specific subfolders within the Drivers folder (e.g., `.\FFUDevelopment\Drivers\HP`, `.\FFUDevelopment\Drivers\Dell`). These drivers are then injected into the FFU during the build.
### When to Enable
You may want to enable Cleanup Drivers in the following scenarios:
* **Conserving disk space**: Driver packages can be large (several gigabytes per manufacturer), and removing them after a successful build frees up storage
* **Ensuring fresh drivers**: When you want each build to download the latest available drivers rather than reusing previously downloaded versions
* **Single-use builds**: When building an FFU for a one-time deployment and you don't need to retain the driver files
### When to Disable
You may want to keep Cleanup Drivers disabled in the following scenarios:
* **Multiple builds with same drivers**: If you're running consecutive builds targeting the same hardware models, keeping drivers avoids re-downloading them each time
* **Debugging driver issues**: When troubleshooting driver injection problems and you want to manually inspect the downloaded driver contents
* **Offline builds**: When building in an environment with limited or no internet access, retaining drivers allows reuse across builds
* **Bring Your Own Drivers:** When you download and bring your own set of drivers from another source and don't want FFU Builder to remove them
{: .note-title}
> Note
>
> Only the contents within the Drivers folder are removed—the folder itself is preserved. If no drivers were downloaded during the build, this option has no effect.
## Remove FFU
Controls the `-RemoveFFU` parameter. When checked, all FFU files in the FFU Capture Location are automatically deleted after the build completes successfully. The default is **unchecked**.
During the build process, the captured FFU image is written to the FFU Capture Location (e.g., `.\FFUDevelopment\FFU`). This option removes all `.ffu` files from that folder after the build finishes, including any previously captured FFU files that may exist in the folder.
### When to Enable
You may want to enable Remove FFU in the following scenarios:
* **USB-only workflow**: When you're using **Build USB Drive** to copy the FFU directly to a USB drive and don't need to retain the FFU file on the host machine
* **Conserving disk space**: FFU files can be very large depending on what you're installing, and removing them after copying to USB frees up storage
* **Automated build pipelines**: When running automated builds where the FFU is immediately transferred to another location (such as a network share or deployment server) and no longer needed locally
### When to Disable
You may want to keep Remove FFU disabled in the following scenarios:
* **Archival purposes**: When you want to retain captured FFU images for future deployments or as a backup
* **Multiple USB drives**: When you need to create additional USB deployment drives at a later time
* **Testing and validation**: When you want to test the FFU in Hyper-V or other environments before deploying to physical hardware
{: .warning-title}
> Warning
>
> This option removes **all** FFU files in the FFU Capture Location folder, not just the FFU from the current build. If you have previously captured FFU files stored in this folder that you want to keep, do not enable this option or move those files to a different location before building.
## Remove Apps Folder Content
Controls the `-RemoveApps` parameter. When checked, application content in the Apps folder is automatically deleted after the FFU has been successfully captured. The default is **un****checked**.
During the build process, application content accumulates in several subfolders within the Apps folder (e.g., `.\FFUDevelopment\Apps`):
| Folder | Contents |
| ----------- | ------------------------------------------------------------------------------------------------------------------------ |
| `Win32` | Winget source applications and Bring Your Own Apps content copied using the**Copy Apps** button or manually copied |
| `MSStore` | Microsoft Store applications downloaded via Winget |
| `Office` | Microsoft 365 Apps installer files downloaded by the Office Deployment Tool |
Additionally, the `WinGetWin32Apps.json` orchestration file in `.\FFUDevelopment\Apps\Orchestration` is removed. This file is automatically regenerated at build time based on downloaded applications.
When this option is enabled, the cleanup process removes:
* The entire `Win32` folder and its contents
* The entire `MSStore` folder and its contents
* The Office download subfolder (`Office\Office`) and the `setup.exe` file within the `Office` folder
### When to Enable
You may want to keep Remove Apps Folder Content enabled in the following scenarios:
* **Conserving disk space**: Downloaded application installers can consume significant storage, and removing them after a successful build frees up space
* **Ensuring fresh downloads**: When you want each build to download the latest available application versions rather than reusing previously downloaded content
* **Single-use builds**: When building an FFU for a one-time deployment and you don't need to retain the application files
### When to Disable
You may want to disable Remove Apps Folder Content in the following scenarios:
* **Multiple builds with same apps**: If you're running consecutive builds with identical application configurations, keeping the downloaded content avoids re-downloading applications each time
* **Debugging app installations**: When troubleshooting application installation issues and you want to manually inspect the downloaded content
* **Offline builds**: When building in an environment with limited or no internet access, retaining downloaded applications allows reuse across builds
* **Preserving Bring Your Own Apps**: When you've manually copied application content into the `Win32` folder and don't want FFU Builder to remove it
{: .note-title}
> Note
>
> Only the application content subfolders are removed—the `Apps` folder itself and configuration files such as `AppList.json` and `UserAppList.json` are preserved. If no applications were configured for the build, this option has no effect.
## Remove Downloaded Update Files
Controls the `-RemoveUpdates` parameter. When checked, downloaded Windows updates and application update payloads are automatically deleted after the FFU has been successfully captured. The default is **unchecked**.
During the build process, update files are downloaded to specific locations within the `FFUDevelopment` folder:
| Folder | Contents |
| ----------------- | ---------------------------------------------------------- |
| `KB` | Windows Cumulative Updates (CU) and .NET Framework updates |
| `Apps\Defender` | Microsoft Defender definition updates |
| `Apps\Edge` | Microsoft Edge browser installer |
| `Apps\MSRT` | Malicious Software Removal Tool updates |
| `Apps\OneDrive` | Microsoft OneDrive installer |
When this option is enabled, the cleanup process removes the entire `KB` folder and the specific update subfolders within the `Apps` directory.
### When to Enable
You may want to keep Remove Downloaded Update Files enabled in the following scenarios:
* **Conserving disk space**: Windows Cumulative Updates can be several gigabytes in size, and removing them after a successful build frees up significant storage
* **Ensuring latest updates**: When you want each build to download the absolute latest available updates rather than potentially reusing older cached versions
### When to Disable
You may want to disable Remove Downloaded Update Files in the following scenarios:
* **Multiple builds**: If you're running consecutive builds, keeping the downloaded updates avoids re-downloading large Cumulative Update files each time
* **Offline builds**: When building in an environment with limited or no internet access, retaining downloaded updates allows reuse across builds
* **Testing and validation**: When you want to manually inspect the update files that were included in the build
{: .note-title}
> Note
>
> Only the update-specific subfolders are removed-the `Apps` folder itself and other application content (unless **Remove Apps Folder Content** is also selected) are preserved.
## Restore Defaults
Use this to restore FFU Builder to its default state. When clicked:
- A confirmation dialog lists what will be removed before anything is deleted.
- Generated JSON files are removed (`config\FFUConfig.json`, `Apps\AppList.json`, `Apps\UserAppList.json`, `Drivers\Drivers.json`).
- Capture, Deploy, and Apps ISO files are deleted.
- Downloaded artifacts are cleared: Apps payloads (Win32, MSStore, Office downloads), update folders under Apps (Defender, Edge, MSRT, OneDrive), driver downloads, and all `.ffu` files in the FFU capture folder.
- UI list views (drivers, apps, Winget search results, AppScript variables) are cleared and all controls are reset to their default values.
{: .note-title}
> Note
>
> VHDX cache and any custom config files in the `FFUDevelopment\config` folder, and `Drivers\DriverMapping.json` will remain. DriverMapping.json is retained because you may have made custom changes to it and we want to retain those.
>
> If you want to keep any content prior to restoring defaults, copy it out first.
## Save Config File
Saves all current UI selections to a JSON file so you can reload the same settings later or run `BuildFFUVM.ps1` from the command line with `-configFile` (e.g. `BuildFFUVM.ps1 -configFile C:\FFUDevelopment\config\FFUConfig.json`)
### How it works
- Collects the full UI state (paths, toggles, driver/app selections, build options) into a single JSON.
- Defaults the save location to `FFUDevelopmentPath\config` and suggests `FFUConfig.json` as the file name. You can browse and pick a different file name or folder.
- Creates the `config` folder if it does not exist and confirms the save when finished.
## Load Config File
Loads a previously saved configuration JSON and repopulates the UI.
### How it works
- Click **Load Config File** to browse for a JSON file (for example, `FFUDevelopment\config\FFUConfig.json`).
- The UI updates with everything from the file: paths, checkboxes, build options, driver/app selections, and USB settings.
- Supplemental files referenced in the config (Winget `AppList.json`, BYO `UserAppList.json`, `Drivers.json`) are also imported if they exist. Missing helper files are treated as optional and noted for you.
- If the file is empty, unreadable, or invalid JSON, the load is stopped and an error message is shown.
## Build FFU
Use **Build FFU** to run `BuildFFUVM.ps1` with the current UI selections.
### What happens when you click Build FFU
- The UI gathers all current settings and saves them to `FFUDevelopment\config\FFUConfig.json`, and launches `BuildFFUVM.ps1 -configFile` pointing to that file in a background job. `FFUConfig.json` persists between builds and is read on each opening of `BuildFFUVM_UI.ps1` so you can continue where you left off on each new run.
- The window switches to the **Monitor** tab so you can watch progress in real time.
- The progress bar shows overall completion
- When the job finishes, the button returns to **Build FFU** and the UI is ready for the next run.
### Cancelling a Build
The Build FFU button will change to Cancel while a build is running. Cancelling will do the following:
- The UI stops the background build job and kills any child processes so DISM, downloads, and other tools exit.
- The in-progress download is always removed to avoid partial or corrupt content.
- Youre prompted to decide whether to remove other items downloaded during this run. Selecting **Yes** removes only this runs downloads. Any previously downloaded content stays in place.
- When cleanup is finished, the Cancel button reverts to Build FFU and a new build can begin
{% include page_nav.html %}
+98
View File
@@ -0,0 +1,98 @@
---
title: Bring Your Own Applications
nav_order: 6
prev_url: /winget.html
prev_label: Install Winget Applications
next_url: /appsscriptvariables.html
next_label: Apps Script Variables
parent: Applications
grand_parent: UI Overview
---
# Bring Your Own Applications
![1760117497413](image/byoapps/1760117497413.png)
Bring Your Own Applications allows you to run any command line you want in the virtual machine to include in your FFU to install an application, run a script, etc. As the name implies, you'll provide the content, command line, arguments, and additional exit codes.
All applications are stored in the `$AppsPath` parent folder which defaults to `C:\FFUDevelopment\Apps`. Winget source applications and BYO Apps that you select Copy Apps are stored in `$AppsPath\Win32`. MSStore source apps from Winget are stored in `$AppsPath\MSStore`.
At build time, an `Apps.iso` file is created of the `$AppsPath` folder. This ISO gets mounted to the VM. It shows up in the VM as the `D:\` drive. When creating your command line or arguments, you must make sure to reference `D:\`.
## Name
The name of the application. The name is also used when selecting **Copy Apps** to copy apps from a source location to the `$AppsPath\Win32\<Name>` folder (e.g. `C:\FFUDevelopment\Apps\Win32\Google Chrome`)
## Command Line
This is the full path to the command line to install the application, script, or to run a command. If the content was included in the `$AppsPath` this should start with `D:\` (e.g. `D:\Win32\Mozilla Firefox\Mozilla Firefox_136.0.3_Machine_X64_exe_en-US.exe`)
For MSI applications, this should only include msiexec. The rest of the command line will be specified in arguments.
## Arguments
These are the command line arguments for the application. Using the Mozilla Firefox example above, the arguments would be `/S /PreventRebootRequired=true`.
For MSI applications, this will include `/i` and the full-path to the MSI file plus any additional command line parameters (e.g. `/i "D:\Win32\Google Chrome\Google Chrome_134.0.6998.178_Machine_X64_wix_en-US.msi" /quiet /norestart`)
## Source
This is an optional parameter. This is the local source to the content. It is used by the Copy Apps button to copy from the source location to the `$AppsPath\Win32\<Name>` folder. If you don't use the **Copy Apps** button, then you must put the conent in the `$AppsPath` folder manually.
## Additional Exit Codes
This is an optional parameter. Enter a comma-separated list of additional success exit codes if necessary.
## Ignore all non-zero exit codes
If checked, any non-zero exit code will be considered a success.
## Save UserAppList.json
When you're done adding your apps, you must save the `UserAppList.json` file to your `$AppsPath` folder. If you click **Copy Apps**, the `UserAppList.json` file is also saved. The `UserAppList.json` is used by the FFU Builder Orchestrator in the VM to know what to install and when based on the priority of the application.
Below is the `UserAppList.json` of Chrome and Firefox using the example above.
```json
[
{
"Priority": 1,
"Name": "Google Chrome",
"CommandLine": "msiexec",
"Arguments": "/i \"D:\\Win32\\Google Chrome\\Google Chrome_134.0.6998.178_Machine_X64_wix_en-US.msi\" /quiet /norestart",
"Source": "C:\\temp\\source\\Google Chrome",
"AdditionalExitCodes": "",
"IgnoreNonZeroExitCodes": false
},
{
"Priority": 2,
"Name": "Mozilla Firefox",
"CommandLine": "D:\\Win32\\Mozilla Firefox\\Mozilla Firefox_136.0.3_Machine_X64_exe_en-US.exe",
"Arguments": "/S /PreventRebootRequired=true",
"Source": "C:\\temp\\source\\Mozilla Firefox",
"AdditionalExitCodes": "",
"IgnoreNonZeroExitCodes": false
}
]
```
## Import UserAppList.json
You can import a saved `UserAppList.json`
## Edit Application
When you select a single application you can select the **Edit Application** button. This allows you to edit the application information and update the application.
## Copy Apps
If the application source is provided, click **Copy Apps** to copy the application content to the `$AppsPath\Win32` folder (e.g. `C:\FFUDevelopment\Apps\Win32\<Name>`). Network shares are supported. When clicking **Copy Apps** the `UserAppList.json` file is automatically created.
## Remove Selected
Removes the selected applications from the list view. Click **Save UserAppList.json** to save the application list.
## Clear List
The **Clear List** button will clear the list view of whats currently in it. It will not clear the `UserAppList.json` file if it exists.
{% include page_nav.html %}
+410
View File
@@ -0,0 +1,410 @@
---
title: Drivers
nav_order: 9
prev_url: /M365appsoffice.html
prev_label: M365 Apps Office
next_url: /build.html
next_label: Build
parent: UI Overview
---
# Drivers
![1760488183854](image/drivers/1760488183854.png)
FFU Builder supports adding drivers directly to the FFU file at build time, or adding them as folders on your USB drive which can be serviced offline after the FFU has been applied to your device.
The UI allows you to download the drivers prior to build and/or create a `Drivers.json` file which can be used to automatically download the drivers at build time. This allows for flexibility in downloading drivers whenever you need them. It supports downloading multiple driver models at once in parallel.
## Drivers Folder
This is the location where drivers are downloaded to, or where you'll manually copy drivers to. The default is `.\FFUDevelopment\Drivers`
## PE Drivers Folder
Path to the folder containing drivers to be injected into the WinPE deployment media. Default is `.\FFUDevelopment\PEDrivers`.
## Drivers.json Path
Path to a JSON file that specifies which drivers to download. Default is `.\FFUDevelopment\Drivers\Drivers.json`
## Download Drivers
FFU Builder can download drivers from the following OEMs:
* Dell
* HP
* Lenovo
* Microsoft
Clicking the **Download Drivers** exposes a **Make:** drop down which lists the above four OEMs and a **Get Models** button
Clicking **Get Models** downloads the list of models from the selected OEM.
The **Model Filter** box allows you to type in a string to filter on the model. The filter should match on any portion of text in the model name.
The model column lists the model name and the System ID (for Dell and HP) or the Machine Type (for Lenovo) in parenthesis. The SystemID/Machine Type values are required to know exactly which set of drivers to download for your model. There typically is a lot of overlap, and sometimes the drivers for the various SystemID/MachineTypes for the same model might be exactly the same, it's still best to grab the SystemID/MachineType before downloading drivers.
To get the System ID:
**HP**
* BIOS/UEFI: Either under Main or System Information (it's going to be different depending on the model) you're looking for the **System Board ID** and it should be a four-character code.
* PowerShell:`(Get-CimInstance -Namespace 'root\WMI' -Class MS_SystemInformation).BaseboardProduct`
**Dell**
* BIOS/UEFI: I'm not sure if it's possible to get the System ID from the BIOS/UEFI. I seem to recall in some BIOS screenshots that System SKU is listed in some BIOS/UEFI implementations, but it may not be consistent.
* PowerShell: `(Get-CIMInstance -ClassName "MS_SystemInformation" -NameSpace "root\WMI").SystemSku`
or
```
[string]$OEMString = Get-WmiObject -Class "Win32_ComputerSystem" | Select-Object -ExpandProperty OEMStringArray
$ComputerDetails.FallbackSKU = [regex]::Matches($OEMString, '\[\S*]')[0].Value.TrimStart("[").TrimEnd("]")
```
**Lenovo**
To find the Machine Type for Lenovo devices, check the bottom/back of the device for the MTM field and capture the first four characters.
* BIOS/UEFI: Look for MTM and grab the first four characters
* PowerShell: `(Get-CIMInstance -ClassName "MS_SystemInformation" -NameSpace "root\WMI").SystemProductName`
You can multi-select different models within the same make, or mix and match different makes. The screenshot below shows different Dell, HP, Lenovo, and Microsoft models selected
![FFU Builder UI showing multiple driver models selected for Dell, HP, Microsoft, and Lenovo](image/drivers/1763794307504.png "FFU Builder UI showing multiple driver models selected for Dell, HP, Microsoft, and Lenovo")
## Save Drivers.json
After selecting the drivers you want to download, clicking **Save Drivers.json** will prompt you for a location to save the `Drivers.json` file to. The `Drivers.json` file is responsible for telling `BuildFFUVM.ps1` what drivers to download during the build process.
Below is an example of `Drivers.json`:
```
{
"HP": {
"Models": [
{
"Name": "HP EliteBook 865 16 inch G11 Notebook PC",
"SystemId": "8d03"
}
]
},
"Dell": {
"Models": [
{
"Name": "Dell Pro Max Desktops Dell Pro Max Micro FCM2250,Dell Pro Max Micro XE FCM2250",
"CabUrl": "https://downloads.dell.com/FOLDER13898125M/1/Dell_Pro_Max_Desktops_0D14.cab",
"SystemId": "0D14"
}
]
},
"Lenovo": {
"Models": [
{
"Name": "Lenovo 300w Yoga Gen 4",
"MachineType": "82VN"
}
]
},
"Microsoft": {
"Models": [
{
"Name": "Surface Pro for Business (11th Edition)",
"Link": "https://www.microsoft.com/download/details.aspx?id=108013"
}
]
}
}
```
## Import Drivers.json
Import Drivers.json allows you to import a previously saved Drivers.json file. The models in the Drivers.json file will show up in the list view pre-selected. This will allow you to select additional models and save an updated version of Drivers.json, or to download the selected models by clicking Download Selected.
## Download Selected
Download Selected will download the selected models to the Drivers Folder path (default .\FFUDevelopment\Drivers). Drivers will download the the .\FFUDevelopment\Drivers\Make\Model folder. Download select also interacts with the Compress Driver Model Folder to WIM checkbox which will download and compress the drivers to WIM.
If you've previously downloaded a driver model and want to compress it to a WIM, you can check he Compress Driver Model Folder to WIM checkbox and click Download Selected again. This will skip the download and compress the driver folder to a WIM file.
Download Selected leverages BITS and the BITS Priority can be controlled by the BITS Priority drop down on the Build tab. If driver downloads via the UI feel slow, change BITS Priority to Foreground to speed them up.
## Clear List
Clears the list view of the previous model list
## Install Drivers to FFU
Install Drivers to FFU will recursively add the drivers in the FFUDevelopment\Drivers folder to the FFU file.
It's recommended to only include a single model's drivers in the FFU. This is because dism will add the drivers to the drivers store in the FFU and any additional models that aren't necessary will bloat the drivers store, using up disk space.
If you're dealing with multiple models, it's recommended to select Copy Drivers to USB drive instead.
## Copy Drivers to USB drive
Copy Drivers to USB drive will copy the drivers to the .\Drivers folder on the deploy partition of the USB drive (e.g. D:\Drivers\Make\Model)
If you're manually copying drivers to the .\FFUDevelopment\Drivers folder, you must copy them to the FFUDevelopment\Drivers\Make\Model folder (e.g. FFUDevelopment\Drivers\Lenovo\Lenovo 300w). Prior releases referenced using just .\FFUDevelopment\Drivers\Model, however for better organization and consistency, the code has been updated to require the make folder.
## Compress Driver Model Folder to WIM
Enabling this checkbox compresses the driver model folder to a WIM file after each model finishes downloading (or when an existing model is detected). Every `Drivers\<Make>\<Model>` directory is captured into a single `<Drivers folder>\<Make>\<Model>.wim` using DISM with `Compress:Max`, which dramatically reduces the space required on your USB drive.
1. Select the models you need, check **Compress Driver Model Folder to WIM**, then click **Download Selected**. Fresh downloads are extracted as usual and immediately compressed into their companion `.wim`.
2. If the model already exists, the download phase is skipped and only the compression runs, so you can rebuild the `.wim` whenever you refresh the folder contents.
By default the extracted folder is deleted after a successful capture so that the `.wim` becomes the canonical artifact. When **Use Drivers Folder as PE Drivers Source** is also checked, the UI keeps the folder in place, writes a `__PreservedForPEDrivers.txt` marker, and lets WinPE driver harvesting reuse the loose INF set.
Additional guidance:
- `DriverMapping.json` is updated to reference the `.wim`, so `Copy Drivers to USB drive`, `BuildFFUVM.ps1 -CopyDrivers`, and the WinPE `ApplyFFU.ps1` flow mount the compressed archive automatically.
- Watch the Drivers tab status column or `FFUDevelopment_UI.log` for DISM progress and troubleshooting details per model.
- Ensure the volume hosting `FFUDevelopment\Drivers` has enough free space for both the source folder and the resulting `.wim`.
- Only applies to drivers from Dell, HP, Lenovo, or Microsoft that are specified in the Drivers.json file. It will not compress models you manually copy to the Drivers folder.
## Copy PE Drivers
When **Copy PE Drivers** is enabled, drivers will be injected into the WinPE deployment media. This ensures that WinPE has the necessary drivers to recognize hardware components like storage controllers, network adapters, and input devices during FFU deployment.
By default, drivers are sourced from the **PE Drivers Folder** (default `.\FFUDevelopment\PEDrivers`). You can manually place drivers in this folder, and they will be injected into the WinPE media during the build process.
### Use Drivers Folder as PE Drivers Source
When **Copy PE Drivers** is checked, an additional sub-option becomes visible: **Use Drivers Folder as PE Drivers Source** .
When this option is enabled, the script bypasses the PE Drivers Folder and instead dynamically builds the WinPE driver set from the main **Drivers Folder**. The script scans all available drivers in the Drivers folder, parses their INF files, and copies only the essential driver types needed for WinPE, including:
* System devices
* SCSI, RAID, and NVMe controllers
* Keyboards
* Mice and other pointing devices
* Human Interface Devices (HID) for touch support
This eliminates the need to maintain a separate, manually curated `PEDrivers` folder and ensures that WinPE has the necessary drivers based on what you've already downloaded for your target devices.
{: .note-title}
> Note
>
> If the PE Drivers folder already contains content when using this option, it will be cleared before the new driver set is copied in.
>
> Some drivers may fail to be added during injection, which is expected behavior and can be safely ignored.
>
> Network adapters are not included when using the drivers folder as PE drivers source, so if you're using WDS or another network-based solution to copy your FFU and you've modified ApplyFFU.ps1, it's best to not use the **Use Drivers Folder as PE Driver Source** option and just copy in your required PE Drivers to the PE Drivers folder.
## DriverMapping.json
`DriverMapping.json` is an automatically generated file that maps hardware identifiers (like System IDs or Machine Types) to specific driver packages. This file enables the WinPE deployment script (`ApplyFFU.ps1`) to automatically detect your device hardware and apply the correct drivers without manual intervention.
### How it gets created
`DriverMapping.json` is created and updated automatically when you download drivers using the **Download Selected** button on the Drivers tab of the UI, or when drivers are downloaded during the FFU build. Each time you successfully download drivers for a model, the file is updated with the mapping information for that model.
### Automatic Driver Selection During Deployment
When you deploy an FFU using the WinPE media, `ApplyFFU.ps1` looks for `DriverMapping.json` on the USB drive at `D:\Drivers\DriverMapping.json` (where D: is your USB deploy partition). If found, the script:
1. Detects the hardware identifiers of the current device (System ID, Machine Type, etc.)
2. Searches `DriverMapping.json` for a matching entry
3. Automatically selects and applies the correct driver package
4. Falls back to manual driver selection if no match is found
### Required Fields by Manufacturer
Each entry in `DriverMapping.json` contains different required fields depending on the manufacturer:
**All Manufacturers:**
* **Manufacturer** The OEM name (e.g., "Dell", "HP", "Lenovo", "Microsoft")
* **Model** The full model name as it appears in the driver download catalog
* **DriverPath** The relative path to the driver folder or WIM file on the USB drive under the Drivers folder (e.g., "Dell\\\Dell Latitude 7490" or "HP\\\HP EliteBook 865 16 inch G11 Notebook PC.wim").
Relative paths are used since we don't know the drive letter of the USB drive when the `DriverMapping.json` file is created. And since this uses json, the double backslash is intentional since the first slash is an escape character.
**Dell:**
* **SystemId** The System SKU identifier (e.g., "0819", "0D14"). This is the primary matching field used during deployment. To find your Dell System SKU via PowerShell, run: `(Get-CIMInstance -ClassName "MS_SystemInformation" -NameSpace "root\WMI").SystemSku`
**HP:**
* **SystemId** The System Board ID, a four-character code (e.g., "8d03", "83D2"). This is the primary matching field used during deployment. To find your HP System Board ID via PowerShell, run: `(Get-CimInstance -Namespace 'root\WMI' -Class MS_SystemInformation).BaseboardProduct`
**Lenovo:**
* **MachineType** The first four characters of the MTM (Machine Type Model) field (e.g., "82VN", "21JD"). This is the primary matching field used during deployment. To find your Lenovo Machine Type via PowerShell, run: `(Get-CIMInstance -ClassName "MS_SystemInformation" -NameSpace "root\WMI").SystemProductName`
**Microsoft:**
* No additional fields required beyond Manufacturer, Model, and DriverPath. Matching is performed based on the normalized model name.
### Example DriverMapping.json
Below is an example of `DriverMapping.json` with entries for multiple manufacturers:
```
[
{
"Manufacturer": "Dell",
"Model": "Dell Latitude 7490",
"DriverPath": "Dell\\Dell Latitude 7490",
"SystemId": "0819"
},
{
"Manufacturer": "Dell",
"Model": "Dell Pro Max Desktops Dell Pro Max Micro FCM2250,Dell Pro Max Micro XE FCM2250",
"DriverPath": "Dell\\Dell Pro Max Desktops Dell Pro Max Micro FCM2250,Dell Pro Max Micro XE FCM2250.wim",
"SystemId": "0D14"
},
{
"Manufacturer": "HP",
"Model": "HP EliteBook 865 16 inch G11 Notebook PC",
"DriverPath": "HP\\HP EliteBook 865 16 inch G11 Notebook PC.wim",
"SystemId": "8D03"
},
{
"Manufacturer": "Lenovo",
"Model": "Lenovo 300w Yoga Gen 4",
"DriverPath": "Lenovo\\Lenovo 300w Yoga Gen 4",
"MachineType": "82VN"
},
{
"Manufacturer": "Microsoft",
"Model": "Surface Pro for Business (11th Edition)",
"DriverPath": "Microsoft\\Surface Pro for Business (11th Edition)"
}
]
```
## Bring Your Own Drivers
If you manage models that aren't from Dell, HP, Lenovo or Microsoft, or you want to use different drivers from what FFU Builder downloads, you can copy your own drivers to the `.\FFUDevelopment\Drivers` folder using the `.\FFUDevelopment\Drivers\Make\Model` format, or simply change the Drivers Folder path to the location of your drivers content.
You can also manually create your own DriverMapping.json file for the following makes/manufacturers
| Manufacturer | Match Field | WMI Class | Property |
| ------------------------------- | ----------- | ------------------------------------------- | ----------------------------------------------- |
| **Dell** | SystemId | `MS_SystemInformation` (via `root\WMI`) | `SystemSku` |
| **Dell** (fallback) | SystemId | `Win32_ComputerSystem` | `OEMStringArray` (parsed for bracketed value) |
| **HP** | SystemId | `MS_SystemInformation` (via `root\WMI`) | `BaseBoardProduct` |
| **Lenovo** | MachineType | `Win32_ComputerSystem` | `Model` |
| **Microsoft** | Model | `Win32_ComputerSystem` | `Model` |
| **Panasonic Corporation** | SystemId | `MS_SystemInformation` (via `root\WMI`) | `BaseBoardProduct` |
| **Viglen** | SystemId | `Win32_BaseBoard` | `SKU` |
| **AZW** | SystemId | `MS_SystemInformation` (via `root\WMI`) | `BaseBoardProduct` |
| **Fujitsu** | SystemId | `Win32_BaseBoard` | `SKU` |
| **Getac** | SystemId | `MS_SystemInformation` (via `root\WMI`) | `BaseBoardProduct` |
| **Intel** | Model | `Win32_ComputerSystem` | `Model` |
| **ByteSpeed** | Model | `Win32_ComputerSystem` | `Model` |
| **Other** (default) | Model | `Win32_ComputerSystem` | `Model` |
**Notes:**
* Match Field is the name of the field in the `DriverMapping.json` file (e.g. SystemID, MachineType, Model)
* SystemId is a catch-all term for a unique identifier, however each manufacturer calls this something different and stores them in different places within WMI
* The Dell (fallback) is used for models where the systemSKU isn't available and the OEMStringArray is parsed via Win32_ComputerSystem
* The `MS_SystemInformation` class is queried from the `root\WMI` namespace
* Unless noted, the other WMI classes use the `root\cimv2` namespace
* All identifiers are normalized to uppercase for matching
* ByteSpeed systems with "NUC" in the model name are re-mapped to Intel and use `BaseBoardProduct` instead
* For manufacturers that aren't listed, the default behavior is to use the `Win32_ComputerSystem` `model` string
Below is an example `DriverMapping.json` that includes the additional manufacturers. Note that the model and systemID information is made up and is used only as an example to show how to format the file. You'll need to collect the model or system ID from the locations in the table above and include it in your custom `DriverMapping.json` file. Each entry includes both a WIM and drivers folder for each manufacturer. If you want to include driver WIM files for manufacturers other than Dell, HP, Lenovo, or Microsoft, you'll need to manually compress the drivers folder to a WIM file.
```
[
{
"Manufacturer": "Panasonic",
"Model": "Toughbook CF-33",
"SystemId": "CF-33LEHAGT1",
"DriverPath": "Panasonic\\CF-33.wim"
},
{
"Manufacturer": "Panasonic",
"Model": "Toughbook FZ-55",
"SystemId": "FZ-55DZ0KVM",
"DriverPath": "Panasonic\\FZ-55"
},
{
"Manufacturer": "Viglen",
"Model": "Genie Desktop Pro",
"SystemId": "VGN-GDP-2024",
"DriverPath": "Viglen\\GeniePro"
},
{
"Manufacturer": "Viglen",
"Model": "Omnino Mini",
"SystemId": "VGN-OMN-M1",
"DriverPath": "Viglen\\OmninoMini.wim"
},
{
"Manufacturer": "AZW",
"Model": "SER5 Pro",
"SystemId": "SER5-5800H",
"DriverPath": "AZW\\SER5Pro.wim"
},
{
"Manufacturer": "AZW",
"Model": "U59 Mini PC",
"SystemId": "U59-N5095",
"DriverPath": "AZW\\U59"
},
{
"Manufacturer": "Fujitsu",
"Model": "LIFEBOOK U9312",
"SystemId": "FPCM52921",
"DriverPath": "Fujitsu\\LIFEBOOK-U9312"
},
{
"Manufacturer": "Fujitsu",
"Model": "ESPRIMO D7010",
"SystemId": "D3644-A1",
"DriverPath": "Fujitsu\\D7010.wim"
},
{
"Manufacturer": "Getac",
"Model": "F110 G6",
"SystemId": "F110G6",
"DriverPath": "Getac\\F110G6.wim"
},
{
"Manufacturer": "Getac",
"Model": "B360 Pro",
"SystemId": "B360PRO",
"DriverPath": "Getac\\B360Pro"
},
{
"Manufacturer": "Intel",
"Model": "NUC13ANHi7",
"DriverPath": "Intel\\NUC13"
},
{
"Manufacturer": "Intel",
"Model": "NUC12WSHi5",
"DriverPath": "Intel\\NUC12.wim"
},
{
"Manufacturer": "ByteSpeed",
"Model": "Tera 2450",
"DriverPath": "ByteSpeed\\Tera2450.wim"
},
{
"Manufacturer": "ByteSpeed",
"Model": "Celeritas X1",
"DriverPath": "ByteSpeed\\CeleritasX1"
},
{
"Manufacturer": "Acer",
"Model": "TravelMate P214-53",
"DriverPath": "Acer\\TMP214"
},
{
"Manufacturer": "ASUS",
"Model": "ExpertBook B5402CVA",
"DriverPath": "ASUS\\B5402.wim"
}
]
```
{% include page_nav.html %}
+50
View File
@@ -0,0 +1,50 @@
---
title: Hyper-V Settings
nav_order: 1
prev_url: /ui_overview.html
prev_label: UI Overview
next_url: /windows_settings.html
next_label: Windows Settings
parent: UI Overview
---
# Hyper-V Settings
![1759533067934](image/ui_overview/1759533067934.png)
## VM Switch Name
Drop down of detected VM Switches. There's also an **Other** option which allows you to specify a VM Switch Name. The other option is useful in scenarios where the machine you're running the UI from isn't going to be the machine where you plan to build the FFU from.
## VM Host IP Address
IP address of the selected Hyper-V switch that will be used for FFU capture. The UI will auto-detected this based on the VM Switch that was selected.
If `$InstallApps` is set to `$true`, this parameter must be configured.
## Disk Size (GB)
Size of the virtual hard disk for the virtual machine. Default is a 50GB dynamic disk. You may want to increase the size if you're installing many apps.
## Memory (GB)
Amount of memory to allocate for the virtual machine. Recommended to use 8GB if possible, especially for Windows 11. Default is 4GB.
## Processors
Number of virtual processors for the virtual machine. Recommended to use at least 4. Default is 4.
## VM Location
Default is `$FFUDevelopmentPath\VM`. This is the location of the VHDX that gets created where Windows will be installed to.
## VM Name Prefix
Prefix for the generated VM. Default is _FFU.
## Logical Sector Size
Uint32 value of 512 or 4096. Useful for 4Kn drives or devices shipping with UFS drives. Default is 512.
There is some error-handling in the script that will call out mismatch issues with logical sector size. Unfortunately you will need to create a new FFU with the correct logical sector size as you can't convert a previously created FFU. Most should be fine with 512, but lower-end devices that used to ship with eMMC drives have now shifted to using UFS.
{% include page_nav.html %}
Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+27
View File
@@ -0,0 +1,27 @@
---
title: Home
nav_order: 0
next_url: /prerequisites.html
next_label: Prerequisites
---
# Using Full Flash Update (FFU) files to speed up Windows deployment
What if you could have a Windows image (Windows 10/11 or Server) that has:
- The latest Windows cumulative update
- The latest .NET cumulative update
- The latest Windows Defender Platform and Definition Updates
- The latest version of Microsoft Edge
- The latest version of OneDrive (Per-Machine)
- The latest version of Microsoft 365 Apps/Office
- The latest drivers from any of the major OEMs (Dell, HP, Lenovo, Microsoft) (yes, the latest, not some out of date enterprise CAB file from years ago)
- Winget support so you can integrate any app available from Winget directly in your image
- ARM64 support for the latest Copilot+ PCs
- The ability to bring your own drivers and apps if necessary
- Custom WinRE support
And the best part: it takes **less than two minutes to apply the image**, even with all of these updates added to the media. After setting Windows up and going through Autopilot or a provisioning package, total elapsed time ~10 minutes (depending on what Intune or your device management tool is deploying).
The Full-Flash update (FFU) process can automatically download the latest release of Windows 11, the updates mentioned above, and creates a USB drive that can be used to quickly reimage a machine.
{% include page_nav.html %}
+66
View File
@@ -0,0 +1,66 @@
---
title: Prerequisites
nav_order: 1
prev_url: /
prev_label: Home
next_url: /quickstart.html
next_label: Quick Start
---
# Prerequisites
## Recommendations
If possible, use an unmanaged Windows 11 or Windows Server machine. In some environments we see security software (AV, EDR, Firewall, etc.) get in the way and cause issues with FFU Builder successfully completing a build.
### Disk space
FFU Builder creates a 50GB dynamic VHDX disk by default which can be configured larger if that's not big enough. When the latest updated Windows media is installed to the VHDX, the VHDX size by itself will be about 15GB or so. If you service the media with the latest CU, that could grow the VHDX by double (~30GB in this case). If you install Office, additional applications, drivers, etc. the VHDX can get large quick. The FFU capture process will compress the size significantly, but between the VHDX size, the Windows media, the application and driver source content, the captured FFU, WinPE, etc. you can easily have over 100GB of used space.
So err on the side of having more free disk space. **Recommended to have at least 100GB free disk space**.
## Enable Hyper-V
Follow the guide linked below to install Hyper-V on Windows client or Server
[Install Hyper-V in Windows and Windows Server \| Microsoft Learn](https://learn.microsoft.com/en-us/windows-server/virtualization/hyper-v/get-started/Install-Hyper-V?tabs=gui&pivots=windows)
## Install PowerShell 7
PowerShell 7 is required as of releases 2507+ onward.
[Installing PowerShell on Windows - PowerShell \| Microsoft Learn
](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows)
Recommended to use winget to install
`winget install --id Microsoft.PowerShell --source winget`
If you can't use winget, [download the MSI](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows#installing-the-msi-package)
**Do not** use the Windows Store version as it has some [known limitations](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows?view=powershell-7.5#known-limitations)
## Create Hyper-V Switch
Once Hyper-V has been enabled and you have rebooted, create either an external or internal switch. An external switch is preferred, but an internal switch can be used.
## Download and Extract the Latest Release
If you haven't [downloaded the latest release yet, do so](https://github.com/rbalsleyMSFT/FFU/releases)
Once downloaded, extract the zip file to `C:\FFUDevelopment`. You can use another location, just be sure set your FFUDevelopmentPath to the new location (e.g. `D:\FFUDevelopment`).
After extraction, you most likely will need to unblock the files as they'll be tagged with the mark of the web. In PowerShell run:
`dir "C:\FFUDevelopment" -Recurse | Unblock-File`
Replace `C:\FFUDevelopment` with the path you extracted the files to.
## Running BuildFFUVM_UI.ps1
Either run Terminal as Admin, making sure to select PowerShell, not Windows PowerShell, or PowerShell 7.5+ as Admin and run `C:\FFUDevelopment\BuildFFUVM_UI.ps1`
If all went well, you should see the FFU Builder UI
![1759527337644](image/Prerequisites/1759527337644.png)
{% include page_nav.html %}
+276
View File
@@ -0,0 +1,276 @@
---
title: Quick Start
nav_order: 2
prev_url: /prerequisites.html
prev_label: Prerequisites
next_url: /ui_overview.html
next_label: UI Overview
---
# Quick Start
This is the quick start guide to getting started with FFU Builder. If you're new, start here to build your first FFU.
After following this guide, you will have a USB drive with an FFU that contains the following:
* Windows 11 25H2 (with your choice of architecture, language, SKU, and media type)
* The latest
* Windows and .NET Cumulative Updates
* Defender definitions, platform updates, and Windows Security Center application update
* Microsoft Edge
* Microsoft OneDrive
* Malicious Software Removal Tool (MSRT)
* Microsoft 365 Apps (Current Channel)
* Winget Applications (Optional)
* Company Portal
* Drivers (Optional)
* In some cases drivers aren't necessary and you can get away with Windows Update providing drivers. We'll go over how to add drivers via the UI for Microsoft, HP, Lenovo, or Dell devices
## Prerequisites
Follow the [prerequisites](/FFU/prerequisites.html) documentation before getting started.
## Hyper-V Settings
Click the Hyper-V Settings tab
You should be able to keep these settings at the defaults. For VM Switch Name and VM Host IP Address, you'll want to make sure the switch you created in the prerequisites section is listed. FFU Builder should automatically figure out the IP address of the swtich for you.
One setting you might need to set is the Logical Sector Size. 512 is the default and that's what most physical disks use today. However 4kn drives and UFS drives use 4k sector sizes, which would require you to select 4096. For now, select 512. If you have issues during deployment, there is error logging that will tell you if you need to set to 4096 on your device.
## Windows Settings
Click the Windows Settings tab
If you keep ISO Path blank, FFU Builder will download the ESD file that the Windows Media Creation Tool uses. Most people should leave this blank since the Media Creation Tool media ESD file is now kept up to date as of Windows 11 25H2 (it's usually updated 2-3 days after patch Tuesday). This reduces the need to service the media and saves time and disk space.
Change the Windows language to the one of your choosing.
Change the Windows SKU to either Home or Pro depending on what your physical device(s) shipped with. This is due to how activation works by Windows. If you match the media type and SKU with what the device shipped with, then Windows will activate using the key in the firmware. In most scenarios, selecting Pro and Consumer will be what you want.
Leave the other settings as is. There should be no need to provide a product key unless you're providing your own Windows ISO.
## Updates
Keep the defaults
## Applications (optional)
Click the Applications tab
In this quick start we'll be installing the winget published version of the Company Portal application. This is optional, but it's a common application that IT admins like to install that use Intune to manage their devices. If you want to use something else, feel free to replace Company Portal with another app.
You can also provide your own applications instead of, or in conjunction with, winget applications, however this guide will keep things simple and opt for using winget.
Check Install Winget Applications
Click Check Winget Status
In the Winget Search box search for Company Portal
If you see multiple applications, select the msstore source version of the Company Portal application (FFU Builder doesn't allow the winget source application to install due to how the application is packaged). Make sure to click the check box.
Click the Save AppList.json button
This will save an AppList.json file in your C:\FFUDevelopment\Apps folder and should look like this:
```json
{
"apps": [
{
"name": "Company Portal",
"id": "9WZDNCRFJ3PZ",
"source": "msstore",
"architecture": "NA",
"AdditionalExitCodes": "",
"IgnoreNonZeroExitCodes": false
}
]
}
```
{: .note-title}
> Note
>
> The AppList.json file is what controls winget application downloads during the FFU build process. You might also notice that you can download the application by clicking the Download Selected button. That will do a point in time download of the selected applications via winget and will also update the AppList.json file. At build time the BuildFFUVM.ps1 script will check the AppList.json file and will check if the apps exist. If they do, it will skip downloading the applications.
{: .note-title}
> Note
>
> If your build machine isn't joined to Microsoft Entra, it will prompt you to authenticate twice to download any msstore source application. First for the application, and the second for the license file. This happens for each msstore source application. If this becomes annoying, you can run the FFUBuilder UI on an Entra joined machine (or hybrid joined) and download the apps you need and copy the Apps folder into the Apps folder on your build machine.
{: .tip-title}
> Tip
>
> To download winget applications with the msstore source, [you need one of the following rights on your account: Global Administrator, User Administrator, or License Administrator](https://learn.microsoft.com/en-us/windows/package-manager/winget/download#:~:text=The%20EntraID%20account%20used%20for%20authentication%20to%20generate%20and%20retrieve%20a%20Microsoft%20Store%20packaged%20app%20license%20file%20must%20be%20a%20member%20of%20one%20of%20the%20following%20three%20Azure%20roles%3A%20Global%20Administrator%2C%20User%20Administrator%2C%20or%20License%20Administrator.)
## M365 Apps/Office
FFU Builder uses the Office Deployment Tool to download and install Microsoft 365 Apps.
The default configuration will install the 64-bit current channel version of Office with Word, Excel, Powerpoint. It will download the currently installed language of the operating system (i.e. uses MatchOS for language), which is fine for most people, however if you need to specify the correct language, you'll need to make some modifications to the XML files.
Check the [M365 Apps/Office UI Overview page](/FFU/M365appsoffice.html) that explains the XMLs used in detail.
## Drivers
FFU Builder makes it easy to add drivers from the four major OEMs (Dell, HP, Lenovo, Microsoft). The difference between FFU Builder and other solutions is that FFU Builder tries to use the latest drivers from the OEM, not out-dated driver packages/CABs. This makes things somewhat more complicated to develop, but should make for a better experience where you have the most secure, up to date set of drivers available. It won't handle firmware/BIOS updates as Windows doesn't allow for servicing those types of updates.
**Bring Your Own Drivers**
You're also free to bring your own drivers instead of relying on using the drivers provided by FFU Builder. If you prefer the OEM cabs, you can download them and copy them to the `C:\FFUDevelopment\Drivers\<Make>\<Model>` folder where make is the name of the OEM and Model is the name of the model.
**WinPE Drivers**
FFU Builder also supports adding PE drivers. You can either copy your PE Drivers to the `C:\FFUDevelopment\PEDrivers` folder, or use the Drivers Folder as the PE drivers source. If you use the Drivers folder as the PE drivers source, the build script will find the appropriate driver class GUIDs for WinPE from the `C:\FFUDevelopment\Drivers` folder and copy them into the `C:\FFUDevelopment\PEDrivers` folder, overwriting what's currently in the PEDrivers folder.
For the purposes of this quick start, we'll use an HP EliteBook 850 G8 as the example model.
Click the Drivers tab and click the Download Drivers checkbox.
In the Make drop down, select HP and click Get Models. This may take 10-30 seconds to download the HP PlatformList.cab file and parse it. Once the file has been downloaded and parsed, the model listview will be populated with all of the HP models. Dell has a similar experience with downloading and parsing its Catalog XML file. Microsoft parses some webpages to generate a SurfaceDriverIndex.json
In the Model Filter text box, enter 850. This will filter down to all models that contain 850 in the model name. The model name column will list the model name and the system ID in parenthesis. The system ID is important because it's that value that is used at deployment time to automatically select which drivers to install if you've chosen to copy drivers to the USB drive instead of including them in the FFU file.
Select **HP EliteBook 850 G8 Notebook PC (8846)**
{: .tip-title}
> Tip
>
> If you need to manage multiple models, it's best to select **Copy Drivers to USB drive.** This will keep the FFU file small and allow FFU Builder to automatically select the right drivers at deployment time (based on the system information queried via WMI).
>
> If you select Install Drivers to FFU, you bloat the driver store with drivers you don't need, creating a larger FFU file and wasting disk space on the physical device. It's best to use this option when building an FFU for a single model.
To find the SystemID for HP, you can check BIOS/UEFI, or you can use PowerShell:
```powershell
(Get-CimInstance -Namespace 'root\WMI' -Class MS_SystemInformation).BaseboardProduct
```
Check **Copy Drivers to USB drive** (even though we're doing a single model in this quick start, most people will likely be managing many different models. The expectation is that most should be using **Copy Drivers to USB drive** in their workflows)
Your view should look like this:
![Drivers tab UI with HP 850 G8 selected and Copy Drivers to USB drive selected](image/quickstart/1769212208722.png "Drivers tab UI")
At this point, you can either Save the Drivers.json file or click Download Selected. Clicking Save Drivers.json will save the Driver model information to the Drivers.json file which will be used at build time to download the drivers. Clicking Download Selected will download the drivers right now. This can be useful for testing without having to go through an entire build, or good for airgapped environments where you can download what you need from the internet on one network, and bring that over to the airgapped network.
For this quick start, click **Save Drivers.json** and save the file to the `C:\FFUDevelopment\Drivers` folder.
## Build
Click the Build tab
On the Build tab, click **Build USB Drive.**
Depending on your USB drive, it might be a removable drive or external hard disk media. If you're using a fast USB SSD, it's likely that Windows treats that drive as an external hard disk. If that's the case, you might need to click **Allow External Hard Disk Media.** If you do, you may also want to uncheck **Prompt for External Hard Disk Media.** This option is in place to prevent external hard disks from automatically being formatted when building the USB drive. If you don't have any external hard disks connected to your build machine, then it's safe to uncheck the **Prompt for External Hard Disk Media** option.
Another safety measure is **Select Specific USB Drives**. When you check **Select Specific USB Drives** a list view will pop up with a **Check USB drives** button. Clicking the **Check USB Drives** button will show all connected USB drives (removable or external hard disks). Select which drives you want.
**Device Naming**
Device naming can be done from PE. The way this works is by leveraging an unattend.xml file to either take input from the user at imaging time or read a list of prefix values and append the serial number of the device. There are some major benefits to doing this:
1. Total deployment time is reduced if naming is set at FFU deployment time since there is no additional reboot done during OOBE.
2. Reduces the need for multiple provisioning packages or autopilot profiles. This means you can use a single PPKG or autopilot profile.
**Prompt for Device Name**
If you want to be prompted for the device name, simply check **Copy Unattend.xml.** This tells the build script to copy the appropriate architecture unattend_arch.xml file from the `C:\FFUDevelopment\Unattend` folder to the `.\unattend` folder on the deploy partition of the USB drive.
**Specifying Multiple Name Prefixes**
If you have multiple device name prefixes for different locations or device use cases, or even a single prefix, you can specify a prefixes.txt file in the `C:\FFUDevelopment\unattend` folder. If the prefixes.txt file is detected and a single prefix is listed, the device will just use that prefix and append the serial number of the device. If there are multiple prefixes listed in the prefixes.txt file, you will be prompted to select which prefix you want to name the device and the serial number will be appended to that prefix. If you want a dash in the name, include the dash in the prefix (e.g. if ABCD- is in the prefixes.txt file, the device name will be ABCD-SerialNumber).
{: .warning-title}
> Warning
>
> If using a provisioning package or autopilot json file, DO NOT specify a name in either of these. They will overwrite the name you have specified in the unattend.xml.
**Post Build Cleanup**
Leave the Post Build Cleanup section at the defaults
Your Build tab should look something like this:
![1769218100003](image/quickstart/1769218100003.png)
Click **Build FFU**
Depending on your internet speed, speed of your build machine, etc. this will take some time (probably at least 20 minutes). After clicking Build FFU, you'll be automatically moved to the Monitor tab.
## Monitor
![1769218278316](image/quickstart/1769218278316.png)
The monitor tab parses the `C:\FFUDevelopment\FFUDevelopment.log` file. If you'd like to use CMTrace or another tool to monitor the log, feel free. The monitor tab has some similar functionality to CMTrace. If you click off the last line of the log in the monitor tab it will stay on that line, allowing you to read what you have selected instead of the log autoscrolling. You can also copy one or multiple lines by selecting a line and shift+clicking the last line you want to select and hitting ctrl+c to copy the lines.
Now sit back, relax, and watch FFU Builder do its magic. You should see a VM pop up after everything is downloaded and Windows has been installed to the VHDX file (it may not if Hyper-V manager wasn't previously open). If you don't see a VM pop up, you can open up Hyper-V manager and look for a VM with a name that starts with **_FFU-.**
{: .note-title}
> Note
>
> Don't interact with the VM (e.g. don't click into the PowerShell window that's orchestrating the install of the updates, apps, etc). The whole process should be completly automated with no user interaction necessary. If you click into the PowerShell window while it's working, you may get PowerShell into "select" mode. If this happens, the PowerShell window will look like it's "stuck." That's because clicking into a cmd/PowerShell window while something is in process and you're in select mode waits for you to exit select by hitting Enter.
## Post Build
Once the build is complete, you should have a USB drive with two partitions: Boot and Deploy.
The Boot partition is the WinPE Deployment media. It'll look like this:
![1769222221857](image/quickstart/1769222221857.png)
The Deploy partition is where the FFU file, the Drivers folder, and Unattend folders should be.
![1769222271610](image/quickstart/1769222271610.png)
The Drivers folder will have an HP folder, a DriverMapping.json and Drivers.json file.
![1769222319233](image/quickstart/1769222319233.png)
The DriverMapping.json file is what's used to do automatic driver matching during deployment. FFU Builder will read this file and match on the SystemID to know which driver folder to apply. Since there's only a single driver folder in this example, the file is fairly simple to understand. When you have multiple models and OEMs, the file gets a bit more complex.
The DriverMapping.json should look like this:
```json
{
"Manufacturer": "HP",
"Model": "HP EliteBook 850 G8 Notebook PC (8846)",
"DriverPath": "HP\\HP EliteBook 850 G8 Notebook PC (8846)",
"SystemId": "8846"
}
```
The HP EliteBook 850 driver folder should be populated with the various driver categories:
![1769222537720](image/quickstart/1769222537720.png)
And the Unattend folder should have an unattend.xml file with the following content:
```xml
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="specialize">
<!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it -->
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ComputerName>MyComputer</ComputerName>
</component>
<!--Place addtional Components Elements and settings below here. -->
</settings>
</unattend>
```
Now you're ready to deploy the FFU to your device.
## Deployment
Deployment should be fairly straight forward: boot off the USB device, get prompted for a device name, and the deployment of the FFU and drivers should happen automatically.
If you have any questions or run into any issues, [open a discussion in the Github repo](https://github.com/rbalsleyMSFT/FFU/discussions).
{% include page_nav.html %}
+14
View File
@@ -0,0 +1,14 @@
---
title: UI Overview
nav_order: 3
prev_url: /prerequisites.html
prev_label: Prerequisites
next_url: /hyperv_settings.html
next_label: Hyper-V Settings
has_toc: false
---
# UI Overview
![1759527337644](image/Prerequisites/1759527337644.png)
The user interface has 9 distinct tabs for easy navigation.
+52
View File
@@ -0,0 +1,52 @@
---
title: Updates
nav_order: 3
prev_url: /windows_settings.html
prev_label: Windows Settings
next_url: /applications.html
next_label: Applications
parent: UI Overview
---
# Updates
![1759878277807](image/updates/1759878277807.png)
## Update Latest Cumulative Update
Controls the `-UpdateLatestCU` parameter. When set to `$true`, will download and install the latest cumulative update for Windows.
## Update .NET
Controls the `-UpdateLatestNet` parameter. When set to `$true`, will download and install the latest .NET framework update for Windows.
## Update Defender
Controls the `-UpdateLatestDefender` parameter. When set to `$true`, will download and install the latest Windows Defender definitions, Defender platform, and Windows Security app update.
## Update Edge
Controls the `-UpdateEdge` parameter. When set to `$true`, will download and install the latest Microsoft Edge browser.
## Update OneDrive (per-machine)
Controls the `-UpdateOneDrive` parameter. When set to `$true`, will download and install the latest OneDrive and install it as per-machine instead of per-user.
## Update Microsoft Software Removal Tool (MSRT)
Controls the `-UpdateLatestMSRT` parameter. When set to `$true`, will download and install the latest Windows Malicious Software Removal Tool.
## Update Latest Microcode (for LTSC/Server 2016/2019)
Controls the `-UpdateLatestMicrocode` parameter. When set to `$true`, will download and install the latest microcode updates for applicable Windows releases (e.g., Windows Server 2016/2019, Windows 10 LTSC 2016/2019) into the FFU.
## Update Preview Cumulative Update
Controls the `-UpdatePreviewCU` parameter. When set to `$true`, will download and install the latest preview cumulative update for Windows.
{: .note-title}
> Note
>
> The UI will only allow one of **Update Latest CU** or **Update Preview CU** to be checked to prevent both being applied.
{% include page_nav.html %}
+147
View File
@@ -0,0 +1,147 @@
---
title: Windows Settings
nav_order: 2
prev_url: /hyperv_settings.html
prev_label: Hyper-V Settings
next_url: /updates.html
next_label: Updates
parent: UI Overview
---
# Windows Settings
![1759535556990](image/ui_overview/1759535556990.png)
## Windows ISO Path
Path to Windows 10/11 ISO file. If left blank, FFU Builder will download the latest version of Windows 10 or 11 from the Media Creation Tool.
{: .tip-title}
> Tip
>
> Should I provide my own ISO, or let FFU Builder download the media
>
> It's recommended to use the latest updated ISO from Visual Studio Downloads, or the Media Creation tool. See the Media Type section below for a better understanding of business and consumer media and how it impacts Subscription Activation.
## Windows Release
Integer value of 10 or 11. This is used to identify which release of Windows to download. Default is 11.
## Windows Version
String value of the Windows version. Default is `25H2`. If an ISO is not specified, this drop down is disabled. If an ISO is specified, you can select from the drop down which version of Windows you're installing. The Windows version is used in quite a few different scenarios (HP driver downloads, VHDXCaching, MCT media downloads, FFU file naming, and cumulative update downloads), so it's important you specify the correct Windows Version.
## Windows Architecture
String value of `x86`, `x64`, or `arm64`. Depending on the Windows release and Windows version, the UI will only specify the supported architecture types for a specific Release/Version combo.
## Windows Language
String value in language-region format (e.g., `en-us`). This is used to identify which language of media to download. Default is `en-us`.
{: .note-title}
> Note
>
> These are the supported languages that can be used with the `-WindowsLang` parameter when downloading the Windows MCT\ESD file
>
> ar-sa
> bg-bg
> cs-cz
> da-dk
> de-de
> el-gr
> en-gb
> en-us
> es-es
> es-mx
> et-ee
> fi-fi
> fr-ca
> fr-fr
> he-il
> hr-hr
> hu-hu
> it-it
> ja-jp
> ko-kr
> lt-lt
> lv-lv
> nb-no
> nl-nl
> pl-pl
> pt-br
> pt-pt
> ro-ro
> ru-ru
> sk-sk
> sl-si
> sr-latn-rs
> sv-se
> th-th
> tr-tr
> uk-ua
> zh-cn
> zh-tw
## Windows SKU
Edition of Windows 10/11/LTSC/Server to be installed.
{: .note-title}
> Note
>
> The following SKUs are supported
>
> Home
> Home N
> Home Single Language
> Education
> Education N
> Pro
> Pro N
> Pro Education
> Pro Education N
> Pro for Workstations
> Pro N for Workstations
> Enterprise
> Enterprise N
> Enterprise 2016 LTSB
> Enterprise N 2016 LTSB
> Enterprise LTSC
> Enterprise N LTSC
> IoT Enterprise LTSC
> IoT Enterprise N LTSC
> Standard
> Standard (Desktop Experience)
> Datacenter
> Datacenter (Desktop Experience)
## Media Type
String value of either `business` or `consumer`. This is used to identify which media type to download if not providing an ISO. Default is `consumer`
{: .tip-title}
> Tip
>
> Recommendation on media type to use
>
> Windows media comes in two types: business or consumer.
>
> Windows media can be obtained from a few different sources: Volume Licensing Service Center (VLSC now available at admin.microsoft.com), Microsoft Visual Studio Downloads, or the Windows Media Creation Tool.
>
> The `BuildFFUVM.ps1` script will allow you to pass whichever type of media you want from whatever source you want using the -ISOPath parameter; however, **its recommended that you use consumer media**, not business/VL. This is because Subscription Activation will fail if the media is mismatched from the key in the firmware.
>
> If you plan on using a MAK or KMS to activate, you can use media from VLSC, but if you expect the device to activate automatically and upgrade to Enterprise or Education SKUs via Subscription Activation, you must use consumer media. To use a MAK/KMS key to activate, you must provide the -`ProductKey XXXXX-XXXXX-XXXXX-XXXXX-XXXXX` parameter.
## Product Key
Product key for the Windows edition specified in WindowsSKU. This will overwrite whatever SKU is entered for WindowsSKU. Recommended to use if you want to use a MAK or KMS key to activate Enterprise or Education. If using VL media instead of consumer media, you'll want to enter a MAK or KMS key here.
## Optional Features
A list of optional features that you can enable for the version of Windows you're installing (e.g. netfx3; TFTP).
{% include page_nav.html %}
+118
View File
@@ -0,0 +1,118 @@
---
title: Install Winget Applications
nav_order: 5
prev_url: /applications.html
prev_label: Applications
next_url: /byoapps.html
next_label: BYO Applications
parent: Applications
grand_parent: UI Overview
---
# Install Winget Applications
![FFU Builder UI - Applications tab with Install Winget Applications checked searching for windows app](image/applicationscopy/1759882085098.png "FFU Builder UI - Applications tab with Install Winget Applications checked searching for windows app")
## Check Winget Status
Installing Winget applications requires that both the winget CLI and Microsoft.Winget.Client PowerShell module to be installed. Minimum required version of both the CLI and PowerShell module is 1.8.1911.
Click **Check Winget Status** to validate the versions of both the CLI and PowerShell module. If older than the minimum required version, will be updated to the latest version.
![Check Winget Status button](image/winget/1759883187930.png "Check Winget Status button")
After validating Winget status, you'll be able to search winget for applications. The larger the result set, the longer it will take for the list view to be populated. For example, if searching for **win**, the UI might appear to hang while it searches for apps with a name or id of **win** due to 669 results being returned and processed. Instead, if you search for **windows app**, 13 results are returned within a few seconds.
The UI allows for multi-selection of applications
![Winget search list view with Windows App, VLC media player, and Snagit 2025 selected](image/winget/1759884200465.png "Winget search list view with Windows App, VLC media player, and Snagit 2025 selected")
You can also change the architecture, add additional exit codes, or ignore exit codes completely.
## Architecture
![Architecture options that can be selected for winget applications](image/winget/1759884446099.png "Architecture options that can be selected for winget applications")
FFU Builder supports x86, x64, arm64, and x86/x64 (both) for applications in the winget source repository. For apps in the msstore source repository, the architecture cannot be changed. In most cases, x64 will be what you want, however in some cases the combo of x86 and x64 will be necessary. This might be due to runtimes (.NET, Visual C++) where an application is expecting both x86 and x64 runtimes.
## Additional Exit Codes
You can provide a comma separated list of additional exit codes if your application doesn't exit with 0. Some apps may exit with a non-zero exit code.
## Ignore Exit Codes
If you know your application exits with some random exit code or simply don't care to populate a list of approved exit codes, check the box to ignore exit codes and FFU Builder will ignore the exit code and continue on.
## Download Status
FFU Builder allows you to download applications prior to deployment. When clicking the Download Selected button, the Download Status column tracks the status of the download and outputs success, or in the case of an error, the reason why the download may have failed.
## Save AppList.json
FFU Builder leverages a number of json files to tell the `BuildFFUVM.ps1` script what to do during deployment time. `AppList.json` controls the Winget application download and installation.
The `AppList.json` file gets created when clicking **Download Selected**, or clicking the **Save AppList.json** file. The default path for the `AppList.json` file is `$AppsPath\AppList.json`
An example of the `AppList.json` file:
```json
{
"apps": [
{
"name": "Windows App",
"id": "Microsoft.WindowsApp",
"source": "winget",
"architecture": "x64",
"AdditionalExitCodes": "",
"IgnoreNonZeroExitCodes": false
},
{
"name": "VLC media player",
"id": "VideoLAN.VLC",
"source": "winget",
"architecture": "x64",
"AdditionalExitCodes": "",
"IgnoreNonZeroExitCodes": false
},
{
"name": "Snagit 2025",
"id": "TechSmith.Snagit.2025",
"source": "winget",
"architecture": "x64",
"AdditionalExitCodes": "",
"IgnoreNonZeroExitCodes": false
},
{
"name": "Company Portal",
"id": "9WZDNCRFJ3PZ",
"source": "msstore",
"architecture": "NA",
"AdditionalExitCodes": "",
"IgnoreNonZeroExitCodes": false
}
]
}
```
## Import AppList.json
If you have a previously saved `AppList.json` you want to use or modify, you can import an `AppList.json` file.
## Download Selected
As mentioned in the Download Selected section above, FFU Builder allows you to download applications prior to deployment. When clicking the Download Selected button, the Download Status column tracks the status of the download and outputs success, or in the case of an error, the reason why the download may have failed. By default it will download five applications at a time. This is controlled by the
Apps are downloaded to `.\FFUDevelopment\Apps\Win32\<AppName>` or `.\FFUDevelopment\Apps\MSStore\<AppName>` depending on the winget source value. Each application will have the app installation files and a yaml manifest file. For Win32 applications, FFU Builder parses the yaml file to grab the silent install switches needed for silent application installation at build time.
{: .tip-title}
> Tip
>
> When downloading msstore source applications, Microsoft requires applications to be downloaded with a license file (the Winget PowerShell module doesn't allow the option to skip downloading the license like the winget CLI does). This requires authentication via Entra ID. If using a device joined to Entra and signed in with your Entra ID, SSO will bypass the need to re-authenticate to download the app and license file. If the machine you are running FFU Builder on is not joined to Entra ID, you will be prompted twice to download the application and the license file.
>
> It's recommended that if you are downloading a lot of msstore source applications, do it from a machine that's joined to Entra ID.
## Clear List
The Clear List button will clear the list view of what's currently in it. It will not clear the AppList.json file if it exists.
{% include page_nav.html %}