diff --git a/ChangeLog.md b/ChangeLog.md index b2a0507..76a879e 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,5 +1,63 @@ # Change Log +# 2604.1 + +## What's Changed + +### Fluent style + +With the release of PowerShell 7.6 finally going to GA, I was able to release the Fluent UI styling refresh. This will bring significant improvement to the look and feel of FFU Builder. Note that you will want to make sure you're running **PowerShell 7.6**, otherwise the listviews for Drivers and Applications will be missing the column headers. + +### Build tab reorganization + +The build tab sections now have expanders for the settings within. This should help with organization of each setting. + +### Home page build and release status + +The Home page of FFU Builder will now tell you what build you're on and if there's a new build along with the release notes for the new build. You can also see disk space and hyper-v status, as well as the latest Github repo discussions and a list of resources. + +### Fixed an issue with Surface and Lenovo driver downloads + +Microsoft changed the Surface driver download support page. FFU Builder now uses the [Microsoft Learn page for Surface driver downloads ](https://learn.microsoft.com/en-us/surface/manage-surface-driver-and-firmware-updates) that's designed for IT Admins. It's an easier table to parse rather than trying to parse the updated support page that FFU Builder used to use. + +Fixed an issue where retrieving Lenovo models was failing. + +### Removed Capture ISO + +FFU Builder no longer relies on booting to WinPE to capture builds done via the VM. Instead, FFU Builder will now just capture the VHDX directly. This improves FFU build times tremendously and reduces the need for the VM Switch. The switch is still necessary for those that want to add internet connectivity during the FFU build process. + +### Refresh Windows SKU after fallback SKU selection + +Fixed an issue grabbing the correct Windows SKU when the user incorrectly chose a SKU that wasn't in the media and had to later be prompted for an available SKU. In this situation the SKU that was provided earlier was chosen, which caused a variety of issues. + +### Removed registry-based FFU file naming + +Removed registry-based FFU file naming and now rely on parameters provided at build time and the custom FFU naming template. This will remove the hard coded wait times that had to do with loading/unloading of the registry. + +### Added a checkbox to enable network connectivity during VM build + +Add a checkbox to enable network connectivity during the VM build. I'm fairly confident that the build process should be able to withstand any sysprep-related issues being connected to the internet. The checkbox is flagged as experimental. Give it a try and let me know if you notice any issues. + +### Added UI controls for Device Naming + +Device naming now has an expander in the Build tab that will expose a number of new options available. Rather than writing up a whole thing here in the release notes, the UI should be intuitive enough to explain how it works. The docs have also been updated. I spent a lot of time testing the changes with both legacy naming scenarios and if you make changes in the UI. If you see something that doesn't work, open a discussion or issue. + +### Fixed Office installation issues on ARM64 VMs + +I actually didn't fix anything, but rather removed a restriction that was put in place due to Office requiring internet access to install on ARM64. It seems the PG has fixed the issue requiring internet access and office will now install. However there's a caveat that it will prompt with a compatibility assistant popup. I think we can disable the compatibility assistant service to prevent the pop up from happening in the orchestrator. Will look into this in a future release. + +### Auto-generate ComputerName in Unattend.xml + +Now you can provide your own Unattend.xml without a ComputerName element and FFU Builder will add it if you've chosen to include a computer name. If there's a ComputerName element already in the file, ApplyFFU.ps1 will find it and modify it as per your naming choices. + +### Add custom unattend.xml paths + +There's a new expander for Unattend.xml options in the Build tab which includes paths for the x64 and arm64 unattend.xml files. This means that you can have your unattend files in any location instead of in the FFUDevelopment\unattend folder. This should make upgrades easier for those that have custom unattend.xml files and copying new releases would overwrite your customized unattend files. + +### Fixed an issue where CUs wouldn't service after the March 31, 2026 OOB update (KB5086672) was installed on your host machine + +The KB5086672 CU which is rolled into the April 14, 2026 update (KB5083769) caused an issue with Add-WindowsPackage. Add-WindowsPackage uses the DISM API to service a Windows image. The native dism.exe doesn't have this issue. To keep things consistent, FFU Builder will now use the dism.exe from the installed Windows ADK. While this version of dism might be older than what's on your machine, it should be consistent and not be impacted by future CUs. + # 2603.2 ## What's Changed diff --git a/FFUDevelopment/Apps/Orchestration/Install-Win32Apps.ps1 b/FFUDevelopment/Apps/Orchestration/Install-Win32Apps.ps1 index 6d4ab63..0316bb0 100644 --- a/FFUDevelopment/Apps/Orchestration/Install-Win32Apps.ps1 +++ b/FFUDevelopment/Apps/Orchestration/Install-Win32Apps.ps1 @@ -1,3 +1,11 @@ +# Allow Orchestrator.ps1 to override the app list file paths while preserving legacy defaults. +param( + [Parameter()] + [string]$wingetAppsJsonFile = (Join-Path -Path $PSScriptRoot -ChildPath "WinGetWin32Apps.json"), + [Parameter()] + [string]$userAppsJsonFile = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath "UserAppList.json") +) + function Invoke-Process { [CmdletBinding(SupportsShouldProcess)] param @@ -247,11 +255,6 @@ function Install-Applications { } } -# Define paths for the JSON files -$wingetAppsJsonFile = "$PSScriptRoot\WinGetWin32Apps.json" -# Look for UserAppList.json one directory level up from the script's location. This keeps the user specific json files (AppList.json and UserAppList.json in the Apps dir) -$userAppsJsonFile = Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath "UserAppList.json" - # Initialize empty arrays for apps from each source $wingetApps = @() $userApps = @() @@ -286,9 +289,9 @@ if ($wingetApps.Count -gt 0) { Install-Applications -apps $wingetApps } -# Read the UserAppList.json file if it exists +# Read the configured BYO app list file if it exists if (Test-Path -Path $userAppsJsonFile) { - Write-Host "Processing UserAppList.json..." + Write-Host "Processing $(Split-Path -Path $userAppsJsonFile -Leaf)..." try { $userContent = Get-Content -Path $userAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json if ($userContent -is [array]) { @@ -296,19 +299,19 @@ if (Test-Path -Path $userAppsJsonFile) { Write-Host "Found $(($userApps | Measure-Object).Count) user-defined apps." } elseif ($userContent) { - $userApps = @($userContent) # Ensure it's an array + $userApps = @($userContent) Write-Host "Found 1 user-defined app." } else { - Write-Host "UserAppList.json is empty or invalid." + Write-Host "$(Split-Path -Path $userAppsJsonFile -Leaf) is empty or invalid." } } catch { - Write-Error "Failed to read or parse UserAppList.json file: $_" + Write-Error "Failed to read or parse BYO app list file '$userAppsJsonFile': $_" } } else { - Write-Host "UserAppList.json file not found. Skipping." + Write-Host "BYO app list file not found at $userAppsJsonFile. Skipping." } # Install User apps if any were found diff --git a/FFUDevelopment/Apps/Orchestration/Orchestrator.ps1 b/FFUDevelopment/Apps/Orchestration/Orchestrator.ps1 index 9f6bc68..88c23f1 100644 --- a/FFUDevelopment/Apps/Orchestration/Orchestrator.ps1 +++ b/FFUDevelopment/Apps/Orchestration/Orchestrator.ps1 @@ -28,6 +28,23 @@ Write-Host "---------------------------------------------------" -ForegroundColo # Define the path to the scripts $scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition +# Resolve the configured BYO app list path for runtime orchestration. +$appInstallConfigPath = Join-Path -Path $scriptPath -ChildPath "AppInstallConfig.json" +$userAppsJsonFile = Join-Path -Path (Split-Path -Parent $scriptPath) -ChildPath "UserAppList.json" + +if (Test-Path -Path $appInstallConfigPath) { + try { + $appInstallConfig = Get-Content -Path $appInstallConfigPath -Raw | ConvertFrom-Json + if ($null -ne $appInstallConfig -and $appInstallConfig.PSObject.Properties.Match('UserAppListPath').Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($appInstallConfig.UserAppListPath)) { + $userAppsJsonFile = $appInstallConfig.UserAppListPath + Write-Host "Using BYO app list path from AppInstallConfig.json: $userAppsJsonFile" + } + } + catch { + Write-Host "Failed to parse AppInstallConfig.json. Falling back to default BYO app list path." + } +} + # Define the list of scripts to run $scriptList = @( "Install-LTSCUpdate.ps1", @@ -51,7 +68,6 @@ foreach ($script in $scriptList) { switch ($script) { "Install-Win32Apps.ps1" { $wingetAppsJsonFile = Join-Path -Path $scriptPath -ChildPath "WinGetWin32Apps.json" - $userAppsJsonFile = Join-Path -Path (Split-Path -Parent $scriptPath) -ChildPath "UserAppList.json" if (-not (Test-Path -Path $wingetAppsJsonFile) -and -not (Test-Path -Path $userAppsJsonFile)) { $shouldRun = $false } @@ -69,8 +85,13 @@ foreach ($script in $scriptList) { Write-Host "---------------------------------------------------" -ForegroundColor Yellow Write-Host " Running script: $script " -ForegroundColor Yellow Write-Host "---------------------------------------------------" -ForegroundColor Yellow - # Run script and wait for it to finish - & $scriptFile + # Run script and wait for it to finish. + if ($script -eq "Install-Win32Apps.ps1") { + & $scriptFile -UserAppsJsonFile $userAppsJsonFile + } + else { + & $scriptFile + } } } diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1 index 6a197fb..250d415 100644 --- a/FFUDevelopment/BuildFFUVM.ps1 +++ b/FFUDevelopment/BuildFFUVM.ps1 @@ -36,9 +36,6 @@ Switch to run cleanup-only mode. When specified, the script performs cleanup and .PARAMETER CleanupAppsISO When set to $true, will remove the Apps ISO after the FFU has been captured. Default is $true. -.PARAMETER CleanupCaptureISO -When set to $true, will remove the WinPE capture ISO after the FFU has been captured. Default is $true. - .PARAMETER CleanupCurrentRunDownloads When set to $true, cleanup mode will remove downloads created during the current run and restore backed up run JSON files. Default is $false. @@ -73,10 +70,31 @@ When set to $true, enables adding WinPE drivers. By default copies drivers from When set to $true, will copy the provisioning package from the $FFUDevelopmentPath\PPKG folder to the Deployment partition of the USB drive. Default is $false. .PARAMETER CopyUnattend -When set to $true, will copy the $FFUDevelopmentPath\Unattend folder to the Deployment partition of the USB drive. Default is $false. +When set to $true, stages the selected architecture-specific unattend XML file as Unattend.xml on the deployment partition of the USB drive. Default is $false. -.PARAMETER CreateCaptureMedia -When set to $true, this will create WinPE capture media for use when $InstallApps is set to $true. This capture media will be automatically attached to the VM, and the boot order will be changed to automate the capture of the FFU. +.PARAMETER DeviceNamingMode +Controls how device naming is handled when unattend content is copied to USB media or injected into the FFU. Supported values are Legacy, None, Prompt, Template, Prefixes, and SerialComputerNames. + +.PARAMETER DeviceNameTemplate +Sets the device name used when DeviceNamingMode is Template. Supports a static name or the %serial% token when CopyUnattend is used. + +.PARAMETER DeviceNamePrefixes +Sets the prefixes used when DeviceNamingMode is Prefixes. Each entry becomes a line in prefixes.txt on the deployment media. + +.PARAMETER DeviceNamePrefixesPath +Path to the source prefixes file used for legacy copy or when DeviceNamePrefixes is not supplied. Default is $FFUDevelopmentPath\Unattend\prefixes.txt. + +.PARAMETER DeviceNameSerialComputerNames +Sets the CSV content used when DeviceNamingMode is SerialComputerNames. The CSV must include SerialNumber and ComputerName headers. + +.PARAMETER DeviceNameSerialComputerNamesPath +Path to the source CSV file used when DeviceNamingMode is SerialComputerNames and DeviceNameSerialComputerNames is not supplied. Default is $FFUDevelopmentPath\Unattend\SerialComputerNames.csv. + +.PARAMETER UnattendX64FilePath +Path to the x64 unattend XML source file. Default is $FFUDevelopmentPath\Unattend\unattend_x64.xml. + +.PARAMETER UnattendArm64FilePath +Path to the arm64 unattend XML source file. Default is $FFUDevelopmentPath\Unattend\unattend_arm64.xml. .PARAMETER CreateDeploymentMedia When set to $true, this will create WinPE deployment media for use when deploying to a physical device. @@ -96,6 +114,9 @@ Path to a JSON file that specifies which drivers to download. .PARAMETER ExportConfigFile Path to a JSON file to export the parameters used for the script. +.PARAMETER EnableVMNetworking +When set to $true, connects the build VM to the Hyper-V virtual switch named in -VMSwitchName during provisioning. Default is $false because internet-connected Sysprep is experimental. + .PARAMETER FFUCaptureLocation Path to the folder where the captured FFU will be stored. Default is $FFUDevelopmentPath\FFU. @@ -109,7 +130,7 @@ Prefix for the generated FFU file. Default is _FFU. Headers to use when downloading files. Not recommended to modify. .PARAMETER InjectUnattend -When set to $true and InstallApps is also $true, copies unattend_[arch].xml from $FFUDevelopmentPath\unattend to $FFUDevelopmentPath\Apps\Unattend\Unattend.xml so sysprep can use it inside the VM. Default is $false. +When set to $true and InstallApps is also $true, stages the selected architecture-specific unattend XML file to $FFUDevelopmentPath\Apps\Unattend\Unattend.xml so sysprep can use it inside the VM. Default is $false. .PARAMETER InstallApps When set to $true, the script will create an Apps.iso file from the $FFUDevelopmentPath\Apps folder. It will also create a VM, mount the Apps.iso, install the apps, sysprep, and capture the VM. When set to $false, the FFU is created from a VHDX file, and no VM is created. @@ -177,9 +198,6 @@ When set to $true, will remove the downloaded CU, MSRT, Defender, Edge, OneDrive .PARAMETER RemoveDownloadedESD When set to $true, will remove downloaded Windows ESD files after they have been applied. Default is $true. -.PARAMETER ShareName -Name of the shared folder for FFU capture. The default is FFUCaptureShare. This share will be created with rights for the user account. When finished, the share will be removed. - .PARAMETER Threads Controls the throttle applied to parallel tasks inside the script. Default is 5, matching the UI Threads field, and applies to driver downloads invoked through Invoke-ParallelProcessing. @@ -228,17 +246,11 @@ User agent string to use when downloading files. .PARAMETER UserAppListPath Path to a JSON file containing a list of user-defined applications to install. Default is $FFUDevelopmentPath\Apps\UserAppList.json. -.PARAMETER Username -Username for accessing the shared folder. The default is ffu_user. The script will auto-create the account and password. When finished, it will remove the account. - -.PARAMETER VMHostIPAddress -IP address of the Hyper-V host for FFU capture. If $InstallApps is set to $true, this parameter must be configured. You must manually configure this, or use the UI to auto-detect. - .PARAMETER VMLocation Default is $FFUDevelopmentPath\VM. This is the location of the VHDX that gets created where Windows will be installed to. .PARAMETER VMSwitchName -Name of the Hyper-V virtual switch. If $InstallApps is set to $true, this must be set to capture the FFU from the VM. +Name of the Hyper-V virtual switch used when -EnableVMNetworking is set to $true. Provide it only if the VM needs network connectivity during provisioning. .PARAMETER WindowsArch String value of 'x86', 'x64', or 'arm64'. This is used to identify which architecture of Windows to download. Default is 'x64'. @@ -257,25 +269,25 @@ String value of the Windows version to download. This is used to identify which .EXAMPLE Command line for most people who want to download the latest Windows 11 Pro x64 media in English (US) with the latest Windows Cumulative Update, .NET Framework, Defender platform and definition updates, Edge, OneDrive, and Office/M365 Apps. It will also copy drivers to the FFU. This can take about 40 minutes to create the FFU due to the time it takes to download and install the updates. -.\BuildFFUVM.ps1 -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -VMSwitchName 'Name of your VM Switch in Hyper-V' -VMHostIPAddress 'Your IP Address' -CreateCaptureMedia $true -CreateDeploymentMedia $true -BuildUSBDrive $true -UpdateLatestCU $true -UpdateLatestNet $true -UpdateLatestDefender $true -UpdateEdge $true -UpdateOneDrive $true -verbose +.\BuildFFUVM.ps1 -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -CreateDeploymentMedia $true -BuildUSBDrive $true -UpdateLatestCU $true -UpdateLatestNet $true -UpdateLatestDefender $true -UpdateEdge $true -UpdateOneDrive $true -verbose Command line for most people who want to create an FFU with Office and drivers and have downloaded their own ISO. This assumes you have copied this script and associated files to the C:\FFUDevelopment folder. If you need to use another drive or folder, change the -FFUDevelopment parameter (e.g. -FFUDevelopment 'D:\FFUDevelopment') -.\BuildFFUVM.ps1 -ISOPath 'C:\path_to_iso\Windows.iso' -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -VMSwitchName 'Name of your VM Switch in Hyper-V' -VMHostIPAddress 'Your IP Address' -CreateCaptureMedia $true -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose +.\BuildFFUVM.ps1 -ISOPath 'C:\path_to_iso\Windows.iso' -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose Command line for those who just want a FFU with no drivers, apps, or Office and have downloaded their own ISO. -.\BuildFFUVM.ps1 -ISOPath 'C:\path_to_iso\Windows.iso' -WindowsSKU 'Pro' -Installapps $false -InstallOffice $false -InstallDrivers $false -CreateCaptureMedia $false -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose +.\BuildFFUVM.ps1 -ISOPath 'C:\path_to_iso\Windows.iso' -WindowsSKU 'Pro' -Installapps $false -InstallOffice $false -InstallDrivers $false -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose Command line for those who just want a FFU with Apps and drivers, no Office and have downloaded their own ISO. -.\BuildFFUVM.ps1 -ISOPath 'C:\path_to_iso\Windows.iso' -WindowsSKU 'Pro' -Installapps $true -InstallOffice $false -InstallDrivers $true -VMSwitchName 'Name of your VM Switch in Hyper-V' -VMHostIPAddress 'Your IP Address' -CreateCaptureMedia $true -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose +.\BuildFFUVM.ps1 -ISOPath 'C:\path_to_iso\Windows.iso' -WindowsSKU 'Pro' -Installapps $true -InstallOffice $false -InstallDrivers $true -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose Command line for those who want to download the latest Windows 11 Pro x64 media in English (US) and install the latest version of Office and drivers. -.\BuildFFUVM.ps1 -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -VMSwitchName 'Name of your VM Switch in Hyper-V' -VMHostIPAddress 'Your IP Address' -CreateCaptureMedia $true -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose +.\BuildFFUVM.ps1 -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose Command line for those who want to download the latest Windows 11 Pro x64 media in French (CA) and install the latest version of Office and drivers. -.\BuildFFUVM.ps1 -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -VMSwitchName 'Name of your VM Switch in Hyper-V' -VMHostIPAddress 'Your IP Address' -CreateCaptureMedia $true -CreateDeploymentMedia $true -BuildUSBDrive $true -WindowsRelease 11 -WindowsArch 'x64' -WindowsLang 'fr-ca' -MediaType 'consumer' -verbose +.\BuildFFUVM.ps1 -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -CreateDeploymentMedia $true -BuildUSBDrive $true -WindowsRelease 11 -WindowsArch 'x64' -WindowsLang 'fr-ca' -MediaType 'consumer' -verbose Command line for those who want to download the latest Windows 11 Pro x64 media in English (US) and install the latest version of Office and drivers. -.\BuildFFUVM.ps1 -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -VMSwitchName 'Name of your VM Switch in Hyper-V' -VMHostIPAddress 'Your IP Address' -CreateCaptureMedia $true -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose +.\BuildFFUVM.ps1 -WindowsSKU 'Pro' -Installapps $true -InstallOffice $true -InstallDrivers $true -CreateDeploymentMedia $true -BuildUSBDrive $true -verbose .NOTES Additional notes about your script. @@ -332,16 +344,12 @@ param( [uint64]$Memory = 4GB, [uint64]$Disksize = 50GB, [int]$Processors = 4, + [bool]$EnableVMNetworking, [string]$VMSwitchName, [string]$VMLocation, [string]$FFUPrefix = '_FFU', [string]$FFUCaptureLocation, - [string]$ShareName = "FFUCaptureShare", - [string]$Username = "ffu_user", [string]$CustomFFUNameTemplate, - [Parameter(Mandatory = $false)] - [string]$VMHostIPAddress, - [bool]$CreateCaptureMedia = $true, [bool]$CreateDeploymentMedia, [ValidateScript({ $allowedFeatures = @("Windows-Defender-Default-Definitions", "Printing-PrintToPDFServices-Features", "Printing-XPSServices-Features", "TelnetClient", "TFTP", @@ -423,9 +431,17 @@ param( [bool]$AllowVHDXCaching, [bool]$CopyPPKG, [bool]$CopyUnattend, + [ValidateSet('Legacy', 'None', 'Prompt', 'Template', 'Prefixes', 'SerialComputerNames')] + [string]$DeviceNamingMode = 'Legacy', + [string]$DeviceNameTemplate, + [string[]]$DeviceNamePrefixes, + [string]$DeviceNamePrefixesPath, + [string[]]$DeviceNameSerialComputerNames, + [string]$DeviceNameSerialComputerNamesPath, + [string]$UnattendX64FilePath, + [string]$UnattendArm64FilePath, [bool]$CopyAutopilot, [bool]$CompactOS = $true, - [bool]$CleanupCaptureISO = $true, [bool]$CleanupDeployISO = $true, [bool]$CleanupAppsISO = $true, [bool]$RemoveUpdates = $true, @@ -464,7 +480,7 @@ param( [switch]$Cleanup ) $ProgressPreference = 'SilentlyContinue' -$version = '2603.2' +$version = '2604.1' # Remove any existing modules to avoid conflicts if (Get-Module -Name 'FFU.Common.Core' -ErrorAction SilentlyContinue) { @@ -522,6 +538,275 @@ if ($ConfigFile -and (Test-Path -Path $ConfigFile)) { } } +function Get-UnattendSourcePath { + param( + [Parameter(Mandatory = $true)] + [string]$UnattendFolder, + [Parameter(Mandatory = $true)] + [string]$WindowsArch, + [string]$UnattendX64FilePath, + [string]$UnattendArm64FilePath + ) + + $resolvedArch = if ($WindowsArch -ieq 'arm64') { 'arm64' } else { 'x64' } + $resolvedSourcePath = if ($resolvedArch -eq 'arm64') { + if ([string]::IsNullOrWhiteSpace($UnattendArm64FilePath)) { + Join-Path $UnattendFolder 'unattend_arm64.xml' + } + else { + $UnattendArm64FilePath + } + } + else { + if ([string]::IsNullOrWhiteSpace($UnattendX64FilePath)) { + Join-Path $UnattendFolder 'unattend_x64.xml' + } + else { + $UnattendX64FilePath + } + } + + WriteLog "Resolved unattend source path for ${resolvedArch}: $resolvedSourcePath" + return $resolvedSourcePath +} + +function Initialize-UnattendComputerNamePath { + param( + [Parameter(Mandatory = $true)] + [xml]$UnattendXml, + [Parameter(Mandatory = $true)] + [string]$WindowsArch + ) + + $unattendRoot = $UnattendXml.DocumentElement + if (($null -eq $unattendRoot) -or ($unattendRoot.LocalName -ne 'unattend')) { + throw 'Unattend XML is missing the unattend root element.' + } + + $unattendNamespace = $unattendRoot.NamespaceURI + if ([string]::IsNullOrWhiteSpace($unattendNamespace)) { + throw 'Unattend XML is missing the default unattend namespace.' + } + + $namespaceManager = New-Object System.Xml.XmlNamespaceManager($UnattendXml.NameTable) + $namespaceManager.AddNamespace('un', $unattendNamespace) + + $specializeSettings = $unattendRoot.SelectSingleNode("un:settings[@pass='specialize']", $namespaceManager) + $createdSpecializeSettings = $false + if ($null -eq $specializeSettings) { + $specializeSettings = $UnattendXml.CreateElement('settings', $unattendNamespace) + $null = $specializeSettings.SetAttribute('pass', 'specialize') + $firstSettingsNode = $unattendRoot.SelectSingleNode('un:settings', $namespaceManager) + if ($null -ne $firstSettingsNode) { + $null = $unattendRoot.InsertBefore($specializeSettings, $firstSettingsNode) + } + else { + $null = $unattendRoot.AppendChild($specializeSettings) + } + $createdSpecializeSettings = $true + } + + $shellSetupComponent = $specializeSettings.SelectSingleNode("un:component[@name='Microsoft-Windows-Shell-Setup']", $namespaceManager) + $createdShellSetupComponent = $false + if ($null -eq $shellSetupComponent) { + $processorArchitecture = if ($WindowsArch -ieq 'arm64') { 'arm64' } else { 'amd64' } + $shellSetupComponent = $UnattendXml.CreateElement('component', $unattendNamespace) + $null = $shellSetupComponent.SetAttribute('name', 'Microsoft-Windows-Shell-Setup') + $null = $shellSetupComponent.SetAttribute('processorArchitecture', $processorArchitecture) + $null = $shellSetupComponent.SetAttribute('publicKeyToken', '31bf3856ad364e35') + $null = $shellSetupComponent.SetAttribute('language', 'neutral') + $null = $shellSetupComponent.SetAttribute('versionScope', 'nonSxS') + $null = $shellSetupComponent.SetAttribute('xmlns:wcm', 'http://www.w3.org/2000/xmlns/', 'http://schemas.microsoft.com/WMIConfig/2002/State') + $null = $shellSetupComponent.SetAttribute('xmlns:xsi', 'http://www.w3.org/2000/xmlns/', 'http://www.w3.org/2001/XMLSchema-instance') + + $firstComponentNode = $specializeSettings.SelectSingleNode('un:component', $namespaceManager) + if ($null -ne $firstComponentNode) { + $null = $specializeSettings.InsertBefore($shellSetupComponent, $firstComponentNode) + } + else { + $null = $specializeSettings.AppendChild($shellSetupComponent) + } + $createdShellSetupComponent = $true + } + + $computerNameElement = $shellSetupComponent.SelectSingleNode('un:ComputerName', $namespaceManager) + $createdComputerNameElement = $false + if ($null -eq $computerNameElement) { + $computerNameElement = $UnattendXml.CreateElement('ComputerName', $unattendNamespace) + $null = $shellSetupComponent.AppendChild($computerNameElement) + $createdComputerNameElement = $true + } + + return [PSCustomObject]@{ + ComputerNameElement = $computerNameElement + CreatedSpecializeSettings = $createdSpecializeSettings + CreatedShellSetupComponent = $createdShellSetupComponent + CreatedComputerNameElement = $createdComputerNameElement + } +} + +function Save-StagedUnattendFile { + param( + [Parameter(Mandatory = $true)] + [string]$SourcePath, + [Parameter(Mandatory = $true)] + [string]$DestinationPath, + [Parameter(Mandatory = $true)] + [ValidateSet('Legacy', 'None', 'Prompt', 'Template', 'Prefixes', 'SerialComputerNames')] + [string]$DeviceNamingMode, + [string]$DeviceNameTemplate, + [Parameter(Mandatory = $true)] + [string]$WindowsArch, + [bool]$LegacyPrefixesWillBeStaged = $false + ) + + if ($DeviceNamingMode -eq 'None') { + Copy-Item -Path $SourcePath -Destination $DestinationPath -Force | Out-Null + return + } + + [xml]$unattendXml = Get-Content -Path $SourcePath + $computerNamePath = Initialize-UnattendComputerNamePath -UnattendXml $unattendXml -WindowsArch $WindowsArch + + if ($computerNamePath.CreatedSpecializeSettings -or $computerNamePath.CreatedShellSetupComponent -or $computerNamePath.CreatedComputerNameElement) { + $createdParts = @() + if ($computerNamePath.CreatedSpecializeSettings) { + $createdParts += 'specialize settings' + } + if ($computerNamePath.CreatedShellSetupComponent) { + $createdParts += 'Microsoft-Windows-Shell-Setup component' + } + if ($computerNamePath.CreatedComputerNameElement) { + $createdParts += 'ComputerName element' + } + WriteLog "Created $($createdParts -join ', ') while staging unattend file $DestinationPath" + } + + if ($DeviceNamingMode -eq 'Prompt') { + $computerNamePath.ComputerNameElement.InnerText = 'MyComputer' + } + elseif ($DeviceNamingMode -eq 'Template') { + $computerNamePath.ComputerNameElement.InnerText = $DeviceNameTemplate + } + elseif ($DeviceNamingMode -eq 'Prefixes') { + if ($computerNamePath.CreatedComputerNameElement) { + $computerNamePath.ComputerNameElement.InnerText = '*' + } + } + elseif ($DeviceNamingMode -eq 'SerialComputerNames') { + if ($computerNamePath.CreatedComputerNameElement) { + $computerNamePath.ComputerNameElement.InnerText = '*' + } + } + elseif (($DeviceNamingMode -eq 'Legacy') -and $computerNamePath.CreatedComputerNameElement) { + $computerNamePath.ComputerNameElement.InnerText = if ($LegacyPrefixesWillBeStaged) { '*' } else { 'MyComputer' } + } + + $unattendXml.Save($DestinationPath) +} + +$vmSwitchWasExplicitlyBound = $PSBoundParameters.ContainsKey('VMSwitchName') +$enableVmNetworkingWasExplicitlyBound = $PSBoundParameters.ContainsKey('EnableVMNetworking') +if (-not $EnableVMNetworking -and $vmSwitchWasExplicitlyBound -and -not $enableVmNetworkingWasExplicitlyBound) { + $EnableVMNetworking = $true + WriteLog 'EnableVMNetworking not explicitly set. Enabling VM networking because -VMSwitchName was supplied on the command line.' +} + +$normalizedDeviceNameTemplate = if ($null -ne $DeviceNameTemplate) { $DeviceNameTemplate.Trim() } else { $null } +$effectiveDeviceNamePrefixes = @($DeviceNamePrefixes | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() }) +$resolvedDeviceNamePrefixesPath = if ([string]::IsNullOrWhiteSpace($DeviceNamePrefixesPath)) { + Join-Path (Join-Path $FFUDevelopmentPath 'Unattend') 'prefixes.txt' +} +else { + $DeviceNamePrefixesPath +} +$effectiveDeviceNameSerialComputerNames = @($DeviceNameSerialComputerNames | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() }) +$resolvedDeviceNameSerialComputerNamesPath = if ([string]::IsNullOrWhiteSpace($DeviceNameSerialComputerNamesPath)) { + Join-Path (Join-Path $FFUDevelopmentPath 'Unattend') 'SerialComputerNames.csv' +} +else { + $DeviceNameSerialComputerNamesPath +} + +if (($DeviceNamingMode -eq 'Prefixes') -and ($effectiveDeviceNamePrefixes.Count -eq 0) -and (Test-Path -Path $resolvedDeviceNamePrefixesPath -PathType Leaf)) { + $effectiveDeviceNamePrefixes = @(Get-Content -Path $resolvedDeviceNamePrefixesPath | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() }) + WriteLog "Loaded device name prefixes from $resolvedDeviceNamePrefixesPath" +} + +if (($DeviceNamingMode -eq 'SerialComputerNames') -and ($effectiveDeviceNameSerialComputerNames.Count -eq 0) -and (Test-Path -Path $resolvedDeviceNameSerialComputerNamesPath -PathType Leaf)) { + $effectiveDeviceNameSerialComputerNames = @(Get-Content -Path $resolvedDeviceNameSerialComputerNamesPath | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() }) + WriteLog "Loaded serial computer-name mappings from $resolvedDeviceNameSerialComputerNamesPath" +} + +if ($CopyUnattend -and $InjectUnattend) { + throw 'CopyUnattend and InjectUnattend cannot both be set to `$true. Select only one unattend delivery method.' +} + +if ($DeviceNamingMode -eq 'Template') { + if ([string]::IsNullOrWhiteSpace($normalizedDeviceNameTemplate)) { + throw 'DeviceNamingMode Template requires DeviceNameTemplate.' + } + + $templateWithoutSupportedVariables = $normalizedDeviceNameTemplate -replace '(?i)%serial%', '' + if ($templateWithoutSupportedVariables -match '%') { + throw 'Only the %serial% device name variable is supported.' + } + + if (-not ($CopyUnattend -or $InjectUnattend)) { + throw 'DeviceNamingMode Template requires either CopyUnattend or InjectUnattend.' + } + + if ($InjectUnattend -and (-not $CopyUnattend) -and $normalizedDeviceNameTemplate -match '(?i)%serial%') { + throw 'The %serial% device name variable is only supported when CopyUnattend is used.' + } +} +elseif ($DeviceNamingMode -eq 'Prompt') { + if (-not $CopyUnattend) { + throw 'DeviceNamingMode Prompt requires CopyUnattend. Prompt-based naming is not supported with InjectUnattend.' + } +} +elseif ($DeviceNamingMode -eq 'Prefixes') { + if (-not $CopyUnattend) { + throw 'DeviceNamingMode Prefixes requires CopyUnattend. Prefix-based naming is not supported with InjectUnattend.' + } + + if ($effectiveDeviceNamePrefixes.Count -eq 0) { + throw 'DeviceNamingMode Prefixes requires at least one DeviceNamePrefixes entry or a valid DeviceNamePrefixesPath.' + } +} +elseif ($DeviceNamingMode -eq 'SerialComputerNames') { + if (-not $CopyUnattend) { + throw 'DeviceNamingMode SerialComputerNames requires CopyUnattend. Serial-to-computer-name mapping is not supported with InjectUnattend.' + } + + if ($effectiveDeviceNameSerialComputerNames.Count -eq 0) { + throw 'DeviceNamingMode SerialComputerNames requires DeviceNameSerialComputerNames content or a valid DeviceNameSerialComputerNamesPath.' + } + + try { + $serialComputerNameMappings = @($effectiveDeviceNameSerialComputerNames | ConvertFrom-Csv -ErrorAction Stop) + } + catch { + throw "DeviceNamingMode SerialComputerNames requires valid CSV content with SerialNumber and ComputerName headers. $($_.Exception.Message)" + } + + if ($serialComputerNameMappings.Count -eq 0) { + throw 'DeviceNamingMode SerialComputerNames requires at least one CSV data row.' + } + + $serialComputerNameHeaders = @($serialComputerNameMappings[0].PSObject.Properties.Name) + if ((-not ($serialComputerNameHeaders -contains 'SerialNumber')) -or (-not ($serialComputerNameHeaders -contains 'ComputerName'))) { + throw 'DeviceNamingMode SerialComputerNames requires SerialNumber and ComputerName headers.' + } + + $validSerialComputerNameMappings = @($serialComputerNameMappings | Where-Object { + -not [string]::IsNullOrWhiteSpace([string]$_.SerialNumber) -and -not [string]::IsNullOrWhiteSpace([string]$_.ComputerName) + }) + if ($validSerialComputerNameMappings.Count -eq 0) { + throw 'DeviceNamingMode SerialComputerNames requires at least one row with both SerialNumber and ComputerName values.' + } +} + # Validate that the selected Windows SKU is compatible with the chosen Windows release and ensure an ISO is provided for unsupported releases $clientSKUs = @( 'Home', @@ -608,6 +893,55 @@ public static extern uint GetPrivateProfileSection( '@ Add-Type -MemberDefinition $definition -Namespace Win32 -Name Kernel32 -PassThru | Out-Null +function Get-WindowsTargetRuntimeState { + param( + [Parameter(Mandatory = $true)] + [int]$WindowsRelease, + + [Parameter(Mandatory = $true)] + [string]$WindowsSKU, + + [Parameter(Mandatory = $true)] + [string]$CurrentWindowsVersion, + + [Parameter(Mandatory = $true)] + [bool]$UpdateLatestCU + ) + + $localInstallationType = if ($WindowsSKU -like 'Standard*' -or $WindowsSKU -like 'Datacenter*') { 'Server' } else { 'Client' } + $localWindowsVersion = $CurrentWindowsVersion + $localIsLTSC = $false + + if ($localInstallationType -eq 'Server') { + switch ($WindowsRelease) { + 2016 { $localWindowsVersion = '1607' } + 2019 { $localWindowsVersion = '1809' } + 2022 { $localWindowsVersion = '21H2' } + 2025 { $localWindowsVersion = '24H2' } + } + } + + if ($WindowsSKU -like '*LTS*') { + switch ($WindowsRelease) { + 2016 { $localWindowsVersion = '1607' } + 2019 { $localWindowsVersion = '1809' } + 2021 { $localWindowsVersion = '21H2' } + 2024 { $localWindowsVersion = '24H2' } + } + $localIsLTSC = $true + } + + $localIsWindows10LtscClient = ($localInstallationType -eq 'Client') -and ($WindowsRelease -in 2016, 2019, 2021) -and $localIsLTSC + + return [pscustomobject]@{ + InstallationType = $localInstallationType + WindowsVersion = $localWindowsVersion + IsLTSC = $localIsLTSC + IsWindows10LtscClient = $localIsWindows10LtscClient + InstallLatestCuInVm = ($UpdateLatestCU -and $localIsWindows10LtscClient) + } +} + #Check if Hyper-V feature is installed (requires only checks the module) $osInfo = Get-CimInstance -ClassName win32_OperatingSystem $isServer = $osInfo.Caption -match 'server' @@ -633,6 +967,7 @@ if (-not $AppsPath) { $AppsPath = "$FFUDevelopmentPath\Apps" } if (-not $AppListPath) { $AppListPath = "$AppsPath\AppList.json" } if (-not $UserAppListPath) { $UserAppListPath = "$AppsPath\UserAppList.json" } if (-not $OrchestrationPath) { $OrchestrationPath = "$AppsPath\Orchestration" } +if (-not $appInstallConfigPath) { $appInstallConfigPath = "$OrchestrationPath\AppInstallConfig.json" } if (-not $wingetWin32jsonFile) { $wingetWin32jsonFile = "$OrchestrationPath\WinGetWin32Apps.json" } if (-not $InstallOfficePath) { $InstallOfficePath = "$OrchestrationPath\Install-Office.ps1" } if (-not $InstallDefenderPath) { $InstallDefenderPath = "$OrchestrationPath\Update-Defender.ps1" } @@ -646,7 +981,6 @@ if (-not $LtscCUStagePath) { $LtscCUStagePath = "$AppsPath\LTSCUpdate" } if (-not $AppsScriptVarsJsonPath) { $AppsScriptVarsJsonPath = "$OrchestrationPath\AppsScriptVariables.json" } if (-not $DeployISO) { $DeployISO = "$FFUDevelopmentPath\WinPE_FFU_Deploy_$WindowsArch.iso" } -if (-not $CaptureISO) { $CaptureISO = "$FFUDevelopmentPath\WinPE_FFU_Capture_$WindowsArch.iso" } if (-not $OfficePath) { $OfficePath = "$AppsPath\Office" } if (-not $OfficeDownloadXML) { $OfficeDownloadXML = "$OfficePath\DownloadFFU.xml" } if (-not $OfficeInstallXML) { $OfficeInstallXML = "DeployFFU.xml" } @@ -666,34 +1000,18 @@ if (-not $EdgePath) { $EdgePath = "$AppsPath\Edge" } if (-not $DriversFolder) { $DriversFolder = "$FFUDevelopmentPath\Drivers" } if (-not $PPKGFolder) { $PPKGFolder = "$FFUDevelopmentPath\PPKG" } if (-not $UnattendFolder) { $UnattendFolder = "$FFUDevelopmentPath\Unattend" } +if ([string]::IsNullOrWhiteSpace($UnattendX64FilePath)) { $UnattendX64FilePath = Join-Path $UnattendFolder 'unattend_x64.xml' } +if ([string]::IsNullOrWhiteSpace($UnattendArm64FilePath)) { $UnattendArm64FilePath = Join-Path $UnattendFolder 'unattend_arm64.xml' } if (-not $AutopilotFolder) { $AutopilotFolder = "$FFUDevelopmentPath\Autopilot" } if (-not $PEDriversFolder) { $PEDriversFolder = "$FFUDevelopmentPath\PEDrivers" } if (-not $VHDXCacheFolder) { $VHDXCacheFolder = "$FFUDevelopmentPath\VHDXCache" } -if (-not $installationType) { $installationType = if ($WindowsSKU -like "Standard*" -or $WindowsSKU -like "Datacenter*") { 'Server' } else { 'Client' } } -if ($installationType -eq 'Server') { - #Map $WindowsRelease to $WindowsVersion for Windows Server - switch ($WindowsRelease) { - 2016 { $WindowsVersion = '1607' } - 2019 { $WindowsVersion = '1809' } - 2022 { $WindowsVersion = '21H2' } - 2025 { $WindowsVersion = '24H2' } - } -} if (-not $AppListPath) { $AppListPath = "$AppsPath\AppList.json" } - -if ($WindowsSKU -like "*LTS*") { - switch ($WindowsRelease) { - 2016 { $WindowsVersion = '1607' } - 2019 { $WindowsVersion = '1809' } - 2021 { $WindowsVersion = '21H2' } - 2024 { $WindowsVersion = '24H2' } - } - $isLTSC = $true -} - -# Determine runtime LTSC CU handling flags -$isWindows10LtscClient = ($installationType -eq 'Client') -and ($WindowsRelease -in 2016, 2019, 2021) -and ($WindowsSKU -like '*LTS*') -$installLatestCuInVm = ($UpdateLatestCU -and $isWindows10LtscClient) +$windowsTargetRuntimeState = Get-WindowsTargetRuntimeState -WindowsRelease $WindowsRelease -WindowsSKU $WindowsSKU -CurrentWindowsVersion $WindowsVersion -UpdateLatestCU:$UpdateLatestCU +$installationType = $windowsTargetRuntimeState.InstallationType +$WindowsVersion = $windowsTargetRuntimeState.WindowsVersion +$isLTSC = $windowsTargetRuntimeState.IsLTSC +$isWindows10LtscClient = $windowsTargetRuntimeState.IsWindows10LtscClient +$installLatestCuInVm = $windowsTargetRuntimeState.InstallLatestCuInVm $refreshAppsIsoForLtscCu = $false # Set the log path for the common logger @@ -815,78 +1133,11 @@ function Get-MicrosoftDrivers { [int]$WindowsRelease ) - $url = "https://support.microsoft.com/en-us/surface/download-drivers-and-firmware-for-surface-09bb2e09-2a4b-cb69-0951-078a7739e120" - - ### DOWNLOAD DRIVER PAGE CONTENT - WriteLog "Getting Surface driver information from $url" - $OriginalVerbosePreference = $VerbosePreference - $VerbosePreference = 'SilentlyContinue' - $webContent = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $Headers -UserAgent $UserAgent - $VerbosePreference = $OriginalVerbosePreference + # Use the shared Learn-based Microsoft model index so the CLI stays aligned with the UI. + WriteLog "Getting Surface driver information from the shared Microsoft model index" + $models = @(Get-SurfaceDriverModelIndex -DriversFolder $DriversFolder | Select-Object Model, Link) WriteLog "Complete" - - ### PARSE THE DRIVER PAGE CONTENT FOR MODELS AND DOWNLOAD LINKS - WriteLog "Parsing web content for models and download links" - $html = $webContent.Content - - # Regex to match divs with selectable-content-options__option-content classes - $divPattern = '
]*>)?(.*?)(?:
)?\s*and
tags if present - # $modelName = $modelName -replace ']*>', '' -replace '
', '' - # $modelName = $modelName.Trim() - - - # The second TD might contain a link or just text - $secondTdContent = $cellMatches[1].Groups[1].Value.Trim() - - # Look for a link in the second TD - $linkPattern = ']+href="([^"]+)"[^>]*>' - $linkMatch = [regex]::Match($secondTdContent, $linkPattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) - - if ($linkMatch.Success) { - $modelLink = $linkMatch.Groups[1].Value - } - else { - # No link, just text instructions - $modelLink = $secondTdContent - } - - $models += [PSCustomObject]@{ Model = $modelName; Link = $modelLink } - } - } - } - } - - WriteLog "Parsing complete" + WriteLog "Loaded $($models.Count) Surface models from the shared Microsoft model index." ### FIND THE MODEL IN THE LIST OF MODELS $selectedModel = $models | Where-Object { $_.Model -eq $Model } @@ -1271,10 +1522,11 @@ function Get-LenovoDrivers { # If anyone knows how to reliably get the the model and machine type information from Lenovo, let me know. # https://download.lenovo.com/cdrt/td/catalogv2.xml only provides a subset of the information available from PSREF (e.g. it's missing 300w, 500w, and other consumer models). - # $lenovoCookie = "X-PSREF-USER-TOKEN=eyJ0eXAiOiJKV1QifQ.bjVTdWk0YklZeUc2WnFzL0lXU0pTeU1JcFo0aExzRXl1UGxHN3lnS1BtckI0ZVU5WEJyVGkvaFE0NmVNU2U1ZjNrK3ZqTEVIZ29nTk1TNS9DQmIwQ0pTN1Q1VytlY1RpNzZTUldXbm4wZ1g2RGJuQWg4MXRkTmxKT2YrOW9LRjBzQUZzV05HM3NpcU92WFVTM0o0blM1SDQyUlVXNThIV1VBS2R0c1B2NjJyQjIrUGxNZ2x6RTRhUjY5UDZWclBX.ZDBmM2EyMWRjZTg2N2JmYWMxZDIxY2NiYjQzMWFhNjg1YjEzZTAxNmU2M2RmN2M5ZjIyZWJhMzZkOWI1OWJhZg" + $lenovoCookie = "X-PSREF-USER-TOKEN=eyJ0eXAiOiJKV1QifQ.bjVTdWk0YklZeUc2WnFzL0lXU0pTeU1JcFo0aExzRXl1UGxHN3lnS1BtckI0ZVU5WEJyVGkvaFE0NmVNU2U1ZjNrK3ZqTEVIZ29nTk1TNS9DQmIwQ0pTN1Q1VytlY1RpNzZTUldXbm4wZ1g2RGJuQWg4MXRkTmxKT2YrOW9LRjBzQUZzV05HM3NpcU92WFVTM0o0blM1SDQyUlVXNThIV1VBS2R0c1B2NjJyQjIrUGxNZ2x6RTRhUjY5UDZWclBX.ZDBmM2EyMWRjZTg2N2JmYWMxZDIxY2NiYjQzMWFhNjg1YjEzZTAxNmU2M2RmN2M5ZjIyZWJhMzZkOWI1OWJhZg" # Wrote a separate function to grab the token. Check the function notes for more details. Keep the above comment for now to see if the cookie ever changes. - $lenovoCookie = Get-LenovoPSREFToken + # 3/25/2026 - The cookie is still the same after 8 months, but we'll keep the retrieval function in case it changes in the future or if we need to get a new one. + # $lenovoCookie = Get-LenovoPSREFToken # Add the cookie to the headers $Headers["Cookie"] = $lenovoCookie @@ -2437,6 +2689,54 @@ function Save-KB { return $fileName } +function Sync-UserAppListForOrchestration { + param( + [Parameter(Mandatory)] + [string]$SourcePath, + [Parameter(Mandatory)] + [string]$AppsPath, + [Parameter(Mandatory)] + [string]$OrchestrationPath, + [Parameter(Mandatory)] + [string]$AppInstallConfigPath + ) + + # Ensure the orchestration folder exists before writing runtime configuration. + if (-not (Test-Path -Path $OrchestrationPath -PathType Container)) { + New-Item -Path $OrchestrationPath -ItemType Directory -Force | Out-Null + } + + # Persist the runtime BYO app list path so Orchestrator can honor custom file names. + $appInstallConfig = [ordered]@{ + UserAppListPath = $null + } + + if (-not [string]::IsNullOrWhiteSpace($SourcePath) -and (Test-Path -Path $SourcePath -PathType Leaf)) { + $stagedUserAppListName = Split-Path -Path $SourcePath -Leaf + $stagedUserAppListPath = Join-Path -Path $AppsPath -ChildPath $stagedUserAppListName + + if (-not [string]::Equals([System.IO.Path]::GetFullPath($SourcePath), [System.IO.Path]::GetFullPath($stagedUserAppListPath), [System.StringComparison]::OrdinalIgnoreCase)) { + Copy-Item -Path $SourcePath -Destination $stagedUserAppListPath -Force | Out-Null + WriteLog "Staged BYO app list for orchestration: $SourcePath -> $stagedUserAppListPath" + } + else { + WriteLog "Using BYO app list already staged at $stagedUserAppListPath" + } + + $appInstallConfig.UserAppListPath = "D:\$stagedUserAppListName" + } + elseif (Test-Path -Path (Join-Path -Path $AppsPath -ChildPath 'UserAppList.json') -PathType Leaf) { + $appInstallConfig.UserAppListPath = "D:\UserAppList.json" + WriteLog "Using default BYO app list path for orchestration." + } + else { + WriteLog "No BYO app list found at configured path '$SourcePath'." + } + + $appInstallConfig | ConvertTo-Json | Set-Content -Path $AppInstallConfigPath -Encoding UTF8 + WriteLog "Wrote app install config to $AppInstallConfigPath" +} + function New-AppsISO { #Create Apps ISO file $OSCDIMG = "$adkpath`Assessment and Deployment Kit\Deployment Tools\amd64\Oscdimg\oscdimg.exe" @@ -2462,13 +2762,76 @@ function Get-WimFromISO { return $wimPath } -function Get-Index { +function Get-ResolvedWindowsSKUFromImage { + param( + [Parameter(Mandatory = $true)] + [string]$EditionId, + + [string]$InstallationType, + + [string]$ImageName, + + [Parameter(Mandatory = $true)] + [int]$WindowsRelease + ) + + $normalizedInstallationType = if ([string]::IsNullOrWhiteSpace($InstallationType)) { '' } else { $InstallationType.Trim() } + + switch ($EditionId) { + 'Core' { return 'Home' } + 'CoreN' { return 'Home N' } + 'CoreSingleLanguage' { return 'Home Single Language' } + 'Education' { return 'Education' } + 'EducationN' { return 'Education N' } + 'Professional' { return 'Pro' } + 'ProfessionalN' { return 'Pro N' } + 'ProfessionalEducation' { return 'Pro Education' } + 'ProfessionalEducationN' { return 'Pro Education N' } + 'ProfessionalWorkstation' { return 'Pro for Workstations' } + 'ProfessionalWorkstationN' { return 'Pro N for Workstations' } + 'Enterprise' { return 'Enterprise' } + 'EnterpriseN' { return 'Enterprise N' } + 'EnterpriseS' { + if ($WindowsRelease -eq 2016 -or $ImageName -match 'LTSB') { + return 'Enterprise 2016 LTSB' + } + return 'Enterprise LTSC' + } + 'EnterpriseSN' { + if ($WindowsRelease -eq 2016 -or $ImageName -match 'LTSB') { + return 'Enterprise N 2016 LTSB' + } + return 'Enterprise N LTSC' + } + 'IoTEnterpriseS' { return 'IoT Enterprise LTSC' } + 'IoTEnterpriseSN' { return 'IoT Enterprise N LTSC' } + 'ServerStandard' { + if ($normalizedInstallationType -eq 'Server') { + return 'Standard (Desktop Experience)' + } + return 'Standard' + } + 'ServerDatacenter' { + if ($normalizedInstallationType -eq 'Server') { + return 'Datacenter (Desktop Experience)' + } + return 'Datacenter' + } + } + + return $null +} + +function Get-WindowsImageSelection { param( [Parameter(Mandatory = $true)] [string]$WindowsImagePath, [Parameter(Mandatory = $true)] - [string]$WindowsSKU + [string]$WindowsSKU, + + [Parameter(Mandatory = $true)] + [int]$WindowsRelease ) # Get the available indexes in the WIM/ESD @@ -2553,25 +2916,26 @@ function Get-Index { } } + # Build per-index metadata (EditionId, InstallationType, resolved SKU) once for deterministic matching and fallback prompts. + $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 + ResolvedWindowsSKU = Get-ResolvedWindowsSKUFromImage -EditionId $details.EditionId -InstallationType $details.InstallationType -ImageName $details.ImageName -WindowsRelease $WindowsRelease + } + } + catch { + $null + } + }) | Where-Object { $null -ne $_ } + # 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 } @@ -2586,14 +2950,17 @@ function Get-Index { # 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 + WriteLog "Selected Windows image index $($bestMatch.ImageIndex) (RequestedSKU='$WindowsSKU', ResolvedSKU='$($bestMatch.ResolvedWindowsSKU)', EditionId='$($bestMatch.EditionId)', InstallationType='$($bestMatch.InstallationType)'): $($bestMatch.ImageName)" + return $bestMatch } } # Final fallback: prompt the user to select an 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 = @($imageMetadata | Where-Object { $_.ImageName -match "(10|11|2016|2019|202\d)" }) + if ($relevantImageIndexes.Count -eq 0) { + $relevantImageIndexes = $imageMetadata + } WriteLog "No matching image index found for SKU '$WindowsSKU' in '$WindowsImagePath'. Prompting user to select an ImageName." @@ -2614,8 +2981,8 @@ function Get-Index { $selectedImage = $relevantImageIndexes[$inputValue - 1] if ($selectedImage) { - WriteLog "User selected Windows image index $($selectedImage.ImageIndex) (SKU='$WindowsSKU'): $($selectedImage.ImageName)" - return $selectedImage.ImageIndex + WriteLog "User selected Windows image index $($selectedImage.ImageIndex) (RequestedSKU='$WindowsSKU', ResolvedSKU='$($selectedImage.ResolvedWindowsSKU)'): $($selectedImage.ImageName)" + return $selectedImage } else { Write-Host "Invalid selection, please try again." @@ -2840,6 +3207,27 @@ function New-FFUVM { $VM = New-VM -Name $VMName -Path $VMPath -MemoryStartupBytes $memory -VHDPath $VHDXPath -Generation 2 Set-VMProcessor -VMName $VMName -Count $processors + # Connect the VM to the requested switch only when the experimental networking flag is enabled. + if ($EnableVMNetworking) { + $primaryVmNetworkAdapter = Get-VMNetworkAdapter -VMName $VMName -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($null -ne $primaryVmNetworkAdapter) { + if ($primaryVmNetworkAdapter.SwitchName -eq $VMSwitchName) { + WriteLog "VM '$VMName' is already connected to Hyper-V switch '$VMSwitchName'." + } + else { + Connect-VMNetworkAdapter -VMNetworkAdapter $primaryVmNetworkAdapter -SwitchName $VMSwitchName -ErrorAction Stop + WriteLog "Connected VM '$VMName' to Hyper-V switch '$VMSwitchName'." + } + } + else { + Add-VMNetworkAdapter -VMName $VMName -SwitchName $VMSwitchName -Name 'FFUNetworkAdapter' -ErrorAction Stop | Out-Null + WriteLog "Added VM network adapter for '$VMName' on Hyper-V switch '$VMSwitchName'." + } + } + else { + WriteLog "VM networking is disabled for '$VMName'." + } + #Mount AppsISO Add-VMDvdDrive -VMName $VMName -Path $AppsISO @@ -2865,69 +3253,6 @@ function New-FFUVM { return $VM } -Function Set-CaptureFFU { - $CaptureFFUScriptPath = "$FFUDevelopmentPath\WinPECaptureFFUFiles\CaptureFFU.ps1" - - # Workaround for PowerShell 7 issue on Windows 11 23H2 and earlier - # https://github.com/PowerShell/PowerShell/issues/21645 - $osBuild = (Get-CimInstance -ClassName Win32_OperatingSystem).BuildNumber - if ($osBuild -le 22631) { - WriteLog "Applying workaround for PowerShell 7 LocalAccounts module issue on Windows 11 build $osBuild" - Import-Module Microsoft.PowerShell.LocalAccounts -UseWindowsPowerShell - } - - If (-not (Test-Path -Path $FFUCaptureLocation)) { - WriteLog "Creating FFU capture location at $FFUCaptureLocation" - New-Item -Path $FFUCaptureLocation -ItemType Directory -Force - WriteLog "Successfully created FFU capture location at $FFUCaptureLocation" - } - - # Create a standard user - $UserExists = Get-LocalUser -Name $UserName -ErrorAction SilentlyContinue - if (-not $UserExists) { - WriteLog "Creating FFU_User account as standard user" - New-LocalUser -Name $UserName -AccountNeverExpires -NoPassword | Out-null - WriteLog "Successfully created FFU_User account" - } - - # Create a random password for the standard user - $Password = New-Guid | Select-Object -ExpandProperty Guid - $SecurePassword = ConvertTo-SecureString -String $Password -AsPlainText -Force - Set-LocalUser -Name $UserName -Password $SecurePassword -PasswordNeverExpires:$true - - # Create a share of the $FFUCaptureLocation variable - $ShareExists = Get-SmbShare -Name $ShareName -ErrorAction SilentlyContinue - if (-not $ShareExists) { - WriteLog "Creating $ShareName and giving access to $UserName" - New-SmbShare -Name $ShareName -Path $FFUCaptureLocation -FullAccess $UserName | Out-Null - WriteLog "Share created" - } - - # Return the share path in the format of \\]*>)?(.*?)(?:
)?\s*