From bcb9911cd0d427586a0a33277ec64a4c8dff839e Mon Sep 17 00:00:00 2001 From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com> Date: Wed, 7 Aug 2024 13:01:26 -0700 Subject: [PATCH 01/14] Small update to fix a logging issue with script run time duration --- FFUDevelopment/BuildFFUVM.ps1 | 5 +++-- FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1 index da4434e..96d5779 100644 --- a/FFUDevelopment/BuildFFUVM.ps1 +++ b/FFUDevelopment/BuildFFUVM.ps1 @@ -336,7 +336,7 @@ param( [bool]$AllowExternalHardDiskMedia, [bool]$PromptExternalHardDiskMedia = $true ) -$version = '2408.1' +$version = '2408.2' #Check if Hyper-V feature is installed (requires only checks the module) $osInfo = Get-WmiObject -Class Win32_OperatingSystem @@ -4133,4 +4133,5 @@ $runTimeFormatted = 'Duration: {0:mm} min {0:ss} sec' -f $runTime if ($VerbosePreference -ne 'Continue'){ Write-Host $runTimeFormatted } -WriteLog 'Script complete: ' + $runTimeFormatted +WriteLog 'Script complete' +WriteLog $runTimeFormatted diff --git a/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 b/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 index 7240239..7c9f19f 100644 --- a/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 +++ b/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 @@ -128,7 +128,7 @@ $LogFileName = 'ScriptLog.txt' $USBDrive = Get-USBDrive New-item -Path $USBDrive -Name $LogFileName -ItemType "file" -Force | Out-Null $LogFile = $USBDrive + $LogFilename -$version = '2408.1' +$version = '2408.2' WriteLog 'Begin Logging' WriteLog "Script version: $version" From db3e09650ac1ddfd3c231cb1cd58b515a616b28f Mon Sep 17 00:00:00 2001 From: Doctair Date: Mon, 12 Aug 2024 10:07:41 -0400 Subject: [PATCH 02/14] Add new Parameter for Installing Preview CU from Mircosoft Update Catalog. Recent Windows Pro not Auto Activating to Enterprise License Bug speared this idea as its resoleve in lastes Prieview CU. Parameter is default $False but if set to $true will install Preivew CU and take precendence over $UpdateLastestCU. --- FFUDevelopment/BuildFFUVM - Copy.ps1 | 4137 ++++++++++++++++++++++++++ FFUDevelopment/BuildFFUVM.ps1 | 24 +- 2 files changed, 4158 insertions(+), 3 deletions(-) create mode 100644 FFUDevelopment/BuildFFUVM - Copy.ps1 diff --git a/FFUDevelopment/BuildFFUVM - Copy.ps1 b/FFUDevelopment/BuildFFUVM - Copy.ps1 new file mode 100644 index 0000000..96d5779 --- /dev/null +++ b/FFUDevelopment/BuildFFUVM - Copy.ps1 @@ -0,0 +1,4137 @@ + +#Requires -Modules Hyper-V, Storage +#Requires -PSEdition Desktop +#Requires -RunAsAdministrator + +<# +.SYNOPSIS +A PowerShell script to create a Windows 10/11 FFU file. + +.DESCRIPTION +This script creates a Windows 10/11 FFU and USB drive to help quickly get a Windows device reimaged. FFU can be customized with drivers, apps, and additional settings. + +.PARAMETER ISOPath +Path to the Windows 10/11 ISO file. + +.PARAMETER WindowsSKU +Edition of Windows 10/11 to be installed, e.g., accepted values are: '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' + +.PARAMETER FFUDevelopmentPath +Path to the FFU development folder (default is C:\FFUDevelopment). + +.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. No VM is created. + +.PARAMETER InstallOffice +Install Microsoft Office if set to $true. The script will download the latest ODT and Office files in the $FFUDevelopmentPath\Apps\Office folder and install Office in the FFU via VM + +.PARAMETER InstallDrivers +Install device drivers from the specified $FFUDevelopmentPath\Drivers folder if set to $true. Download the drivers and put them in the Drivers folder. The script will recurse the drivers folder and add the drivers to the FFU. + +.PARAMETER Memory +Amount of memory to allocate for the virtual machine. Recommended to use 8GB if possible, especially for Windows 11. Use 4GB if necesary. + +.PARAMETER Disksize +Size of the virtual hard disk for the virtual machine. Default is a 30GB dynamic disk. + +.PARAMETER Processors +Number of virtual processors for the virtual machine. Recommended to use at least 4. + +.PARAMETER VMSwitchName +Name of the Hyper-V virtual switch. If $InstallApps is set to $true, this must be set. This is required to capture the FFU from the VM. The default is *external*, but you will likely need to change this. + +.PARAMETER VMLocation +Default is $FFUDevelopmentPath\VM. This is the location of the VHDX that gets created where Windows will be installed to. + +.PARAMETER FFUPrefix +Prefix for the generated FFU file. Default is _FFU + +.PARAMETER FFUCaptureLocation +Path to the folder where the captured FFU will be stored. Default is $FFUDevelopmentPath\FFU + +.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 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. The script will not auto detect your IP (depending on your network adapters, it may not find the correct IP). + +.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 CreateDeploymentMedia +When set to $true, this will create WinPE deployment media for use when deploying to a physical device. + +.PARAMETER OptionalFeatures +Provide a semi-colon separated list of Windows optional features you want to include in the FFU (e.g. netfx3;TFTP) + +.PARAMETER ProductKey +Product key for the Windows 10/11 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. + +.PARAMETER BuildUSBDrive +When set to $true, will partition and format a USB drive and copy the captured FFU to the drive. If you'd like to customize the drive to add drivers, provisioning packages, name prefix, etc. You'll need to do that afterward. + +.PARAMETER WindowsRelease +Integer value of 10 or 11. This is used to identify which release of Windows to download. Default is 11. + +.PARAMETER WindowsVersion +String value of the Windows version to download. This is used to identify which version of Windows to download. Default is 23h2. + +.PARAMETER WindowsArch +String value of x86 or x64. This is used to identify which architecture of Windows to download. Default is x64. + +.PARAMETER WindowsLang +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. + +.PARAMETER MediaType +String value of either business or consumer. This is used to identify which media type to download. Default is consumer. + +.PARAMETER LogicalSectorBytes +unit32 value of 512 or 4096. Not recommended to change from 512. Might be useful for 4kn drives, but needs more testing. Default is 512. + +.PARAMETER Optimize +When set to $true, will optimize the FFU file. Default is $true. + +.PARAMETER CopyDrivers +When set to $true, will copy the drivers from the $FFUDevelopmentPath\Drivers folder to the Drivers folder on the deploy partition of the USB drive. Default is $false. + +.PARAMETER CopyPEDrivers +When set to $true, will copy the drivers from the $FFUDevelopmentPath\PEDrivers folder to the WinPE deployment media. Default is $false. + +.PARAMETER RemoveFFU +When set to $true, will remove the FFU file from the $FFUDevelopmentPath\FFU folder after it has been copied to the USB drive. Default is $false. + +.PARAMETER UpdateLatestCU +When set to $true, will download and install the latest cumulative update for Windows 10/11. Default is $false. + +.PARAMETER UpdateLatestNet +When set to $true, will download and install the latest .NET Framework for Windows 10/11. Default is $false. + +.PARAMETER UpdateLatestDefender +When set to $true, will download and install the latest Windows Defender definitions and Defender platform update. Default is $false. + +.PARAMETER UpdateEdge +When set to $true, will download and install the latest Microsoft Edge for Windows 10/11. Default is $false. + +.PARAMETER UpdateOneDrive +When set to $true, will download and install the latest OneDrive for Windows 10/11 and install it as a per machine installation instead of per user. Default is $false. + +.PARAMETER CopyPPKG +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. + +.PARAMETER CopyAutopilot +When set to $true, will copy the $FFUDevelopmentPath\Autopilot folder to the Deployment partition of the USB drive. Default is $false. + +.PARAMETER CompactOS +When set to $true, will compact the OS when building the FFU. 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 CleanupDeployISO +When set to $true, will remove the WinPE deployment ISO after the FFU has been captured. Default is $true. + +.PARAMETER CleanupAppsISO +When set to $true, will remove the Apps ISO after the FFU has been captured. Default is $true. + +.PARAMETER DriversFolder +Path to the drivers folder. Default is $FFUDevelopmentPath\Drivers. + +.PARAMETER CleanupDrivers +When set to $true, will remove the drivers folder after the FFU has been captured. Default is $true. + +.PARAMETER UserAgent +User agent string to use when downloading files. + +.PARAMETER Headers +Headers to use when downloading files. + +.PARAMETER AllowExternalHardDiskMedia +When set to $true, will allow the use of media identified as External Hard Disk media via WMI class Win32_DiskDrive. Default is not defined. + +.PARAMETER PromptExternalHardDiskMedia +When set to $true, will prompt the user to confirm the use of media identified as External Hard Disk media via WMI class Win32_DiskDrive. Default is $true. + +.PARAMETER Make +Make of the device to download drivers. Accepted values are: 'Microsoft', 'Dell', 'HP', 'Lenovo' + +.PARAMETER Model +Model of the device to download drivers. This is required if Make is set. + +.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 + +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 + +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 + +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 + +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 + +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 + +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 + +.NOTES + Additional notes about your script. + +.LINK + https://github.com/rbalsleyMSFT/FFU +#> + + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false, Position = 0)] + [ValidateScript({ Test-Path $_ })] + [string]$ISOPath, + [ValidateScript({ + $allowedSKUs = @('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') + if ($allowedSKUs -contains $_) { $true } else { throw "Invalid WindowsSKU value. Allowed values: $($allowedSKUs -join ', ')" } + return $true + })] + [string]$WindowsSKU = 'Pro', + [ValidateScript({ Test-Path $_ })] + [string]$FFUDevelopmentPath = $PSScriptRoot, + [bool]$InstallApps, + [bool]$InstallOffice, + [ValidateSet('Microsoft', 'Dell', 'HP', 'Lenovo')] + [string]$Make, + [string]$Model, + [Parameter(Mandatory = $false)] + [ValidateScript({ + if ($Make) { + return $true + } + if ($_ -and (!(Test-Path -Path '.\Drivers') -or ((Get-ChildItem -Path '.\Drivers' -Recurse | Measure-Object -Property Length -Sum).Sum -lt 1MB))) { + throw 'InstallDrivers is set to $true, but either the Drivers folder is missing or empty' + } + return $true + })] + [bool]$InstallDrivers, + [uint64]$Memory = 4GB, + [uint64]$Disksize = 30GB, + [int]$Processors = 4, + [string]$VMSwitchName, + [string]$VMLocation, + [string]$FFUPrefix = '_FFU', + [string]$FFUCaptureLocation, + [String]$ShareName = "FFUCaptureShare", + [string]$Username = "ffu_user", + [Parameter(Mandatory = $false)] + [string]$VMHostIPAddress, + [bool]$CreateCaptureMedia = $true, + [bool]$CreateDeploymentMedia, + [ValidateScript({ + $allowedFeatures = @("Windows-Defender-Default-Definitions", "Printing-PrintToPDFServices-Features", "Printing-XPSServices-Features", "TelnetClient", "TFTP", + "TIFFIFilter", "LegacyComponents", "DirectPlay", "MSRDC-Infrastructure", "Windows-Identity-Foundation", "MicrosoftWindowsPowerShellV2Root", "MicrosoftWindowsPowerShellV2", + "SimpleTCP", "NetFx4-AdvSrvs", "NetFx4Extended-ASPNET45", "WCF-Services45", "WCF-HTTP-Activation45", "WCF-TCP-Activation45", "WCF-Pipe-Activation45", "WCF-MSMQ-Activation45", + "WCF-TCP-PortSharing45", "IIS-WebServerRole", "IIS-WebServer", "IIS-CommonHttpFeatures", "IIS-HttpErrors", "IIS-HttpRedirect", "IIS-ApplicationDevelopment", "IIS-Security", + "IIS-RequestFiltering", "IIS-NetFxExtensibility", "IIS-NetFxExtensibility45", "IIS-HealthAndDiagnostics", "IIS-HttpLogging", "IIS-LoggingLibraries", "IIS-RequestMonitor", + "IIS-HttpTracing", "IIS-URLAuthorization", "IIS-IPSecurity", "IIS-Performance", "IIS-HttpCompressionDynamic", "IIS-WebServerManagementTools", "IIS-ManagementScriptingTools", + "IIS-IIS6ManagementCompatibility", "IIS-Metabase", "WAS-WindowsActivationService", "WAS-ProcessModel", "WAS-NetFxEnvironment", "WAS-ConfigurationAPI", "IIS-HostableWebCore", + "WCF-HTTP-Activation", "WCF-NonHTTP-Activation", "IIS-StaticContent", "IIS-DefaultDocument", "IIS-DirectoryBrowsing", "IIS-WebDAV", "IIS-WebSockets", "IIS-ApplicationInit", + "IIS-ISAPIFilter", "IIS-ISAPIExtensions", "IIS-ASPNET", "IIS-ASPNET45", "IIS-ASP", "IIS-CGI", "IIS-ServerSideIncludes", "IIS-CustomLogging", "IIS-BasicAuthentication", + "IIS-HttpCompressionStatic", "IIS-ManagementConsole", "IIS-ManagementService", "IIS-WMICompatibility", "IIS-LegacyScripts", "IIS-LegacySnapIn", "IIS-FTPServer", "IIS-FTPSvc", + "IIS-FTPExtensibility", "MSMQ-Container", "MSMQ-DCOMProxy", "MSMQ-Server", "MSMQ-ADIntegration", "MSMQ-HTTP", "MSMQ-Multicast", "MSMQ-Triggers", "IIS-CertProvider", + "IIS-WindowsAuthentication", "IIS-DigestAuthentication", "IIS-ClientCertificateMappingAuthentication", "IIS-IISCertificateMappingAuthentication", "IIS-ODBCLogging", + "NetFx3", "SMB1Protocol-Deprecation", "MediaPlayback", "WindowsMediaPlayer", "Client-DeviceLockdown", "Client-EmbeddedShellLauncher", "Client-EmbeddedBootExp", + "Client-EmbeddedLogon", "Client-KeyboardFilter", "Client-UnifiedWriteFilter", "HostGuardian", "MultiPoint-Connector", "MultiPoint-Connector-Services", "MultiPoint-Tools" + , "AppServerClient", "SearchEngine-Client-Package", "WorkFolders-Client", "Printing-Foundation-Features", "Printing-Foundation-InternetPrinting-Client", + "Printing-Foundation-LPDPrintService", "Printing-Foundation-LPRPortMonitor", "HypervisorPlatform", "VirtualMachinePlatform", "Microsoft-Windows-Subsystem-Linux", + "Client-ProjFS", "Containers-DisposableClientVM", 'Containers-DisposableClientVM', 'Microsoft-Hyper-V-All', 'Microsoft-Hyper-V', 'Microsoft-Hyper-V-Tools-All', + 'Microsoft-Hyper-V-Management-PowerShell', 'Microsoft-Hyper-V-Hypervisor', 'Microsoft-Hyper-V-Services', 'Microsoft-Hyper-V-Management-Clients', 'DataCenterBridging', + 'DirectoryServices-ADAM-Client', 'Windows-Defender-ApplicationGuard', 'ServicesForNFS-ClientOnly', 'ClientForNFS-Infrastructure', 'NFS-Administration', 'Containers', 'Containers-HNS', + 'Containers-SDN', 'SMB1Protocol', 'SMB1Protocol-Client', 'SMB1Protocol-Server', 'SmbDirect') + $inputFeatures = $_ -split ';' + foreach ($feature in $inputFeatures) { + if (-not ($allowedFeatures -contains $feature)) { + throw "Invalid optional feature '$feature'. Allowed values: $($allowedFeatures -join ', ')" + } + } + return $true + })] + [string]$OptionalFeatures, + [string]$ProductKey, + [bool]$BuildUSBDrive, + [Parameter(Mandatory = $false)] + [ValidateSet(10, 11)] + [int]$WindowsRelease = 11, + [Parameter(Mandatory = $false)] + [string]$WindowsVersion = '23h2', + [Parameter(Mandatory = $false)] + [ValidateSet('x86', 'x64', 'arm64')] + [string]$WindowsArch = 'x64', + [ValidateScript({ + $allowedLang = @('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') + if ($allowedLang -contains $_) { $true } else { throw "Invalid WindowsLang value. Allowed values: $($allowedLang -join ', ')" } + return $true + })] + [Parameter(Mandatory = $false)] + [string]$WindowsLang = 'en-us', + [Parameter(Mandatory = $false)] + [ValidateSet('consumer', 'business')] + [string]$MediaType = 'consumer', + [ValidateSet(512, 4096)] + [uint32]$LogicalSectorSizeBytes = 512, + [bool]$Optimize = $true, + [Parameter(Mandatory = $false)] + [ValidateScript({ + if ($Make) { + return $true + } + if ($_ -and (!(Test-Path -Path '.\Drivers') -or ((Get-ChildItem -Path '.\Drivers' -Recurse | Measure-Object -Property Length -Sum).Sum -lt 1MB))) { + throw 'CopyDrivers is set to $true, but either the Drivers folder is missing or empty' + } + return $true + })] + [bool]$CopyDrivers, + [bool]$CopyPEDrivers, + [bool]$RemoveFFU, + [bool]$UpdateLatestCU, + [bool]$UpdateLatestNet, + [bool]$UpdateLatestDefender, + [bool]$UpdateEdge, + [bool]$UpdateOneDrive, + [bool]$CopyPPKG, + [bool]$CopyUnattend, + [bool]$CopyAutopilot, + [bool]$CompactOS = $true, + [bool]$CleanupCaptureISO = $true, + [bool]$CleanupDeployISO = $true, + [bool]$CleanupAppsISO = $true, + [string]$DriversFolder, + [bool]$CleanupDrivers = $true, + [string]$UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.0', + #Microsoft sites will intermittently fail on downloads. These headers are to help with that. + $Headers = @{ + "Accept" = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7" + "Accept-Encoding" = "gzip, deflate, br, zstd" + "Accept-Language" = "en-US,en;q=0.9" + "Priority" = "u=0, i" + "Sec-Ch-Ua" = "`"Microsoft Edge`";v=`"125`", `"Chromium`";v=`"125`", `"Not.A/Brand`";v=`"24`"" + "Sec-Ch-Ua-Mobile" = "?0" + "Sec-Ch-Ua-Platform" = "`"Windows`"" + "Sec-Fetch-Dest" = "document" + "Sec-Fetch-Mode" = "navigate" + "Sec-Fetch-Site" = "none" + "Sec-Fetch-User" = "?1" + "Upgrade-Insecure-Requests" = "1" + }, + [bool]$AllowExternalHardDiskMedia, + [bool]$PromptExternalHardDiskMedia = $true +) +$version = '2408.2' + +#Check if Hyper-V feature is installed (requires only checks the module) +$osInfo = Get-WmiObject -Class Win32_OperatingSystem +$isServer = $osInfo.Caption -match 'server' + +if ($isServer) { + $hyperVFeature = Get-WindowsFeature -Name Hyper-V + if ($hyperVFeature.InstallState -ne "Installed") { + Write-Host "Hyper-V feature is not installed. Please install it before running this script." + exit + } +} +else { + $hyperVFeature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All + if ($hyperVFeature.State -ne "Enabled") { + Write-Host "Hyper-V feature is not enabled. Please enable it before running this script." + exit + } +} + +# Set default values for variables that depend on other parameters +if (-not $AppsISO) { $AppsISO = "$FFUDevelopmentPath\Apps.iso" } +if (-not $AppsPath) { $AppsPath = "$FFUDevelopmentPath\Apps" } +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 $rand) { $rand = Get-Random } +if (-not $VMLocation) { $VMLocation = "$FFUDevelopmentPath\VM" } +if (-not $VMName) { $VMName = "$FFUPrefix-$rand" } +if (-not $VMPath) { $VMPath = "$VMLocation\$VMName" } +if (-not $VHDXPath) { $VHDXPath = "$VMPath\$VMName.vhdx" } +if (-not $FFUCaptureLocation) { $FFUCaptureLocation = "$FFUDevelopmentPath\FFU" } +if (-not $LogFile) { $LogFile = "$FFUDevelopmentPath\FFUDevelopment.log" } +if (-not $KBPath) { $KBPath = "$FFUDevelopmentPath\KB" } +if (-not $DefenderPath) { $DefenderPath = "$AppsPath\Defender" } +if (-not $OneDrivePath) { $OneDrivePath = "$AppsPath\OneDrive" } +if (-not $EdgePath) { $EdgePath = "$AppsPath\Edge" } +if (-not $DriversFolder) { $DriversFolder = "$FFUDevelopmentPath\Drivers" } + +#FUNCTIONS +function WriteLog($LogText) { + Add-Content -path $LogFile -value "$((Get-Date).ToString()) $LogText" -Force -ErrorAction SilentlyContinue + Write-Verbose $LogText +} + +function LogVariableValues { + $excludedVariables = @( + 'PSBoundParameters', + 'PSScriptRoot', + 'PSCommandPath', + 'MyInvocation', + '?', + 'ConsoleFileName', + 'ExecutionContext', + 'false', + 'HOME', + 'Host', + 'hyperVFeature', + 'input', + 'MaximumAliasCount', + 'MaximumDriveCount', + 'MaximumErrorCount', + 'MaximumFunctionCount', + 'MaximumVariableCount', + 'null', + 'PID', + 'PSCmdlet', + 'PSCulture', + 'PSUICulture', + 'PSVersionTable', + 'ShellId', + 'true' + ) + + $allVariables = Get-Variable -Scope Script | Where-Object { $_.Name -notin $excludedVariables } + Writelog "Script version: $version" + WriteLog 'Logging variables' + foreach ($variable in $allVariables) { + $variableName = $variable.Name + $variableValue = $variable.Value + if ($null -ne $variableValue) { + WriteLog "[VAR]$variableName`: $variableValue" + } + else { + WriteLog "[VAR]Variable $variableName not found or not set" + } + } + WriteLog 'End logging variables' +} + +function Invoke-Process { + [CmdletBinding(SupportsShouldProcess)] + param + ( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$FilePath, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string]$ArgumentList + ) + + $ErrorActionPreference = 'Stop' + + try { + $stdOutTempFile = "$env:TEMP\$((New-Guid).Guid)" + $stdErrTempFile = "$env:TEMP\$((New-Guid).Guid)" + + $startProcessParams = @{ + FilePath = $FilePath + ArgumentList = $ArgumentList + RedirectStandardError = $stdErrTempFile + RedirectStandardOutput = $stdOutTempFile + Wait = $true; + PassThru = $true; + NoNewWindow = $true; + } + if ($PSCmdlet.ShouldProcess("Process [$($FilePath)]", "Run with args: [$($ArgumentList)]")) { + $cmd = Start-Process @startProcessParams + $cmdOutput = Get-Content -Path $stdOutTempFile -Raw + $cmdError = Get-Content -Path $stdErrTempFile -Raw + if ($cmd.ExitCode -ne 0) { + if ($cmdError) { + throw $cmdError.Trim() + } + if ($cmdOutput) { + throw $cmdOutput.Trim() + } + } + else { + if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) { + WriteLog $cmdOutput + } + } + } + } + catch { + #$PSCmdlet.ThrowTerminatingError($_) + WriteLog $_ + Write-Host "Script failed - $Logfile for more info" + throw $_ + + } + finally { + Remove-Item -Path $stdOutTempFile, $stdErrTempFile -Force -ErrorAction Ignore + + } + +} + +function Test-Url { + param ( + [Parameter(Mandatory = $true)] + [string]$Url + ) + try { + # Create a web request and check the response + $request = [System.Net.WebRequest]::Create($Url) + $request.Method = 'HEAD' + $response = $request.GetResponse() + return $true + } + catch { + return $false + } +} + +# Function to download a file using BITS with retry and error handling +function Start-BitsTransferWithRetry { + param ( + [Parameter(Mandatory = $true)] + [string]$Source, + [Parameter(Mandatory = $true)] + [string]$Destination, + [int]$Retries = 3 + ) + + $attempt = 0 + while ($attempt -lt $Retries) { + try { + $OriginalVerbosePreference = $VerbosePreference + $VerbosePreference = 'SilentlyContinue' + $ProgressPreference = 'SilentlyContinue' + Start-BitsTransfer -Source $Source -Destination $Destination -ErrorAction Stop + $ProgressPreference = 'Continue' + $VerbosePreference = $OriginalVerbosePreference + return + } + catch { + $attempt++ + WriteLog "Attempt $attempt of $Retries failed to download $Source. Retrying..." + Start-Sleep -Seconds 5 + } + } + WriteLog "Failed to download $Source after $Retries attempts." + return $false +} + +function Get-MicrosoftDrivers { + param ( + [string]$Make, + [string]$Model, + [int]$WindowsRelease + ) + + $url = "https://support.microsoft.com/en-us/surface/download-drivers-and-firmware-for-surface-09bb2e09-2a4b-cb69-0951-078a7739e120" + + # Download the webpage content + WriteLog "Getting Surface driver information from $url" + $OriginalVerbosePreference = $VerbosePreference + $VerbosePreference = 'SilentlyContinue' + $webContent = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $Headers -UserAgent $UserAgent + $VerbosePreference = $OriginalVerbosePreference + WriteLog "Complete" + + # Parse the content of the relevant nested divs + WriteLog "Parsing web content for models and download links" + $html = $webContent.Content + $nestedDivPattern = '
(.*?)
' + $nestedDivMatches = [regex]::Matches($html, $nestedDivPattern, [System.Text.RegularExpressions.RegexOptions]::Singleline) + + $models = @() + $modelPattern = '

(.*?)

\s*\s*\s*

\s*\|]', '_' + WriteLog "Downloading driver: $Name" + $Category = $update.Category + $Category = $Category -replace '[\\\/\:\*\?\"\<\>\|]', '_' + $Version = $update.Version + $Version = $Version -replace '[\\\/\:\*\?\"\<\>\|]', '_' + $DriverUrl = "https://$($update.URL)" + WriteLog "Driver URL: $DriverUrl" + $DriverFileName = [System.IO.Path]::GetFileName($DriverUrl) + $downloadFolder = "$DriversFolder\$ProductName\$Category" + $DriverFilePath = Join-Path -Path $downloadFolder -ChildPath $DriverFileName + + if (Test-Path -Path $DriverFilePath) { + WriteLog "Driver already downloaded: $DriverFilePath, skipping" + continue + } + + if (-not (Test-Path -Path $downloadFolder)) { + WriteLog "Creating download folder: $downloadFolder" + New-Item -Path $downloadFolder -ItemType Directory -Force | Out-Null + WriteLog "Download folder created" + } + + # Download the driver with retry + WriteLog "Downloading driver to: $DriverFilePath" + Start-BitsTransferWithRetry -Source $DriverUrl -Destination $DriverFilePath + WriteLog 'Driver downloaded' + + # Make folder for extraction + $extractFolder = "$downloadFolder\$Name\$Version\" + $DriverFileName.TrimEnd('.exe') + Writelog "Creating extraction folder: $extractFolder" + New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null + WriteLog 'Extraction folder created' + + # Extract the driver + $arguments = "/s /e /f `"$extractFolder`"" + WriteLog "Extracting driver" + Invoke-Process -FilePath $DriverFilePath -ArgumentList $arguments + WriteLog "Driver extracted to: $extractFolder" + + # Delete the .exe driver file after extraction + Remove-Item -Path $DriverFilePath -Force + WriteLog "Driver installation file deleted: $DriverFilePath" + } + # Clean up the downloaded cab and xml files + Remove-Item -Path $DriverCabFile, $DriverXmlFile, $PlatformListCab, $PlatformListXml -Force + WriteLog "Driver cab and xml files deleted" +} +function Get-LenovoDrivers { + param ( + [Parameter()] + [string]$Model, + [Parameter()] + [ValidateSet("x64", "x86", "ARM64")] + [string]$WindowsArch, + [Parameter()] + [ValidateSet(10, 11)] + [int]$WindowsRelease + ) + + function Get-LenovoPSREF { + param ( + [string]$ModelName + ) + + $url = "https://psref.lenovo.com/api/search/DefinitionFilterAndSearch/Suggest?kw=$ModelName" + WriteLog "Querying Lenovo PSREF API for model: $ModelName" + $OriginalVerbosePreference = $VerbosePreference + $VerbosePreference = 'SilentlyContinue' + $response = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $Headers -UserAgent $UserAgent + $VerbosePreference = $OriginalVerbosePreference + WriteLog "Complete" + + $jsonResponse = $response.Content | ConvertFrom-Json + + $products = @() + foreach ($item in $jsonResponse.data) { + $productName = $item.ProductName + $machineTypes = $item.MachineType -split " / " + + foreach ($machineType in $machineTypes) { + if ($machineType -eq $ModelName) { + WriteLog "Model name entered is a matching machine type" + $products = @() + $products += [pscustomobject]@{ + ProductName = $productName + MachineType = $machineType + } + WriteLog "Product Name: $productName Machine Type: $machineType" + return $products + } + $products += [pscustomobject]@{ + ProductName = $productName + MachineType = $machineType + } + } + } + + return ,$products + } + + # Parse the Lenovo PSREF page for the model + $machineTypes = Get-LenovoPSREF -ModelName $Model + if ($machineTypes.ProductName.Count -eq 0) { + WriteLog "No machine types found for model: $Model" + WriteLog "Enter a valid model or machine type in the -model parameter" + exit + } elseif ($machineTypes.ProductName.Count -eq 1) { + $machineType = $machineTypes[0].MachineType + $model = $machineTypes[0].ProductName + } else { + if ($VerbosePreference -ne 'Continue'){ + Write-Output "Multiple machine types found for model: $Model" + } + WriteLog "Multiple machine types found for model: $Model" + for ($i = 0; $i -lt $machineTypes.ProductName.Count; $i++) { + if ($VerbosePreference -ne 'Continue'){ + Write-Output "$($i + 1). $($machineTypes[$i].ProductName) ($($machineTypes[$i].MachineType))" + } + WriteLog "$($i + 1). $($machineTypes[$i].ProductName) ($($machineTypes[$i].MachineType))" + } + $selection = Read-Host "Enter the number of the model you want to select" + $machineType = $machineTypes[$selection - 1].MachineType + WriteLog "Selected machine type: $machineType" + $model = $machineTypes[$selection - 1].ProductName + WriteLog "Selected model: $model" + } + + + # Construct the catalog URL based on Windows release and machine type + $ModelRelease = $machineType + "_Win" + $WindowsRelease + $CatalogUrl = "https://download.lenovo.com/catalog/$ModelRelease.xml" + WriteLog "Lenovo Driver catalog URL: $CatalogUrl" + + if (-not (Test-Url -Url $catalogUrl)) { + Write-Error "Lenovo Driver catalog URL is not accessible: $catalogUrl" + WriteLog "Lenovo Driver catalog URL is not accessible: $catalogUrl" + exit + } + + # Create the folder structure for the Lenovo drivers + $driversFolder = "$DriversFolder\$Make" + if (-not (Test-Path -Path $DriversFolder)) { + WriteLog "Creating Drivers folder: $DriversFolder" + New-Item -Path $DriversFolder -ItemType Directory -Force | Out-Null + WriteLog "Drivers folder created" + } + + # Download and parse the Lenovo catalog XML + $LenovoCatalogXML = "$DriversFolder\$ModelRelease.xml" + WriteLog "Downloading $catalogUrl to $LenovoCatalogXML" + Start-BitsTransferWithRetry -Source $catalogUrl -Destination $LenovoCatalogXML + WriteLog "Download Complete" + $xmlContent = [xml](Get-Content -Path $LenovoCatalogXML) + + WriteLog "Parsing Lenovo catalog XML" + # Process each package in the catalog + foreach ($package in $xmlContent.packages.package) { + $packageUrl = $package.location + $category = $package.category + + #If category starts with BIOS, skip the package + if ($category -like 'BIOS*') { + continue + } + + #If category name is 'Motherboard Devices Backplanes core chipset onboard video PCIe switches', truncate to 'Motherboard Devices' to shorten path + if ($category -eq 'Motherboard Devices Backplanes core chipset onboard video PCIe switches') { + $category = 'Motherboard Devices' + } + + $packageName = [System.IO.Path]::GetFileName($packageUrl) + #Remove the filename from the $packageURL + $baseURL = $packageUrl -replace $packageName, "" + + # Download the package XML + $packageXMLPath = "$DriversFolder\$packageName" + WriteLog "Downloading $category package XML $packageUrl to $packageXMLPath" + If ((Start-BitsTransferWithRetry -Source $packageUrl -Destination $packageXMLPath) -eq $false) { + Write-Output "Failed to download $category package XML: $packageXMLPath" + WriteLog "Failed to download $category package XML: $packageXMLPath" + continue + } + + # Load the package XML content + $packageXmlContent = [xml](Get-Content -Path $packageXMLPath) + $packageType = $packageXmlContent.Package.PackageType.type + $packageTitle = $packageXmlContent.Package.title.InnerText + + # Fix the name for drivers that contain illegal characters for folder name purposes + $packageTitle = $packageTitle -replace '[\\\/\:\*\?\"\<\>\|]', '_' + + # If ' - ' is in the package title, truncate the title to the first part of the string. + $packageTitle = $packageTitle -replace ' - .*', '' + + #Check if packagetype = 2. If packagetype is not 2, skip the package. $packageType is a System.Xml.XmlElement. + #This filters out Firmware, BIOS, and other non-INF drivers + if ($packageType -ne 2) { + Remove-Item -Path $packageXMLPath -Force + continue + } + + # Extract the driver file name and the extract command + $driverFileName = $packageXmlContent.Package.Files.Installer.File.Name + $extractCommand = $packageXmlContent.Package.ExtractCommand + + #if extract command is empty/missing, skip the package + if (!($extractCommand)) { + Remove-Item -Path $packageXMLPath -Force + continue + } + + # Create the download URL and folder structure + $driverUrl = $baseUrl + $driverFileName + $downloadFolder = "$DriversFolder\$Model\$Category\$packageTitle" + $driverFilePath = Join-Path -Path $downloadFolder -ChildPath $driverFileName + + # Check if file has already been downloaded + if (Test-Path -Path $driverFilePath) { + Write-Output "Driver already downloaded: $driverFilePath skipping" + WriteLog "Driver already downloaded: $driverFilePath skipping" + continue + } + + if (-not (Test-Path -Path $downloadFolder)) { + WriteLog "Creating download folder: $downloadFolder" + New-Item -Path $downloadFolder -ItemType Directory -Force | Out-Null + WriteLog "Download folder created" + } + + # Download the driver with retry + WriteLog "Downloading driver: $driverUrl to $driverFilePath" + Start-BitsTransferWithRetry -Source $driverUrl -Destination $driverFilePath + WriteLog "Driver downloaded" + + # Make folder for extraction + $extractFolder = $downloadFolder + "\" + $driverFileName.TrimEnd($driverFileName[-4..-1]) + WriteLog "Creating extract folder: $extractFolder" + New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null + WriteLog "Extract folder created" + + # Modify the extract command + $modifiedExtractCommand = $extractCommand -replace '%PACKAGEPATH%', "`"$extractFolder`"" + + # Extract the driver + # Start-Process -FilePath $driverFilePath -ArgumentList $modifiedExtractCommand -Wait -NoNewWindow + WriteLog "Extracting driver: $driverFilePath to $extractFolder" + Invoke-Process -FilePath $driverFilePath -ArgumentList $modifiedExtractCommand + WriteLog "Driver extracted" + + # Delete the .exe driver file after extraction + WriteLog "Deleting driver installation file: $driverFilePath" + Remove-Item -Path $driverFilePath -Force + WriteLog "Driver installation file deleted: $driverFilePath" + + # Delete the package XML file after extraction + WriteLog "Deleting package XML file: $packageXMLPath" + Remove-Item -Path $packageXMLPath -Force + WriteLog "Package XML file deleted" + } + + #Delete the catalog XML file after processing + WriteLog "Deleting catalog XML file: $LenovoCatalogXML" + Remove-Item -Path $LenovoCatalogXML -Force + WriteLog "Catalog XML file deleted" +} + +function Get-DellDrivers { + param ( + [Parameter(Mandatory = $true)] + [string]$Model, + [Parameter(Mandatory = $true)] + [ValidateSet("x64", "x86", "ARM64")] + [string]$WindowsArch + ) + + $catalogUrl = "http://downloads.dell.com/catalog/CatalogPC.cab" + if (-not (Test-Url -Url $catalogUrl)) { + WriteLog "Dell Catalog cab URL is not accessible: $catalogUrl Exiting" + if ($VerbosePreference -ne 'Continue') { + Write-Host "Dell Catalog cab URL is not accessible: $catalogUrl Exiting" + } + exit + } + + if (-not (Test-Path -Path $DriversFolder)) { + WriteLog "Creating Drivers folder: $DriversFolder" + New-Item -Path $DriversFolder -ItemType Directory -Force | Out-Null + WriteLog "Drivers folder created" + } + + $DriversFolder = "$DriversFolder\$Make" + WriteLog "Creating Dell Drivers folder: $DriversFolder" + New-Item -Path $DriversFolder -ItemType Directory -Force | Out-Null + WriteLog "Dell Drivers folder created" + + $DellCabFile = "$DriversFolder\CatalogPC.cab" + WriteLog "Downloading Dell Catalog cab file: $catalogUrl to $DellCabFile" + Start-BitsTransferWithRetry -Source $catalogUrl -Destination $DellCabFile + WriteLog "Dell Catalog cab file downloaded" + + $DellCatalogXML = "$DriversFolder\CatalogPC.XML" + WriteLog "Extracting Dell Catalog cab file to $DellCatalogXML" + Invoke-Process -FilePath Expand.exe -ArgumentList "$DellCabFile $DellCatalogXML" + WriteLog "Dell Catalog cab file extracted" + + $xmlContent = [xml](Get-Content -Path $DellCatalogXML) + $baseLocation = "https://" + $xmlContent.manifest.baseLocation + "/" + $latestDrivers = @{} + + $softwareComponents = $xmlContent.Manifest.SoftwareComponent | Where-Object { $_.ComponentType.value -eq "DRVR" } + foreach ($component in $softwareComponents) { + $models = $component.SupportedSystems.Brand.Model + foreach ($item in $models) { + if ($item.Display.'#cdata-section' -match $Model) { + $validOS = $component.SupportedOperatingSystems.OperatingSystem | Where-Object { $_.osArch -eq $WindowsArch } + if ($validOS) { + $driverPath = $component.path + $downloadUrl = $baseLocation + $driverPath + $driverFileName = [System.IO.Path]::GetFileName($driverPath) + $name = $component.Name.Display.'#cdata-section' + $name = $name -replace '[\\\/\:\*\?\"\<\>\|]', '_' + $name = $name -replace '[\,]', '-' + $category = $component.Category.Display.'#cdata-section' + $version = [version]$component.vendorVersion + $namePrefix = ($name -split '-')[0] + + # Use hash table to store the latest driver for each category to prevent downloading older driver versions + if ($latestDrivers[$category]) { + if ($latestDrivers[$category][$namePrefix]) { + if ($latestDrivers[$category][$namePrefix].Version -lt $version) { + $latestDrivers[$category][$namePrefix] = [PSCustomObject]@{ + Name = $name; + DownloadUrl = $downloadUrl; + DriverFileName = $driverFileName; + Version = $version; + Category = $category + } + } + } + else { + $latestDrivers[$category][$namePrefix] = [PSCustomObject]@{ + Name = $name; + DownloadUrl = $downloadUrl; + DriverFileName = $driverFileName; + Version = $version; + Category = $category + } + } + } + else { + $latestDrivers[$category] = @{} + $latestDrivers[$category][$namePrefix] = [PSCustomObject]@{ + Name = $name; + DownloadUrl = $downloadUrl; + DriverFileName = $driverFileName; + Version = $version; + Category = $category + } + } + } + } + } + } + + foreach ($category in $latestDrivers.Keys) { + foreach ($driver in $latestDrivers[$category].Values) { + $downloadFolder = "$DriversFolder\$Model\$($driver.Category)" + $driverFilePath = Join-Path -Path $downloadFolder -ChildPath $driver.DriverFileName + + if (Test-Path -Path $driverFilePath) { + Write-Output "Driver already downloaded: $driverFilePath skipping" + continue + } + + WriteLog "Downloading driver: $($driver.Name)" + if (-not (Test-Path -Path $downloadFolder)) { + WriteLog "Creating download folder: $downloadFolder" + New-Item -Path $downloadFolder -ItemType Directory -Force | Out-Null + WriteLog "Download folder created" + } + + WriteLog "Downloading driver: $($driver.DownloadUrl) to $driverFilePath" + try{ + Start-BitsTransferWithRetry -Source $driver.DownloadUrl -Destination $driverFilePath + WriteLog "Driver downloaded" + }catch{ + WriteLog "Failed to download driver: $($driver.DownloadUrl) to $driverFilePath" + continue + } + + + $extractFolder = $downloadFolder + "\" + $driver.DriverFileName.TrimEnd($driver.DriverFileName[-4..-1]) + WriteLog "Creating extraction folder: $extractFolder" + New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null + WriteLog "Extraction folder created" + + $arguments = "/s /e=`"$extractFolder`"" + WriteLog "Extracting driver: $driverFilePath to $extractFolder" + Invoke-Process -FilePath $driverFilePath -ArgumentList $arguments + WriteLog "Driver extracted" + + WriteLog "Deleting driver file: $driverFilePath" + Remove-Item -Path $driverFilePath -Force + WriteLog "Driver file deleted" + } + } +} +function Get-ADKURL { + param ( + [ValidateSet("Windows ADK", "WinPE add-on")] + [string]$ADKOption + ) + + # Define base pattern for URL scraping + $basePattern = '

  • Download the ' + + # Define specific URL patterns based on ADK options + $ADKUrlPattern = @{ + "Windows ADK" = $basePattern + "Windows ADK" + "WinPE add-on" = $basePattern + "Windows PE add-on for the Windows ADK" + }[$ADKOption] + + try { + # Retrieve content of Microsoft documentation page + $OriginalVerbosePreference = $VerbosePreference + $VerbosePreference = 'SilentlyContinue' + $ADKWebPage = Invoke-RestMethod "https://learn.microsoft.com/en-us/windows-hardware/get-started/adk-install" -Headers $Headers -UserAgent $UserAgent + $VerbosePreference = $OriginalVerbosePreference + + # Extract download URL based on specified pattern + $ADKMatch = [regex]::Match($ADKWebPage, $ADKUrlPattern) + + if (-not $ADKMatch.Success) { + WriteLog "Failed to retrieve ADK download URL. Pattern match failed." + return + } + + # Extract FWlink from the matched pattern + $ADKFWLink = $ADKMatch.Groups[1].Value + + if ($null -eq $ADKFWLink) { + WriteLog "FWLink for $ADKOption not found." + return + } + + # Retrieve headers of the FWlink URL + $OriginalVerbosePreference = $VerbosePreference + $VerbosePreference = 'SilentlyContinue' + $FWLinkRequest = Invoke-WebRequest -Uri $ADKFWLink -Method Head -MaximumRedirection 0 -ErrorAction SilentlyContinue + $VerbosePreference = $OriginalVerbosePreference + + if ($FWLinkRequest.StatusCode -ne 302) { + WriteLog "Failed to retrieve ADK download URL. Unexpected status code: $($FWLinkRequest.StatusCode)" + return + } + + # Get the ADK link redirected to by the FWlink + $ADKUrl = $FWLinkRequest.Headers.Location + return $ADKUrl + } + catch { + WriteLog $_ + Write-Error "Error occurred while retrieving ADK download URL" + throw $_ + } +} +function Install-ADK { + param ( + [ValidateSet("Windows ADK", "WinPE add-on")] + [string]$ADKOption + ) + + try { + $ADKUrl = Get-ADKURL -ADKOption $ADKOption + + if ($null -eq $ADKUrl) { + throw "Failed to retrieve URL for $ADKOption. Please manually install it." + } + + # Select the installer based on the ADK option specified + $installer = @{ + "Windows ADK" = "adksetup.exe" + "WinPE add-on" = "adkwinpesetup.exe" + }[$ADKOption] + + # Select the feature based on the ADK option specified + $feature = @{ + "Windows ADK" = "OptionId.DeploymentTools" + "WinPE add-on" = "OptionId.WindowsPreinstallationEnvironment" + }[$ADKOption] + + $installerLocation = Join-Path $env:TEMP $installer + + WriteLog "Downloading $ADKOption from $ADKUrl to $installerLocation" + Start-BitsTransferWithRetry -Source $ADKUrl -Destination $installerLocation -ErrorAction Stop + WriteLog "$ADKOption downloaded to $installerLocation" + + WriteLog "Installing $ADKOption with $feature enabled" + Invoke-Process $installerLocation "/quiet /installpath ""%ProgramFiles(x86)%\Windows Kits\10"" /features $feature" + + WriteLog "$ADKOption installation completed." + WriteLog "Removing $installer from $installerLocation" + # Clean up downloaded installation file + Remove-Item -Path $installerLocation -Force -ErrorAction SilentlyContinue + } + catch { + WriteLog $_ + Write-Error "Error occurred while installing $ADKOption. Please manually install it." + throw $_ + } +} +function Get-InstalledProgramRegKey { + param ( + [string]$DisplayName + ) + + $uninstallRegPath = "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + $uninstallRegKeys = Get-ChildItem -Path $uninstallRegPath -Recurse + + foreach ($regKey in $uninstallRegKeys) { + try { + $regValue = $regKey.GetValue("DisplayName") + if ($regValue -eq $DisplayName) { + return $regKey + } + } + catch { + WriteLog $_ + throw "Error retrieving installed program info for $DisplayName : $_" + } + } +} + +function Uninstall-ADK { + param ( + [ValidateSet("Windows ADK", "WinPE add-on")] + [string]$ADKOption + ) + + # Match name as it appears in the registry + $displayName = switch ($ADKOption) { + "Windows ADK" { "Windows Assessment and Deployment Kit" } + "WinPE add-on" { "Windows Assessment and Deployment Kit Windows Preinstallation Environment Add-ons" } + } + + try { + $adkRegKey = Get-InstalledProgramRegKey -DisplayName $displayName + + if (-not $adkRegKey) { + WriteLog "$ADKOption is not installed." + return + } + + $adkBundleCachePath = $adkRegKey.GetValue("BundleCachePath") + WriteLog "Uninstalling $ADKOption..." + Invoke-Process $adkBundleCachePath "/uninstall /quiet" + WriteLog "$ADKOption uninstalled successfully." + } + catch { + WriteLog $_ + Write-Error "Error occurred while uninstalling $ADKOption. Please manually uninstall it." + throw $_ + } +} + +function Confirm-ADKVersionIsLatest { + param ( + [ValidateSet("Windows ADK", "WinPE add-on")] + [string]$ADKOption + ) + + $displayName = switch ($ADKOption) { + "Windows ADK" { "Windows Assessment and Deployment Kit" } + "WinPE add-on" { "Windows Assessment and Deployment Kit Windows Preinstallation Environment Add-ons" } + } + + try { + $adkRegKey = Get-InstalledProgramRegKey -DisplayName $displayName + + if (-not $adkRegKey) { + return $false + } + + $installedADKVersion = $adkRegKey.GetValue("DisplayVersion") + + # Retrieve content of Microsoft documentation page + $adkWebPage = Invoke-RestMethod "https://learn.microsoft.com/en-us/windows-hardware/get-started/adk-install" -Headers $Headers -UserAgent $UserAgent + # Specify regex pattern for ADK version + $adkVersionPattern = 'ADK\s+(\d+(\.\d+)+)' + # Check for regex pattern match + $adkVersionMatch = [regex]::Match($adkWebPage, $adkVersionPattern) + + if (-not $adkVersionMatch.Success) { + WriteLog "Failed to retrieve latest ADK version from web page." + return $false + } + + # Extract ADK version from the matched pattern + $latestADKVersion = $adkVersionMatch.Groups[1].Value + + if ($installedADKVersion -eq $latestADKVersion) { + WriteLog "Installed $ADKOption version $installedADKVersion is the latest." + return $true + } + else { + WriteLog "Installed $ADKOption version $installedADKVersion is not the latest ($latestADKVersion)" + return $false + } + } + catch { + WriteLog "An error occurred while confirming the ADK version: $_" + return $false + } +} + +function Get-ADK { + # Check if latest ADK and WinPE add-on are installed + $latestADKInstalled = Confirm-ADKVersionIsLatest -ADKOption "Windows ADK" + $latestWinPEInstalled = Confirm-ADKVersionIsLatest -ADKOption "WinPE add-on" + + # Uninstall older versions and install latest versions if necessary + if (-not $latestADKInstalled) { + Uninstall-ADK -ADKOption "Windows ADK" + Install-ADK -ADKOption "Windows ADK" + } + + if (-not $latestWinPEInstalled) { + Uninstall-ADK -ADKOption "WinPE add-on" + Install-ADK -ADKOption "WinPE add-on" + } + + # Define registry path + $adkPathKey = "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows Kits\Installed Roots" + $adkPathName = "KitsRoot10" + + # Check if ADK installation path exists in registry + $adkPathNameExists = (Get-ItemProperty -Path $adkPathKey -Name $adkPathName -ErrorAction SilentlyContinue) + + if ($adkPathNameExists) { + # Get the ADK installation path + WriteLog 'Get ADK Path' + $adkPath = (Get-ItemProperty -Path $adkPathKey -Name $adkPathName).$adkPathName + WriteLog "ADK located at $adkPath" + } + else { + throw "Windows ADK installation path could not be found." + } + + # If ADK was already installed, then check if the Windows Deployment Tools feature is also installed + $deploymentToolsRegKey = Get-InstalledProgramRegKey -DisplayName "Windows Deployment Tools" + + if (-not $deploymentToolsRegKey) { + WriteLog "ADK is installed, but the Windows Deployment Tools feature is not installed." + $adkRegKey = Get-InstalledProgramRegKey -DisplayName "Windows Assessment and Deployment Kit" + $adkBundleCachePath = $adkRegKey.GetValue("BundleCachePath") + if ($adkBundleCachePath) { + WriteLog "Installing Windows Deployment Tools..." + $adkInstallPath = $adkPath.TrimEnd('\') + Invoke-Process $adkBundleCachePath "/quiet /installpath ""$adkInstallPath"" /features OptionId.DeploymentTools" + WriteLog "Windows Deployment Tools installed successfully." + } + else { + throw "Failed to retrieve path to adksetup.exe to install the Windows Deployment Tools. Please manually install it." + } + } + return $adkPath +} +function Get-WindowsESD { + param( + [Parameter(Mandatory = $false)] + [ValidateSet(10, 11)] + [int]$WindowsRelease, + + [Parameter(Mandatory = $false)] + [ValidateSet('x86', 'x64', 'ARM64')] + [string]$WindowsArch, + + [Parameter(Mandatory = $false)] + [string]$WindowsLang, + + [Parameter(Mandatory = $false)] + [ValidateSet('consumer', 'business')] + [string]$MediaType + ) + WriteLog "Downloading Windows $WindowsRelease ESD file" + WriteLog "Windows Architecture: $WindowsArch" + WriteLog "Windows Language: $WindowsLang" + WriteLog "Windows Media Type: $MediaType" + + # Select cab file URL based on Windows Release + $cabFileUrl = if ($WindowsRelease -eq 10) { + 'https://go.microsoft.com/fwlink/?LinkId=841361' + } + else { + 'https://go.microsoft.com/fwlink/?LinkId=2156292' + } + + # Download cab file + WriteLog "Downloading Cab file" + $cabFilePath = Join-Path $PSScriptRoot "tempCabFile.cab" + $OriginalVerbosePreference = $VerbosePreference + $VerbosePreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $cabFileUrl -OutFile $cabFilePath -Headers $Headers -UserAgent $UserAgent + $VerbosePreference = $OriginalVerbosePreference + WriteLog "Download succeeded" + + # Extract XML from cab file + WriteLog "Extracting Products XML from cab" + $xmlFilePath = Join-Path $PSScriptRoot "products.xml" + Invoke-Process Expand "-F:*.xml $cabFilePath $xmlFilePath" + WriteLog "Products XML extracted" + + # Load XML content + [xml]$xmlContent = Get-Content -Path $xmlFilePath + + # Define the client type to look for in the FilePath + $clientType = if ($MediaType -eq 'consumer') { 'CLIENTCONSUMER' } else { 'CLIENTBUSINESS' } + + # Find FilePath values based on WindowsArch, WindowsLang, and MediaType + foreach ($file in $xmlContent.MCT.Catalogs.Catalog.PublishedMedia.Files.File) { + if ($file.Architecture -eq $WindowsArch -and $file.LanguageCode -eq $WindowsLang -and $file.FilePath -like "*$clientType*") { + $esdFilePath = Join-Path $PSScriptRoot (Split-Path $file.FilePath -Leaf) + #Download if ESD file doesn't already exist + If (-not (Test-Path $esdFilePath)) { + #Required to fix slow downloads + $ProgressPreference = 'SilentlyContinue' + WriteLog "Downloading $($file.filePath) to $esdFIlePath" + $OriginalVerbosePreference = $VerbosePreference + $VerbosePreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $file.FilePath -OutFile $esdFilePath -Headers $Headers -UserAgent $UserAgent + $VerbosePreference = $OriginalVerbosePreference + WriteLog "Download succeeded" + #Set back to show progress + $ProgressPreference = 'Continue' + WriteLog "Cleanup cab and xml file" + Remove-Item -Path $cabFilePath -Force + Remove-Item -Path $xmlFilePath -Force + WriteLog "Cleanup done" + } + return $esdFilePath + } + } +} + + + +function Get-ODTURL { + + # [String]$MSWebPage = Invoke-RestMethod 'https://www.microsoft.com/en-us/download/confirmation.aspx?id=49117' + [String]$MSWebPage = Invoke-RestMethod 'https://www.microsoft.com/en-us/download/confirmation.aspx?id=49117' -Headers $Headers -UserAgent $UserAgent + + $MSWebPage | ForEach-Object { + if ($_ -match 'url=(https://.*officedeploymenttool.*\.exe)') { + $matches[1] + } + } +} + +function Get-Office { + #Download ODT + $ODTUrl = Get-ODTURL + $ODTInstallFile = "$env:TEMP\odtsetup.exe" + WriteLog "Downloading Office Deployment Toolkit from $ODTUrl to $ODTInstallFile" + $OriginalVerbosePreference = $VerbosePreference + $VerbosePreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $ODTUrl -OutFile $ODTInstallFile -Headers $Headers -UserAgent $UserAgent + $VerbosePreference = $OriginalVerbosePreference + + # Extract ODT + WriteLog "Extracting ODT to $OfficePath" + Invoke-Process $ODTInstallFile "/extract:$OfficePath /quiet" + + # Run setup.exe with config.xml and modify xml file to download to $OfficePath + $ConfigXml = "$OfficePath\DownloadFFU.xml" + $xmlContent = [xml](Get-Content $ConfigXml) + $xmlContent.Configuration.Add.SourcePath = $OfficePath + $xmlContent.Save($ConfigXml) + WriteLog "Downloading M365 Apps/Office to $OfficePath" + Invoke-Process $OfficePath\setup.exe "/download $ConfigXml" + + WriteLog "Cleaning up ODT default config files and checking InstallAppsandSysprep.cmd file for proper command line" + #Clean up default configuration files + Remove-Item -Path "$OfficePath\configuration*" -Force + + #Read the contents of the InstallAppsandSysprep.cmd file + $content = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" + + #Update the InstallAppsandSysprep.cmd file with the Office install command + $officeCommand = "d:\Office\setup.exe /configure d:\Office\DeployFFU.xml" + + # Check if Office command is not commented out or missing and fix it if it is + if ($content[3] -ne $officeCommand) { + $content[3] = $officeCommand + + # Write the modified content back to the file + Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" -Value $content + } +} + +function Install-WinGet { + param ( + [string]$Architecture + ) + $packages = @( + @{Name = "VCLibs"; Url = "https://aka.ms/Microsoft.VCLibs.$Architecture.14.00.Desktop.appx"; File = "Microsoft.VCLibs.$Architecture.14.00.Desktop.appx"}, + @{Name = "UIXaml"; Url = "https://github.com/microsoft/microsoft-ui-xaml/releases/download/v2.8.6/Microsoft.UI.Xaml.2.8.$Architecture.appx"; File = "Microsoft.UI.Xaml.2.8.$Architecture.appx"}, + @{Name = "WinGet"; Url = "https://aka.ms/getwinget"; File = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle"} + ) + foreach ($package in $packages) { + $destination = Join-Path -Path $env:TEMP -ChildPath $package.File + WriteLog "Downloading $($package.Name) from $($package.Url) to $destination" + Start-BitsTransferWithRetry -Source $package.Url -Destination $destination + WriteLog "Installing $($package.Name)..." + Add-AppxPackage -Path $destination -ErrorAction SilentlyContinue + WriteLog "Removing $($package.Name)..." + Remove-Item -Path $destination -Force -ErrorAction SilentlyContinue + } + WriteLog "WinGet installation complete." +} + +function Confirm-WinGetInstallation { + WriteLog 'Checking if WinGet is installed...' + $wingetPath = "$env:LOCALAPPDATA\Microsoft\WindowsApps\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\winget.exe" + $minVersion = [version]"1.8.1911" + if (-not (Test-Path -Path $wingetPath -PathType Leaf)) { + WriteLog "WinGet is not installed. Downloading WinGet..." + Install-WinGet -Architecture $WindowsArch + return + } + if (-not (Get-Command -Name winget -ErrorAction SilentlyContinue)) { + WriteLog "WinGet not found. Downloading WinGet..." + Install-WinGet -Architecture $WindowsArch + return + } + $wingetVersion = & winget.exe --version + WriteLog "Installed version of WinGet: $wingetVersion" + if ($wingetVersion -match 'v?(\d+\.\d+\.\d+)' -and [version]$matches[1] -lt $minVersion) { + WriteLog "The installed version of WinGet $($matches[1]) does not support downloading MSStore apps. Downloading the latest version of WinGet..." + Install-WinGet -Architecture $WindowsArch + return + } +} + +function Add-Win32SilentInstallCommand { + param ( + [string]$AppFolder, + [string]$AppFolderPath + ) + $appName = $AppFolder + $installerPath = Get-ChildItem -Path "$appFolderPath\*" -Include "*.exe", "*.msi" -File -ErrorAction Stop + if (-not $installerPath) { + WriteLog "No win32 app installers were found. Skipping the inclusion of $AppFolder" + Remove-Item -Path $AppFolderPath -Recurse -Force + return $false + } + $yamlFile = Get-ChildItem -Path "$appFolderPath\*" -Include "*.yaml" -File -ErrorAction Stop + $yamlContent = Get-Content -Path $yamlFile -Raw + $silentInstallSwitch = [regex]::Match($yamlContent, 'Silent:\s*(.+)').Groups[1].Value.Replace("'", "").Trim() + if (-not $silentInstallSwitch) { + WriteLog "Silent install switch for $appName could not be found. Skipping the inclusion of $appName." + Remove-Item -Path $appFolderPath -Recurse -Force + return $false + } + $installer = Split-Path -Path $installerPath -Leaf + if ($installerPath.Extension -eq ".exe") { + $silentInstallCommand = "`"D:\win32\$appFolder\$installer`" $silentInstallSwitch" + } + elseif ($installerPath.Extension -eq ".msi") { + $silentInstallCommand = "msiexec /i `"D:\win32\$appFolder\$installer`" $silentInstallSwitch" + } + $cmdFile = "$AppsPath\InstallAppsandSysprep.cmd" + $cmdContent = Get-Content -Path $cmdFile + $UpdatedcmdContent = $CmdContent -replace '^(REM Winget Win32 Apps)', ("REM Winget Win32 Apps`r`nREM Win32 $($AppName)`r`n$($silentInstallCommand.Trim())") + WriteLog "Writing silent install command for $appName to InstallAppsandSysprep.cmd" + Set-Content -Path $cmdFile -Value $UpdatedcmdContent +} + +function Set-InstallStoreAppsFlag { + $cmdPath = "$AppsPath\InstallAppsandSysprep.cmd" + $cmdContent = Get-Content -Path $cmdPath + if ($cmdContent -match 'set "INSTALL_STOREAPPS=false"') { + WriteLog "Setting INSTALL_STOREAPPS flag to true in InstallAppsandSysprep.cmd file." + $updatedcmdContent = $cmdContent -replace 'set "INSTALL_STOREAPPS=false"', 'set "INSTALL_STOREAPPS=true"' + Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" -Value $updatedcmdContent + } +} + +function Get-WinGetApp { + param ( + [string]$WinGetAppName, + [string]$WinGetAppId + ) + $wingetSearchResult = & winget.exe search --id "$WinGetAppId" --exact --accept-source-agreements --source winget + if ($wingetSearchResult -contains "No package found matching input criteria.") { + WriteLog "$WinGetAppName not found in WinGet repository. Skipping download." + } + $appFolderPath = Join-Path -Path "$AppsPath\Win32" -ChildPath $WinGetAppName + WriteLog "Creating $appFolderPath" + New-Item -Path $appFolderPath -ItemType Directory -Force | Out-Null + WriteLog "Downloading $WinGetAppName to $appFolderPath" + $downloadParams = @( + "download", + "--id", "$WinGetAppId", + "--exact", + "--download-directory", "$appFolderPath", + "--accept-package-agreements", + "--accept-source-agreements", + "--source", "winget", + "--scope", "machine", + "--architecture", "$WindowsArch" + ) + WriteLog "winget command: winget.exe $downloadParams" + $wingetDownloadResult = & winget.exe @downloadParams | Out-String + if ($wingetDownloadResult -match "No applicable installer found") { + WriteLog "No installer found for $WindowsArch architecture. Attempting to download without specifying architecture..." + $downloadParams = $downloadParams | Where-Object { $_ -notmatch "--architecture" -and $_ -notmatch "$WindowsArch" } + $wingetDownloadResult = & winget.exe @downloadParams | Out-String + if ($wingetDownloadResult -match "Installer downloaded") { + WriteLog "Downloaded $WinGetAppName without specifying architecture." + } + } + if ($wingetDownloadResult -notmatch "Installer downloaded") { + WriteLog "No installer found for $WinGetAppName. Skipping download." + Remove-Item -Path $appFolderPath -Recurse -Force + } + WriteLog "$WinGetAppName downloaded to $appFolderPath" + $installerPath = Get-ChildItem -Path "$appFolderPath\*" -Exclude "*.yaml", "*.xml" -File -ErrorAction Stop + $uwpExtensions = @(".appx", ".appxbundle", ".msix", ".msixbundle") + if ($uwpExtensions -contains $installerPath.Extension) { + $NewAppPath = "$AppsPath\MSStore\$WinGetAppName" + Writelog "$WinGetAppName is a UWP app. Moving to $NewAppPath" + WriteLog "Creating $NewAppPath" + New-Item -Path "$AppsPath\MSStore\$WinGetAppName" -ItemType Directory -Force | Out-Null + WriteLog "Moving $WinGetAppName to $NewAppPath" + Move-Item -Path "$appFolderPath\*" -Destination "$AppsPath\MSStore\$WinGetAppName" -Force + WriteLog "Removing $appFolderPath" + Remove-Item -Path $appFolderPath -Force + WriteLog "$WinGetAppName moved to $NewAppPath" + Set-InstallStoreAppsFlag + } + else { + Add-Win32SilentInstallCommand -AppFolder $WinGetAppName -AppFolderPath $appFolderPath + } +} + +function Get-StoreApp { + param ( + [string]$StoreAppName, + [string]$StoreAppId + ) + $wingetSearchResult = & winget.exe search "$StoreAppId" --accept-source-agreements --source msstore + if ($wingetSearchResult -contains "No package found matching input criteria.") { + WriteLog "$StoreAppName not found in WinGet repository. Skipping download." + return + } + WriteLog "Checking if $StoreAppName is a win32 app..." + $appIsWin32 = $StoreAppId.StartsWith("XP") + if ($appIsWin32) { + WriteLog "$StoreAppName is a win32 app. Adding to $AppsPath\win32 folder" + $appFolderPath = Join-Path -Path "$AppsPath\win32" -ChildPath $StoreAppName + } + else { + WriteLog "$StoreAppName is not a win32 app." + $appFolderPath = Join-Path -Path "$AppsPath\MSStore" -ChildPath $StoreAppName + } + New-Item -Path $appFolderPath -ItemType Directory -Force | Out-Null + WriteLog "Downloading $StoreAppName for $WindowsArch architecture..." + $downloadParams = @( + "download", "$StoreAppId", + "--download-directory", "$appFolderPath", + "--accept-package-agreements", + "--accept-source-agreements", + "--source", "msstore", + "--scope", "machine", + "--architecture", "$WindowsArch" + ) + WriteLog 'MSStore app downloads require authentication with an Entra ID account. You may be prompted twice for credentials, once for the app and another for the license file.' + WriteLog "Attempting to download $StoreAppName and dependencies for $WindowsArch architecture..." + $wingetDownloadResult = & winget.exe @downloadParams | Out-String + # For some apps, specifying the architecture leads to no results found for the app. In those cases, the command will be run without the architecture parameter. + if ($wingetDownloadResult -match "No applicable installer found") { + WriteLog "No installer found for $WindowsArch architecture. Attempting to download without specifying architecture..." + $downloadParams = $downloadParams | Where-Object { $_ -notmatch "--architecture" -and $_ -notmatch "$WindowsArch" } + $wingetDownloadResult = & winget.exe @downloadParams | Out-String + if ($wingetDownloadResult -match "Microsoft Store package download completed") { + WriteLog "Downloaded $StoreAppName without specifying architecture." + } + } + if ($wingetDownloadResult -notmatch "Installer downloaded|Microsoft Store package download completed") { + WriteLog "Download not supported for $StoreAppName. Skipping download." + Remove-Item -Path $appFolderPath -Recurse -Force + return + } + if ($appIsWin32) { + Add-Win32SilentInstallCommand -AppFolder $StoreAppName -AppFolderPath $appFolderPath + } + Set-InstallStoreAppsFlag + # If $WindowsArch -eq 'ARM64', remove all dependency files that are not ARM64 + if ($WindowsArch -eq 'ARM64') { + WriteLog 'Windows architecture is ARM64. Removing dependencies that are not ARM64.' + $dependencies = Get-ChildItem -Path "$appFolderPath\Dependencies" -ErrorAction SilentlyContinue + if ($dependencies) { + foreach ($dependency in $dependencies) { + if ($dependency.Name -notmatch 'ARM64') { + WriteLog "Removing dependency file $($dependency.FullName)" + Remove-Item -Path $dependency.FullName -Recurse -Force + } + } + } + } + WriteLog "$StoreAppName has completed downloading. Identifying the latest version of $StoreAppName." + $packages = Get-ChildItem -Path "$appFolderPath\*" -Exclude "Dependencies\*", "*.xml", "*.yaml" -File -ErrorAction Stop + # WinGet downloads multiple versions of certain store apps. The latest version of the package will be determined based on the date of the file signature. + $latestPackage = $packages | Sort-Object { (Get-AuthenticodeSignature $_.FullName).SignerCertificate.NotBefore } -Descending | Select-Object -First 1 + # Removing all packages that are not the latest version + WriteLog "Latest version of $StoreAppName has been identified as $latestPackage. Removing old versions of $StoreAppName that may have downloaded." + foreach ($package in $packages) { + if ($package.FullName -ne $latestPackage) { + try { + WriteLog "Removing $($package.FullName)" + Remove-Item -Path $package.FullName -Force + } + catch { + WriteLog "Failed to delete: $($package.FullName) - $_" + throw $_ + } + } + } +} + +function Get-Apps { + param ( + [string]$AppList + ) + $apps = Get-Content -Path $AppList -Raw | ConvertFrom-Json + if (-not $apps) { + WriteLog "No apps were specified in AppList.json file." + return + } + $wingetApps = $apps.apps | Where-Object { $_.source -eq "winget" } + # List each Winget app in the AppList.json file + if ($wingetApps) { + WriteLog 'Winget apps to be installed:' + foreach ($wingetapp in $wingetApps){ + WriteLog "$($wingetapp.Name)" + } + } + $StoreApps = $apps.apps | Where-Object { $_.source -eq "msstore" } + # List each Store app in the AppList.json file + if ($StoreApps) { + WriteLog 'Store apps to be installed:' + foreach ($StoreApp in $StoreApps){ + WriteLog "$($StoreApp.Name)" + } + } + Confirm-WinGetInstallation + $win32Folder = Join-Path -Path $AppsPath -ChildPath "Win32" + $storeAppsFolder = Join-Path -Path $AppsPath -ChildPath "MSStore" + if ($wingetApps) { + if (-not (Test-Path -Path $win32Folder -PathType Container)) { + WriteLog "Creating folder for Winget Win32 apps: $win32Folder" + New-Item -Path $win32Folder -ItemType Directory -Force | Out-Null + WriteLog "Folder created successfully." + } + foreach ($wingetApp in $wingetApps) { + try { + Get-WinGetApp -WinGetAppName $wingetApp.Name -WinGetAppId $wingetApp.Id + } + catch { + WriteLog "Error occurred while processing $wingetApp : $_" + throw $_ + } + } + } + if ($storeApps) { + if (-not (Test-Path -Path $storeAppsFolder -PathType Container)) { + New-Item -Path $storeAppsFolder -ItemType Directory -Force | Out-Null + } + foreach ($storeApp in $storeApps) { + try { + Get-StoreApp -StoreAppName $storeApp.Name -StoreAppId $storeApp.Id + } + catch { + WriteLog "Error occurred while processing $storeApp : $_" + throw $_ + } + } + } +} + +function Get-KBLink { + param( + [Parameter(Mandatory)] + [string]$Name + ) + $OriginalVerbosePreference = $VerbosePreference + $VerbosePreference = 'SilentlyContinue' + $results = Invoke-WebRequest -Uri "http://www.catalog.update.microsoft.com/Search.aspx?q=$Name" -Headers $Headers -UserAgent $UserAgent + $VerbosePreference = $OriginalVerbosePreference + $kbids = $results.InputFields | + Where-Object { $_.type -eq 'Button' -and $_.Value -eq 'Download' } | + Select-Object -ExpandProperty ID + + Write-Verbose -Message "$kbids" + + if (-not $kbids) { + Write-Warning -Message "No results found for $Name" + return + } + + $guids = $results.Links | + Where-Object ID -match '_link' | + Where-Object { $_.OuterHTML -match ( "(?=.*" + ( $Filter -join ")(?=.*" ) + ")" ) } | + ForEach-Object { $_.id.replace('_link', '') } | + Where-Object { $_ -in $kbids } + + if (-not $guids) { + Write-Warning -Message "No file found for $Name" + return + } + + foreach ($guid in $guids) { + Write-Verbose -Message "Downloading information for $guid" + $post = @{ size = 0; updateID = $guid; uidInfo = $guid } | ConvertTo-Json -Compress + $body = @{ updateIDs = "[$post]" } + $OriginalVerbosePreference = $VerbosePreference + $VerbosePreference = 'SilentlyContinue' + $links = Invoke-WebRequest -Uri 'https://www.catalog.update.microsoft.com/DownloadDialog.aspx' -Method Post -Body $body -Headers $Headers -UserAgent $UserAgent | + Select-Object -ExpandProperty Content | + Select-String -AllMatches -Pattern "http[s]?://[^']*\.microsoft\.com/[^']*|http[s]?://[^']*\.windowsupdate\.com/[^']*" | + Select-Object -Unique + $VerbosePreference = $OriginalVerbosePreference + + foreach ($link in $links) { + $link.matches.value + #Filter out cab files + # #if ($link -notmatch '\.cab') { + # $link.matches.value + # } + + } + } +} +function Get-LatestWindowsKB { + param ( + [ValidateSet(10, 11)] + [int]$WindowsRelease + ) + + # Define the URL of the update history page based on the Windows release + if ($WindowsRelease -eq 11) { + $updateHistoryUrl = 'https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information' + } + else { + $updateHistoryUrl = 'https://learn.microsoft.com/en-us/windows/release-health/release-information' + } + + # Use Invoke-WebRequest to fetch the content of the page + $OriginalVerbosePreference = $VerbosePreference + $VerbosePreference = 'SilentlyContinue' + $response = Invoke-WebRequest -Uri $updateHistoryUrl -Headers $Headers -UserAgent $UserAgent + $VerbosePreference = $OriginalVerbosePreference + + # Use a regular expression to find the KB article number + $kbArticleRegex = 'KB\d+' + $kbArticle = [regex]::Match($response.Content, $kbArticleRegex).Value + + return $kbArticle +} + +function Save-KB { + [CmdletBinding()] + param( + [string[]]$Name, + [string]$Path + ) + + if ($WindowsArch -eq 'x64') { + [array]$WindowsArch = @("x64", "amd64") + } + #Keep for now, will remove in future + # foreach ($kb in $name) { + # $links = Get-KBLink -Name $kb + # foreach ($link in $links) { + # #Check if $WindowsArch is an array + # if ($WindowsArch -is [array]) { + # #Some file names include either x64 or amd64 + # if ($link -match $WindowsArch[0] -or $link -match $WindowsArch[1]) { + # Start-BitsTransferWithRetry -Source $link -Destination $Path + # $fileName = ($link -split '/')[-1] + # break + # } + # # elseif (!($link -match 'x64' -or $link -match 'amd64' -or $link -match 'x86' -or $link -match 'arm64')) { + # # Write-Host "No architecture found in $link, assume it's for all architectures" + # # Start-BitsTransfer -Source $link -Destination $Path + # # $fileName = ($link -split '/')[-1] + # # break + # # } + # elseif (!($link -match 'x64' -or $link -match 'amd64' -or $link -match 'x86' -or $link -match 'arm64')) { + # WriteLog "No architecture found in $link, assume this is for all architectures" + # #FIX: 3/22/2024 - the SecurityHealthSetup fix was updated and now includes two files (one is x64 and the other is arm64) + # #Unfortunately there is no easy way to determine the architecture from the file name + # #There is a support doc that include links to download, but it's out of date (n-1) + # #https://support.microsoft.com/en-us/topic/windows-security-update-a6ac7d2e-b1bf-44c0-a028-41720a242da3 + # #These files don't change that often, so will check the link above to see when it updates and may use that + # #For now this is hard-coded for these specific file names + # if ($link -match 'security'){ + # #Make sure we're getting the correct architecture for the Security Health Setup update + # WriteLog "Link: $link matches security" + # if ($WindowsArch -eq 'x64'){ + # if ($link -match 'securityhealthsetup_e1'){ + # Writelog "Downloading $Link for $WindowsArch to $Path" + # Start-BitsTransferWithRetry -Source $link -Destination $Path + # $fileName = ($link -split '/')[-1] + # Writelog "Returning $fileName" + # break + # } + # } + # elseif ($WindowsArch -eq 'arm64'){ + # if ($link -match 'securityhealthsetup_25'){ + # Writelog "Downloading $Link for $WindowsArch to $Path" + # Start-BitsTransferWithRetry -Source $link -Destination $Path + # $fileName = ($link -split '/')[-1] + # Writelog "Returning $fileName" + # break + # } + # } + # continue + # } + # Start-BitsTransferWithRetry -Source $link -Destination $Path + # $fileName = ($link -split '/')[-1] + # } + # } + # else { + # if ($link -match $WindowsArch) { + # Start-BitsTransferWithRetry -Source $link -Destination $Path + # $fileName = ($link -split '/')[-1] + # break + # } + # } + # } + # } + foreach ($kb in $name) { + $links = Get-KBLink -Name $kb + foreach ($link in $links) { + if (!($link -match 'x64' -or $link -match 'amd64' -or $link -match 'x86' -or $link -match 'arm64')) { + WriteLog "No architecture found in $link, assume this is for all architectures" + #FIX: 3/22/2024 - the SecurityHealthSetup fix was updated and now includes two files (one is x64 and the other is arm64) + #Unfortunately there is no easy way to determine the architecture from the file name + #There is a support doc that include links to download, but it's out of date (n-1) + #https://support.microsoft.com/en-us/topic/windows-security-update-a6ac7d2e-b1bf-44c0-a028-41720a242da3 + #These files don't change that often, so will check the link above to see when it updates and may use that + #For now this is hard-coded for these specific file names + if ($link -match 'security') { + #Make sure we're getting the correct architecture for the Security Health Setup update + WriteLog "Link: $link matches security" + if ($WindowsArch -eq 'x64') { + if ($link -match 'securityhealthsetup_e1') { + Writelog "Downloading $Link for $WindowsArch to $Path" + Start-BitsTransferWithRetry -Source $link -Destination $Path + $fileName = ($link -split '/')[-1] + Writelog "Returning $fileName" + break + } + } + if ($WindowsArch -eq 'arm64') { + if ($link -match 'securityhealthsetup_25') { + Writelog "Downloading $Link for $WindowsArch to $Path" + Start-BitsTransferWithRetry -Source $link -Destination $Path + $fileName = ($link -split '/')[-1] + Writelog "Returning $fileName" + break + } + } + } + } + + if ($link -match 'x64' -or $link -match 'amd64') { + if($WindowsArch -is [array]) { + if ($link -match $WindowsArch[0] -or $link -match $WindowsArch[1]) { + Writelog "Downloading $Link for $WindowsArch to $Path" + Start-BitsTransferWithRetry -Source $link -Destination $Path + $fileName = ($link -split '/')[-1] + Writelog "Returning $fileName" + break + } + } + + } + if ($link -match 'arm64') { + if ($WindowsArch -eq 'arm64') { + Writelog "Downloading $Link for $WindowsArch to $Path" + Start-BitsTransferWithRetry -Source $link -Destination $Path + $fileName = ($link -split '/')[-1] + Writelog "Returning $fileName" + break + } + } + } + } + return $fileName +} + +function New-AppsISO { + #Create Apps ISO file + $OSCDIMG = "$adkpath`Assessment and Deployment Kit\Deployment Tools\amd64\Oscdimg\oscdimg.exe" + #Start-Process -FilePath $OSCDIMG -ArgumentList "-n -m -d $Appspath $AppsISO" -wait + Invoke-Process $OSCDIMG "-n -m -d $Appspath $AppsISO" + + #Remove the Office Download and ODT + if ($InstallOffice) { + $ODTPath = "$AppsPath\Office" + $OfficeDownloadPath = "$ODTPath\Office" + WriteLog 'Cleaning up Office and ODT download' + Remove-Item -Path $OfficeDownloadPath -Recurse -Force + Remove-Item -Path "$ODTPath\setup.exe" + } +} +function Get-WimFromISO { + #Mount ISO, get Wim file + $mountResult = Mount-DiskImage -ImagePath $isoPath -PassThru + $sourcesFolder = ($mountResult | Get-Volume).DriveLetter + ":\sources\" + + # Check for install.wim or install.esd + $wimPath = (Get-ChildItem $sourcesFolder\install.* | Where-Object { $_.Name -match "install\.(wim|esd)" }).FullName + + if ($wimPath) { + WriteLog "The path to the install file is: $wimPath" + } + else { + WriteLog "No install.wim or install.esd file found in: $sourcesFolder" + } + + return $wimPath +} + + +function Get-WimIndex { + param ( + [Parameter(Mandatory = $true)] + [string]$WindowsSKU + ) + WriteLog "Getting WIM Index for Windows SKU: $WindowsSKU" + + If ($ISOPath) { + $wimindex = switch ($WindowsSKU) { + 'Home' { 1 } + 'Home_N' { 2 } + 'Home_SL' { 3 } + 'EDU' { 4 } + 'EDU_N' { 5 } + 'Pro' { 6 } + 'Pro_N' { 7 } + 'Pro_EDU' { 8 } + 'Pro_Edu_N' { 9 } + 'Pro_WKS' { 10 } + 'Pro_WKS_N' { 11 } + Default { 6 } + } + } + + Writelog "WIM Index: $wimindex" + return $WimIndex +} + +function Get-Index { + param( + [Parameter(Mandatory = $true)] + [string]$WindowsImagePath, + + [Parameter(Mandatory = $true)] + [string]$WindowsSKU + ) + + + # Get the available indexes using Get-WindowsImage + $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 + if($ISOPath){ + $imageIndex = $imageIndexes | Where-Object ImageIndex -eq 1 + $WindowsImage = $imageIndex.ImageName.Substring(0, 10) + } + else{ + $imageIndex = $imageIndexes | Where-Object ImageIndex -eq 4 + $WindowsImage = $imageIndex.ImageName.Substring(0, 10) + } + + # Concatenate $WindowsImage and $WindowsSKU (E.g. Windows 11 Pro) + $ImageNameToFind = "$WindowsImage $WindowsSKU" + + # Find the ImageName in all of the indexes in the image + $matchingImageIndex = $imageIndexes | Where-Object ImageName -eq $ImageNameToFind + + # Return the index that matches exactly + if ($matchingImageIndex) { + return $matchingImageIndex.ImageIndex + } + else { + # Look for either the number 10 or 11 in the ImageName + $relevantImageIndexes = $imageIndexes | Where-Object { ($_.ImageName -like "*10*") -or ($_.ImageName -like "*11*") } + + while ($true) { + # 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:" + + $i = 1 + $relevantImageIndexes | ForEach-Object { + Write-Host "$i. $($_.ImageName)" + $i++ + } + + # Ask for user input + $inputValue = Read-Host "Enter the number of the ImageName you want to use" + + # Get selected ImageName based on user input + $selectedImage = $relevantImageIndexes[$inputValue - 1] + + if ($selectedImage) { + return $selectedImage.ImageIndex + } + else { + Write-Host "Invalid selection, please try again." + } + } + } +} + +#Create VHDX +function New-ScratchVhdx { + param( + [Parameter(Mandatory = $true)] + [string]$VhdxPath, + [uint64]$SizeBytes = 30GB, + [uint32]$LogicalSectorSizeBytes, + [switch]$Dynamic, + [Microsoft.PowerShell.Cmdletization.GeneratedTypes.Disk.PartitionStyle]$PartitionStyle = [Microsoft.PowerShell.Cmdletization.GeneratedTypes.Disk.PartitionStyle]::GPT + ) + + WriteLog "Creating new Scratch VHDX..." + + $newVHDX = New-VHD -Path $VhdxPath -SizeBytes $disksize -LogicalSectorSizeBytes $LogicalSectorSizeBytes -Dynamic:($Dynamic.IsPresent) + $toReturn = $newVHDX | Mount-VHD -Passthru | Initialize-Disk -PassThru -PartitionStyle GPT + + #Remove auto-created partition so we can create the correct partition layout + remove-partition $toreturn.DiskNumber -PartitionNumber 1 -Confirm:$False + + Writelog "Done." + return $toReturn +} +#Add System Partition +function New-SystemPartition { + param( + [Parameter(Mandatory = $true)] + [ciminstance]$VhdxDisk, + [uint64]$SystemPartitionSize = 260MB + ) + + WriteLog "Creating System partition..." + + $sysPartition = $VhdxDisk | New-Partition -DriveLetter 'S' -Size $SystemPartitionSize -GptType "{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}" -IsHidden + $sysPartition | Format-Volume -FileSystem FAT32 -Force -NewFileSystemLabel "System" + + WriteLog 'Done.' + return $sysPartition.DriveLetter +} +#Add MSRPartition +function New-MSRPartition { + param( + [Parameter(Mandatory = $true)] + [ciminstance]$VhdxDisk + ) + + WriteLog "Creating MSR partition..." + + # $toReturn = $VhdxDisk | New-Partition -AssignDriveLetter -Size 16MB -GptType "{e3c9e316-0b5c-4db8-817d-f92df00215ae}" -IsHidden | Out-Null + $toReturn = $VhdxDisk | New-Partition -Size 16MB -GptType "{e3c9e316-0b5c-4db8-817d-f92df00215ae}" -IsHidden | Out-Null + + WriteLog "Done." + + return $toReturn +} +#Add OS Partition +function New-OSPartition { + param( + [Parameter(Mandatory = $true)] + [ciminstance]$VhdxDisk, + [Parameter(Mandatory = $true)] + [string]$WimPath, + [uint32]$WimIndex, + [uint64]$OSPartitionSize = 0 + ) + + WriteLog "Creating OS partition..." + + if ($OSPartitionSize -gt 0) { + $osPartition = $vhdxDisk | New-Partition -DriveLetter 'W' -Size $OSPartitionSize -GptType "{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}" + } + else { + $osPartition = $vhdxDisk | New-Partition -DriveLetter 'W' -UseMaximumSize -GptType "{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}" + } + + $osPartition | Format-Volume -FileSystem NTFS -Confirm:$false -Force -NewFileSystemLabel "Windows" + WriteLog 'Done' + Writelog "OS partition at drive $($osPartition.DriveLetter):" + + WriteLog "Writing Windows at $WimPath to OS partition at drive $($osPartition.DriveLetter):..." + + #Server 2019 is missing the Windows Overlay Filter (wof.sys), likely other Server SKUs are missing it as well. Script will error if trying to use the -compact switch on Server OSes + if ((Get-CimInstance Win32_OperatingSystem).Caption -match "Server") { + WriteLog (Expand-WindowsImage -ImagePath $WimPath -Index $WimIndex -ApplyPath "$($osPartition.DriveLetter):\") + } + if ($CompactOS) { + WriteLog '$CompactOS is set to true, using -Compact switch to apply the WIM file to the OS partition.' + WriteLog (Expand-WindowsImage -ImagePath $WimPath -Index $WimIndex -ApplyPath "$($osPartition.DriveLetter):\" -Compact) + } + else { + WriteLog (Expand-WindowsImage -ImagePath $WimPath -Index $WimIndex -ApplyPath "$($osPartition.DriveLetter):\") + } + + WriteLog 'Done' + return $osPartition +} +#Add Recovery partition +function New-RecoveryPartition { + param( + [Parameter(Mandatory = $true)] + [ciminstance]$VhdxDisk, + [Parameter(Mandatory = $true)] + $OsPartition, + [uint64]$RecoveryPartitionSize = 0, + [ciminstance]$DataPartition + ) + + WriteLog "Creating empty Recovery partition (to be filled on first boot automatically)..." + + $calculatedRecoverySize = 0 + $recoveryPartition = $null + + if ($RecoveryPartitionSize -gt 0) { + $calculatedRecoverySize = $RecoveryPartitionSize + } + else { + $winReWim = Get-ChildItem "$($OsPartition.DriveLetter):\Windows\System32\Recovery\Winre.wim" + + if (($null -ne $winReWim) -and ($winReWim.Count -eq 1)) { + # Wim size + 100MB is minimum WinRE partition size. + # NTFS and other partitioning size differences account for about 17MB of space that's unavailable. + # Adding 32MB as a buffer to ensure there's enough space to account for NTFS file system overhead. + # Adding 250MB as per recommendations from + # https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/configure-uefigpt-based-hard-drive-partitions?view=windows-11#recovery-tools-partition + $calculatedRecoverySize = $winReWim.Length + 250MB + 32MB + + WriteLog "Calculated space needed for recovery in bytes: $calculatedRecoverySize" + + if ($null -ne $DataPartition) { + $DataPartition | Resize-Partition -Size ($DataPartition.Size - $calculatedRecoverySize) + WriteLog "Data partition shrunk by $calculatedRecoverySize bytes for Recovery partition." + } + else { + $newOsPartitionSize = [math]::Floor(($OsPartition.Size - $calculatedRecoverySize) / 4096) * 4096 + $OsPartition | Resize-Partition -Size $newOsPartitionSize + WriteLog "OS partition shrunk by $calculatedRecoverySize bytes for Recovery partition." + } + + $recoveryPartition = $VhdxDisk | New-Partition -DriveLetter 'R' -UseMaximumSize -GptType "{de94bba4-06d1-4d40-a16a-bfd50179d6ac}" ` + | Format-Volume -FileSystem NTFS -Confirm:$false -Force -NewFileSystemLabel 'Recovery' + + WriteLog "Done. Recovery partition at drive $($recoveryPartition.DriveLetter):" + } + else { + WriteLog "No WinRE.WIM found in the OS partition under \Windows\System32\Recovery." + WriteLog "Skipping creating the Recovery partition." + WriteLog "If a Recovery partition is desired, please re-run the script setting the -RecoveryPartitionSize flag as appropriate." + } + } + + return $recoveryPartition +} +#Add boot files +function Add-BootFiles { + param( + [Parameter(Mandatory = $true)] + [string]$OsPartitionDriveLetter, + [Parameter(Mandatory = $true)] + [string]$SystemPartitionDriveLetter, + [string]$FirmwareType = 'UEFI' + ) + + WriteLog "Adding boot files for `"$($OsPartitionDriveLetter):\Windows`" to System partition `"$($SystemPartitionDriveLetter):`"..." + Invoke-Process bcdboot "$($OsPartitionDriveLetter):\Windows /S $($SystemPartitionDriveLetter): /F $FirmwareType" + WriteLog "Done." +} + +function Enable-WindowsFeaturesByName { + param ( + [Parameter(Mandatory = $true)] + [string]$FeatureNames, + [Parameter(Mandatory = $true)] + [string]$Source + ) + + $FeaturesArray = $FeatureNames.Split(';') + + # Looping through each feature and enabling it + foreach ($FeatureName in $FeaturesArray) { + WriteLog "Enabling Windows Optional feature: $FeatureName" + Enable-WindowsOptionalFeature -Path $WindowsPartition -FeatureName $FeatureName -All -Source $Source | Out-Null + WriteLog "Done" + } +} + +#Dismount VHDX +function Dismount-ScratchVhdx { + param( + [Parameter(Mandatory = $true)] + [string]$VhdxPath + ) + + if (Test-Path $VhdxPath) { + WriteLog "Dismounting scratch VHDX..." + Dismount-VHD -Path $VhdxPath + WriteLog "Done." + } +} + +function New-FFUVM { + #Create new Gen2 VM + $VM = New-VM -Name $VMName -Path $VMPath -MemoryStartupBytes $memory -VHDPath $VHDXPath -Generation 2 + Set-VMProcessor -VMName $VMName -Count $processors + + #Mount AppsISO + Add-VMDvdDrive -VMName $VMName -Path $AppsISO + + #Set Hard Drive as boot device + $VMHardDiskDrive = Get-VMHarddiskdrive -VMName $VMName + Set-VMFirmware -VMName $VMName -FirstBootDevice $VMHardDiskDrive + Set-VM -Name $VMName -AutomaticCheckpointsEnabled $false -StaticMemory + + #Configure TPM + New-HgsGuardian -Name $VMName -GenerateCertificates + $owner = get-hgsguardian -Name $VMName + $kp = New-HgsKeyProtector -Owner $owner -AllowUntrustedRoot + Set-VMKeyProtector -VMName $VMName -KeyProtector $kp.RawData + Enable-VMTPM -VMName $VMName + + #Connect to VM + WriteLog "Starting vmconnect localhost $VMName" + & vmconnect localhost "$VMName" + + #Start VM + Start-VM -Name $VMName + + return $VM +} + +Function Set-CaptureFFU { + $CaptureFFUScriptPath = "$FFUDevelopmentPath\WinPECaptureFFUFiles\CaptureFFU.ps1" + + 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 \\\ /user: + $SharePath = "\\$VMHostIPAddress\$ShareName /user:$UserName $Password" + $SharePath = "net use W: " + $SharePath + + # Update CaptureFFU.ps1 script + if (Test-Path -Path $CaptureFFUScriptPath) { + $ScriptContent = Get-Content -Path $CaptureFFUScriptPath + $UpdatedContent = $ScriptContent -replace '(net use).*', ("$SharePath") + WriteLog 'Updating share command in CaptureFFU.ps1 script with new share information' + Set-Content -Path $CaptureFFUScriptPath -Value $UpdatedContent + WriteLog 'Update complete' + } + else { + throw "CaptureFFU.ps1 script not found at $CaptureFFUScriptPath" + } +} + +function New-PEMedia { + param ( + [Parameter()] + [bool]$Capture, + [Parameter()] + [bool]$Deploy + ) + #Need to use the Demployment and Imaging tools environment to create winPE media + $DandIEnv = "$adkPath`Assessment and Deployment Kit\Deployment Tools\DandISetEnv.bat" + $WinPEFFUPath = "$FFUDevelopmentPath\WinPE" + + If (Test-path -Path "$WinPEFFUPath") { + WriteLog "Removing old WinPE path at $WinPEFFUPath" + Remove-Item -Path "$WinPEFFUPath" -Recurse -Force | out-null + } + + WriteLog "Copying WinPE files to $WinPEFFUPath" + if($WindowsArch -eq 'x64') { + & cmd /c """$DandIEnv"" && copype amd64 $WinPEFFUPath" | Out-Null + } + elseif($WindowsArch -eq 'arm64') { + & cmd /c """$DandIEnv"" && copype arm64 $WinPEFFUPath" | Out-Null + } + #Invoke-Process cmd "/c ""$DandIEnv"" && copype amd64 $WinPEFFUPath" + WriteLog 'Files copied successfully' + + WriteLog 'Mounting WinPE media to add WinPE optional components' + Mount-WindowsImage -ImagePath "$WinPEFFUPath\media\sources\boot.wim" -Index 1 -Path "$WinPEFFUPath\mount" | Out-Null + WriteLog 'Mounting complete' + + $Packages = @( + "WinPE-WMI.cab", + "en-us\WinPE-WMI_en-us.cab", + "WinPE-NetFX.cab", + "en-us\WinPE-NetFX_en-us.cab", + "WinPE-Scripting.cab", + "en-us\WinPE-Scripting_en-us.cab", + "WinPE-PowerShell.cab", + "en-us\WinPE-PowerShell_en-us.cab", + "WinPE-StorageWMI.cab", + "en-us\WinPE-StorageWMI_en-us.cab", + "WinPE-DismCmdlets.cab", + "en-us\WinPE-DismCmdlets_en-us.cab" + ) + + if($WindowsArch -eq 'x64'){ + $PackagePathBase = "$adkPath`Assessment and Deployment Kit\Windows Preinstallation Environment\amd64\WinPE_OCs\" + } + elseif($WindowsArch -eq 'arm64'){ + $PackagePathBase = "$adkPath`Assessment and Deployment Kit\Windows Preinstallation Environment\arm64\WinPE_OCs\" + } + + + foreach ($Package in $Packages) { + $PackagePath = Join-Path $PackagePathBase $Package + WriteLog "Adding Package $Package" + Add-WindowsPackage -Path "$WinPEFFUPath\mount" -PackagePath $PackagePath | Out-Null + WriteLog "Adding package complete" + } + If ($Capture) { + WriteLog "Copying $FFUDevelopmentPath\WinPECaptureFFUFiles\* to WinPE capture media" + Copy-Item -Path "$FFUDevelopmentPath\WinPECaptureFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | out-null + WriteLog "Copy complete" + #Remove Bootfix.bin - for BIOS systems, shouldn't be needed, but doesn't hurt to remove for our purposes + #Remove-Item -Path "$WinPEFFUPath\media\boot\bootfix.bin" -Force | Out-null + # $WinPEISOName = 'WinPE_FFU_Capture.iso' + $WinPEISOFile = $CaptureISO + # $Capture = $false + } + If ($Deploy) { + WriteLog "Copying $FFUDevelopmentPath\WinPEDeployFFUFiles\* to WinPE deploy media" + Copy-Item -Path "$FFUDevelopmentPath\WinPEDeployFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | Out-Null + WriteLog 'Copy complete' + #If $CopyPEDrivers = $true, add drivers to WinPE media using dism + if ($CopyPEDrivers) { + WriteLog "Adding drivers to WinPE media" + try { + Add-WindowsDriver -Path "$WinPEFFUPath\Mount" -Driver "$FFUDevelopmentPath\PEDrivers" -Recurse -ErrorAction SilentlyContinue | Out-null + } + catch { + WriteLog 'Some drivers failed to be added to the FFU. This can be expected. Continuing.' + } + WriteLog "Adding drivers complete" + } + # $WinPEISOName = 'WinPE_FFU_Deploy.iso' + $WinPEISOFile = $DeployISO + + # $Deploy = $false + } + WriteLog 'Dismounting WinPE media' + Dismount-WindowsImage -Path "$WinPEFFUPath\mount" -Save | Out-Null + WriteLog 'Dismount complete' + #Make ISO + if ($WindowsArch -eq 'x64') { + $OSCDIMGPath = "$adkPath`Assessment and Deployment Kit\Deployment Tools\amd64\Oscdimg" + } + elseif ($WindowsArch -eq 'arm64') { + $OSCDIMGPath = "$adkPath`Assessment and Deployment Kit\Deployment Tools\arm64\Oscdimg" + } + $OSCDIMG = "$OSCDIMGPath\oscdimg.exe" + WriteLog "Creating WinPE ISO at $WinPEISOFile" + # & "$OSCDIMG" -m -o -u2 -udfver102 -bootdata:2`#p0,e,b$OSCDIMGPath\etfsboot.com`#pEF,e,b$OSCDIMGPath\Efisys_noprompt.bin $WinPEFFUPath\media $FFUDevelopmentPath\$WinPEISOName | Out-null + if($WindowsArch -eq 'x64'){ + if($Capture){ + $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys_noprompt.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`"" + } + if($Deploy){ + $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`"" + } + } + elseif($WindowsArch -eq 'arm64'){ + if($Capture){ + $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys_noprompt.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`"" + } + if($Deploy){ + $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`"" + } + + } + Invoke-Process $OSCDIMG $OSCDIMGArgs + WriteLog "ISO created successfully" + WriteLog "Cleaning up $WinPEFFUPath" + Remove-Item -Path "$WinPEFFUPath" -Recurse -Force + WriteLog 'Cleanup complete' +} + +function Optimize-FFUCaptureDrive { + param ( + [string]$VhdxPath + ) + try { + WriteLog 'Mounting VHDX for volume optimization' + Mount-VHD -Path $VhdxPath + WriteLog 'Defragmenting Windows partition...' + Optimize-Volume -DriveLetter W -Defrag -NormalPriority -Verbose + WriteLog 'Performing slab consolidation on Windows partition...' + Optimize-Volume -DriveLetter W -SlabConsolidate -NormalPriority -Verbose + WriteLog 'Dismounting VHDX' + Dismount-ScratchVhdx -VhdxPath $VhdxPath + WriteLog 'Mounting VHDX as read-only for optimization' + Mount-VHD -Path $VhdxPath -NoDriveLetter -ReadOnly + WriteLog 'Optimizing VHDX in full mode...' + Optimize-VHD -Path $VhdxPath -Mode Full + WriteLog 'Dismounting VHDX' + Dismount-ScratchVhdx -VhdxPath $VhdxPath + } catch { + throw $_ + } +} + +function New-FFU { + param ( + [Parameter(Mandatory = $false)] + [string]$VMName + ) + #If $InstallApps = $true, configure the VM + If ($InstallApps) { + WriteLog 'Creating FFU from VM' + WriteLog "Setting $CaptureISO as first boot device" + $VMDVDDrive = Get-VMDvdDrive -VMName $VMName + Set-VMFirmware -VMName $VMName -FirstBootDevice $VMDVDDrive + Set-VMDvdDrive -VMName $VMName -Path $CaptureISO + $VMSwitch = Get-VMSwitch -name $VMSwitchName + WriteLog "Setting $($VMSwitch.Name) as VMSwitch" + get-vm $VMName | Get-VMNetworkAdapter | Connect-VMNetworkAdapter -SwitchName $VMSwitch.Name + WriteLog "Configuring VM complete" + + #Start VM + WriteLog "Starting VM" + Start-VM -Name $VMName + + # Wait for the VM to turn off + do { + $FFUVM = Get-VM -Name $VMName + Start-Sleep -Seconds 5 + } while ($FFUVM.State -ne 'Off') + WriteLog "VM Shutdown" + # Check for .ffu files in the FFUDevelopment folder + WriteLog "Checking for FFU Files" + $FFUFiles = Get-ChildItem -Path $FFUCaptureLocation -Filter "*.ffu" -File + + # If there's more than one .ffu file, get the most recent and store its path in $FFUFile + if ($FFUFiles.Count -gt 0) { + WriteLog 'Getting the most recent FFU file' + $FFUFile = ($FFUFiles | Sort-Object -Property LastWriteTime -Descending | Select-Object -First 1).FullName + WriteLog "Most recent .ffu file: $FFUFile" + } + else { + WriteLog "No .ffu files found in $FFUFolderPath" + throw $_ + } + } + elseif (-not $InstallApps) { + #Get Windows Version Information from the VHDX + $winverinfo = Get-WindowsVersionInfo + $FFUFileName = "$($winverinfo.Name)`_$($winverinfo.DisplayVersion)`_$($winverinfo.SKU)`_$($winverinfo.BuildDate).ffu" + WriteLog "FFU file name: $FFUFileName" + $FFUFile = "$FFUCaptureLocation\$FFUFileName" + #Capture the FFU + Invoke-Process cmd "/c ""$DandIEnv"" && dism /Capture-FFU /ImageFile:$FFUFile /CaptureDrive:\\.\PhysicalDrive$($vhdxDisk.DiskNumber) /Name:$($winverinfo.Name)$($winverinfo.DisplayVersion)$($winverinfo.SKU) /Compress:Default" + # Invoke-Process cmd "/c dism /Capture-FFU /ImageFile:$FFUFile /CaptureDrive:\\.\PhysicalDrive$($vhdxDisk.DiskNumber) /Name:$($winverinfo.Name)$($winverinfo.DisplayVersion)$($winverinfo.SKU) /Compress:Default" + WriteLog 'FFU Capture complete' + Dismount-ScratchVhdx -VhdxPath $VHDXPath + } + + #Without this 120 second sleep, we sometimes see an error when mounting the FFU due to a file handle lock. Needed for both driver and optimize steps. + WriteLog 'Sleeping 2 minutes to prevent file handle lock' + Start-Sleep 120 + + #Add drivers + If ($InstallDrivers) { + WriteLog 'Adding drivers' + WriteLog "Creating $FFUDevelopmentPath\Mount directory" + New-Item -Path "$FFUDevelopmentPath\Mount" -ItemType Directory -Force | Out-Null + WriteLog "Created $FFUDevelopmentPath\Mount directory" + WriteLog "Mounting $FFUFile to $FFUDevelopmentPath\Mount" + Mount-WindowsImage -ImagePath $FFUFile -Index 1 -Path "$FFUDevelopmentPath\Mount" | Out-null + WriteLog 'Mounting complete' + WriteLog 'Adding drivers - This will take a few minutes, please be patient' + try { + Add-WindowsDriver -Path "$FFUDevelopmentPath\Mount" -Driver "$DriversFolder" -Recurse -ErrorAction SilentlyContinue | Out-null + } + catch { + WriteLog 'Some drivers failed to be added to the FFU. This can be expected. Continuing.' + } + WriteLog 'Adding drivers complete' + WriteLog "Dismount $FFUDevelopmentPath\Mount" + Dismount-WindowsImage -Path "$FFUDevelopmentPath\Mount" -Save | Out-Null + WriteLog 'Dismount complete' + WriteLog "Remove $FFUDevelopmentPath\Mount folder" + Remove-Item -Path "$FFUDevelopmentPath\Mount" -Recurse -Force | Out-null + WriteLog 'Folder removed' + } + #Optimize FFU + if ($Optimize -eq $true) { + WriteLog 'Optimizing FFU - This will take a few minutes, please be patient' + #Need to use ADK version of DISM to address bug in DISM - perhaps Windows 11 24H2 will fix this + Invoke-Process cmd "/c ""$DandIEnv"" && dism /optimize-ffu /imagefile:$FFUFile" + #Invoke-Process cmd "/c dism /optimize-ffu /imagefile:$FFUFile" + WriteLog 'Optimizing FFU complete' + } + + +} +function Remove-FFUVM { + param ( + [Parameter(Mandatory = $false)] + [string]$VMName + ) + #Get the VM object and remove the VM, the HGSGuardian, and the certs + If ($VMName) { + $FFUVM = get-vm $VMName | Where-Object { $_.state -ne 'running' } + } + If ($null -ne $FFUVM) { + WriteLog 'Cleaning up VM' + $certPath = 'Cert:\LocalMachine\Shielded VM Local Certificates\' + $VMName = $FFUVM.Name + WriteLog "Removing VM: $VMName" + Remove-VM -Name $VMName -Force + WriteLog 'Removal complete' + WriteLog "Removing $VMPath" + Remove-Item -Path $VMPath -Force -Recurse + WriteLog 'Removal complete' + WriteLog "Removing HGSGuardian for $VMName" + Remove-HgsGuardian -Name $VMName -WarningAction SilentlyContinue + WriteLog 'Removal complete' + WriteLog 'Cleaning up HGS Guardian certs' + $certs = Get-ChildItem -Path $certPath -Recurse | Where-Object { $_.Subject -like "*$VMName*" } + foreach ($cert in $Certs) { + Remove-item -Path $cert.PSPath -force | Out-Null + } + WriteLog 'Cert removal complete' + } + #If just building the FFU from vhdx, remove the vhdx path + If (-not $InstallApps -and $vhdxDisk) { + WriteLog 'Cleaning up VHDX' + WriteLog "Removing $VMPath" + Remove-Item -Path $VMPath -Force -Recurse | Out-Null + WriteLog 'Removal complete' + } + + #Remove orphaned mounted images + $mountedImages = Get-WindowsImage -Mounted + if ($mountedImages) { + foreach ($image in $mountedImages) { + $mountPath = $image.Path + WriteLog "Dismounting image at $mountPath" + Dismount-WindowsImage -Path $mountPath -discard + WriteLog "Successfully dismounted image at $mountPath" + } + } + #Remove Mount folder if it exists + If (Test-Path -Path $FFUDevelopmentPath\Mount) { + WriteLog "Remove $FFUDevelopmentPath\Mount folder" + Remove-Item -Path "$FFUDevelopmentPath\Mount" -Recurse -Force + WriteLog 'Folder removed' + } + #Remove unused mountpoints + WriteLog 'Remove unused mountpoints' + Invoke-Process cmd "/c mountvol /r" + WriteLog 'Removal complete' +} +Function Remove-FFUUserShare { + WriteLog "Removing $ShareName" + Remove-SmbShare -Name $ShareName -Force | Out-null + WriteLog 'Removal complete' + WriteLog "Removing $Username" + Remove-LocalUser -Name $Username | Out-Null + WriteLog 'Removal complete' +} + +Function Get-WindowsVersionInfo { + #This sleep prevents CBS/CSI corruption which causes issues with Windows update after deployment. Capturing from very fast disks (NVME) can cause the capture to happen faster than Windows is ready for. This seems to affect VHDX-only captures, not VM captures. + WriteLog 'Sleep 60 seconds before opening registry to grab Windows version info ' + Start-sleep 60 + WriteLog "Getting Windows Version info" + #Load Registry Hive + $Software = "$osPartitionDriveLetter`:\Windows\System32\config\software" + WriteLog "Loading Software registry hive" + Invoke-Process reg "load HKLM\FFU $Software" + + #Find Windows version values + $SKU = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'EditionID' + WriteLog "Windows SKU: $SKU" + [int]$CurrentBuild = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'CurrentBuild' + WriteLog "Windows Build: $CurrentBuild" + $DisplayVersion = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'DisplayVersion' + WriteLog "Windows Version: $DisplayVersion" + $BuildDate = Get-Date -uformat %b%Y + + $SKU = switch ($SKU) { + Core { 'Home' } + Professional { 'Pro' } + ProfessionalEducation { 'Pro_Edu' } + Enterprise { 'Ent' } + Education { 'Edu' } + ProfessionalWorkstation { 'Pro_Wks' } + } + WriteLog "Windows SKU Modified to: $SKU" + + if ($CurrentBuild -ge 22000) { + $Name = 'Win11' + } + else { + $Name = 'Win10' + } + + WriteLog "Unloading registry" + Invoke-Process reg "unload HKLM\FFU" + #This prevents Critical Process Died errors you can have during deployment of the FFU. Capturing from very fast disks (NVME) can cause the capture to happen faster than Windows is ready for. + WriteLog 'Sleep 60 seconds to allow registry to completely unload' + Start-sleep 60 + + return @{ + + DisplayVersion = $DisplayVersion + BuildDate = $buildDate + Name = $Name + SKU = $SKU + } +} +Function Get-USBDrive { + # Log the start of the USB drive check + WriteLog 'Checking for USB drives' + + # Check if external hard disk media is allowed + If ($AllowExternalHardDiskMedia) { + # 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]$ExternalHardDiskDrives = $USBDrives | Where-Object { $_.MediaType -eq 'External hard disk media' } + $ExternalCount = $ExternalHardDiskDrives.Count + $USBDrivesCount = $USBDrives.Count + + # Check if user should be prompted for external hard disk media + if ($PromptExternalHardDiskMedia) { + if ($ExternalHardDiskDrives) { + # Log and warn about found external hard disk media drives + if ($VerbosePreference -ne 'Continue') { + Write-Warning 'Found external hard disk media drives' + Write-Warning 'Will prompt for user input to select the drive to use to prevent accidental data loss' + Write-Warning 'If you do not want to be prompted for this in the future, set -PromptExternalHardDiskMedia to $false' + } + WriteLog 'Found external hard disk media drives' + WriteLog 'Will prompt for user input to select the drive to use to prevent accidental data loss' + WriteLog 'If you do not want to be prompted for this in the future, set -PromptExternalHardDiskMedia to $false' + + # Prepare output for user selection + $Output = @() + for ($i = 0; $i -lt $ExternalHardDiskDrives.Count; $i++) { + $ExternalDiskNumber = $ExternalHardDiskDrives[$i].Index + $ExternalDisk = Get-Disk -Number $ExternalDiskNumber + $Index = $i + 1 + $Name = $ExternalDisk.FriendlyName + $SerialNumber = $ExternalHardDiskDrives[$i].serialnumber + $PartitionStyle = $ExternalDisk.PartitionStyle + $Status = $ExternalDisk.OperationalStatus + $Properties = [ordered]@{ + 'Drive Number' = $Index + 'Drive Name' = $Name + 'Serial Number' = $SerialNumber + 'Partition Style' = $PartitionStyle + 'Status' = $Status + } + $Output += New-Object PSObject -Property $Properties + } + + # Format and display the output + $FormattedOutput = $Output | Format-Table -AutoSize -Property 'Drive Number', 'Drive Name', 'Serial Number', 'Partition Style', 'Status' | Out-String + if ($VerbosePreference -ne 'Continue') { + $FormattedOutput | Out-Host + } + WriteLog $FormattedOutput + + # Prompt user to select a drive + do { + $inputChoice = Read-Host "Enter the number corresponding to the external hard disk media drive you want to use" + if ($inputChoice -match '^\d+$') { + $inputChoice = [int]$inputChoice + if ($inputChoice -ge 1 -and $inputChoice -le $ExternalCount) { + $SelectedIndex = $inputChoice - 1 + $ExternalDiskNumber = $ExternalHardDiskDrives[$SelectedIndex].Index + $ExternalDisk = Get-Disk -Number $ExternalDiskNumber + $USBDrives = $ExternalHardDiskDrives[$SelectedIndex] + $USBDrivesCount = $USBDrives.Count + if ($VerbosePreference -ne 'Continue') { + Write-Host "Drive $inputChoice was selected" + } + WriteLog "Drive $inputChoice was selected" + } + else { + # Handle invalid selection + if ($VerbosePreference -ne 'Continue') { + Write-Host "Invalid selection. Please try again." + } + WriteLog "Invalid selection. Please try again." + } + + # Check if the selected drive is offline + if ($ExternalDisk.OperationalStatus -eq 'Offline') { + if ($VerbosePreference -ne 'Continue') { + Write-Error "Selected Drive is in an Offline State. Please check the drive status in Disk Manager and try again." + } + WriteLog "Selected Drive is in an Offline State. Please check the drive status in Disk Manager and try again." + exit 1 + } + } + else { + # Handle invalid input + if ($VerbosePreference -ne 'Continue') { + Write-Host "Invalid selection. Please try again." + } + WriteLog "Invalid selection. Please try again." + } + } while ($null -eq $selectedIndex) + } + } + else { + # Log the count of found USB drives + if ($VerbosePreference -ne 'Continue') { + Write-Host "Found $USBDrivesCount total USB drives" + If ($ExternalCount -gt 0) { + Write-Host "$ExternalCount are external drives" + } + } + WriteLog "Found $USBDrivesCount total USB drives" + If ($ExternalCount -gt 0) { + WriteLog "$ExternalCount are external drives" + } + } + } + else { + # Get only removable media drives + [array]$USBDrives = (Get-WmiObject -Class Win32_DiskDrive -Filter "MediaType='Removable Media'") + $USBDrivesCount = $USBDrives.Count + WriteLog "Found $USBDrivesCount Removable USB drives" + } + + # Check if any USB drives were found + if ($null -eq $USBDrives) { + WriteLog "No removable USB drive found. Exiting" + Write-Error "No removable USB drive found. Exiting" + exit 1 + } + + # Return the found USB drives and their count + return $USBDrives, $USBDrivesCount +} +Function New-DeploymentUSB { + param( + [switch]$CopyFFU + ) + WriteLog "CopyFFU is set to $CopyFFU" + $BuildUSBPath = $PSScriptRoot + WriteLog "BuildUSBPath is $BuildUSBPath" + + $SelectedFFUFile = $null + + # Check if the CopyFFU switch is present + if ($CopyFFU.IsPresent) { + # Get all FFU files in the specified directory + $FFUFiles = Get-ChildItem -Path "$BuildUSBPath\FFU" -Filter "*.ffu" + $FFUCount = $FFUFiles.count + + # If there is exactly one FFU file, select it + if ($FFUCount -eq 1) { + $SelectedFFUFile = $FFUFiles.FullName + } + # If there are multiple FFU files, prompt the user to select one + elseif ($FFUCount -gt 1) { + WriteLog "Found $FFUCount FFU files" + if($VerbosePreference -ne 'Continue'){ + Write-Host "Found $FFUCount FFU files" + } + $output = @() + # Create a table of FFU files with their index, name, and last modified date + for ($i = 0; $i -lt $FFUCount; $i++) { + $index = $i + 1 + $name = $FFUFiles[$i].Name + $modified = $FFUFiles[$i].LastWriteTime + $Properties = [ordered]@{ + 'FFU Number' = $index + 'FFU Name' = $name + 'Last Modified' = $modified + } + $output += New-Object PSObject -Property $Properties + } + $output | Format-Table -AutoSize -Property 'FFU Number', 'FFU Name', 'Last Modified' + + # Loop until a valid FFU file is selected + do { + $inputChoice = Read-Host "Enter the number corresponding to the FFU file you want to copy or 'A' to copy all FFU files" + # Check if the input is a valid number or 'A' + if ($inputChoice -match '^\d+$' -or $inputChoice -eq 'A') { + if ($inputChoice -eq 'A') { + # Select all FFU files + $SelectedFFUFile = $FFUFiles.FullName + if ($VerbosePreference -ne 'Continue') { + Write-Host 'Will copy all FFU files' + } + WriteLog 'Will copy all FFU Files' + } + else { + # Convert input to integer and validate the selection + $inputChoice = [int]$inputChoice + if ($inputChoice -ge 1 -and $inputChoice -le $FFUCount) { + $selectedIndex = $inputChoice - 1 + $SelectedFFUFile = $FFUFiles[$selectedIndex].FullName + if ($VerbosePreference -ne 'Continue') { + Write-Host "$SelectedFFUFile was selected" + } + WriteLog "$SelectedFFUFile was selected" + } + else { + # Handle invalid selection + if ($VerbosePreference -ne 'Continue') { + Write-Host "Invalid selection. Please try again." + } + WriteLog "Invalid selection. Please try again." + } + } + } + else { + # Handle invalid input + if ($VerbosePreference -ne 'Continue') { + Write-Host "Invalid selection. Please try again." + } + WriteLog "Invalid selection. Please try again." + } + } while ($null -eq $SelectedFFUFile) + + } + else { + # Handle case where no FFU files are found + WriteLog "No FFU files found in the current directory." + Write-Error "No FFU files found in the current directory." + Return + } + } + $counter = 0 + + foreach ($USBDrive in $USBDrives) { + $Counter++ + WriteLog "Formatting USB drive $Counter out of $USBDrivesCount" + $DiskNumber = $USBDrive.DeviceID.Replace("\\.\PHYSICALDRIVE", "") + WriteLog "Physical Disk number is $DiskNumber for USB drive $Counter out of $USBDrivesCount" + + $ScriptBlock = { + param($DiskNumber) + $Disk = Get-Disk -Number $DiskNumber + # Clear-Disk -Number $DiskNumber -RemoveData -RemoveOEM -Confirm:$false + # Clear-disk has an unusual behavior where it sets external hard disk media as RAW, however removable media is set as MBR. + if ($Disk.PartitionStyle -ne "RAW") { + $Disk | Clear-Disk -RemoveData -RemoveOEM -Confirm:$false + $Disk = Get-Disk -Number $DiskNumber + } + + if($Disk.PartitionStyle -eq "RAW") { + $Disk | Initialize-Disk -PartitionStyle MBR -Confirm:$false + } + elseif($Disk.PartitionStyle -ne "RAW"){ + $Disk | Get-Partition | Remove-Partition -Confirm:$false + $Disk | Set-Disk -PartitionStyle MBR + } + # Get-Disk $DiskNumber | Get-Partition | Remove-Partition + $BootPartition = $Disk | New-Partition -Size 2GB -IsActive -AssignDriveLetter + $DeployPartition = $Disk | New-Partition -UseMaximumSize -AssignDriveLetter + Format-Volume -Partition $BootPartition -FileSystem FAT32 -NewFileSystemLabel "TempBoot" -Confirm:$false + Format-Volume -Partition $DeployPartition -FileSystem NTFS -NewFileSystemLabel "TempDeploy" -Confirm:$false + } + + WriteLog 'Partitioning USB Drive' + Invoke-Command -ScriptBlock $ScriptBlock -ArgumentList $DiskNumber | Out-null + WriteLog 'Done' + + # $BootPartitionDriveLetter = (Get-WmiObject -Class win32_volume -Filter "Label='TempBoot' AND DriveType=2 AND DriveLetter IS NOT NULL").Name + $BootPartitionDriveLetter = (Get-WmiObject -Class win32_volume -Filter "Label='TempBoot' AND DriveLetter IS NOT NULL").Name + $ISOMountPoint = (Mount-DiskImage -ImagePath $DeployISO -PassThru | Get-Volume).DriveLetter + ":\" + WriteLog "Copying WinPE files to $BootPartitionDriveLetter" + robocopy "$ISOMountPoint" "$BootPartitionDriveLetter" /E /COPYALL /R:5 /W:5 /J + Dismount-DiskImage -ImagePath $DeployISO | Out-Null + + if ($CopyFFU.IsPresent) { + if ($null -ne $SelectedFFUFile) { + # $DeployPartitionDriveLetter = (Get-WmiObject -Class win32_volume -Filter "Label='TempDeploy' AND DriveType=2 AND DriveLetter IS NOT NULL").Name + $DeployPartitionDriveLetter = (Get-WmiObject -Class win32_volume -Filter "Label='TempDeploy' AND DriveLetter IS NOT NULL").Name + if ($SelectedFFUFile -is [array]) { + WriteLog "Copying multiple FFU files to $DeployPartitionDriveLetter. This could take a few minutes." + foreach ($FFUFile in $SelectedFFUFile) { + robocopy $(Split-Path $FFUFile -Parent) $DeployPartitionDriveLetter $(Split-Path $FFUFile -Leaf) /COPYALL /R:5 /W:5 /J + } + } + else { + WriteLog ("Copying " + $SelectedFFUFile + " to $DeployPartitionDriveLetter. This could take a few minutes.") + robocopy $(Split-Path $SelectedFFUFile -Parent) $DeployPartitionDriveLetter $(Split-Path $SelectedFFUFile -Leaf) /COPYALL /R:5 /W:5 /J + } + #Copy drivers using robocopy due to potential size + if ($CopyDrivers) { + WriteLog "Copying drivers to $DeployPartitionDriveLetter\Drivers" + if ($Make){ + robocopy "$DriversFolder\$Make" "$DeployPartitionDriveLetter\Drivers" /E /R:5 /W:5 /J + }else{ + robocopy "$DriversFolder" "$DeployPartitionDriveLetter\Drivers" /E /R:5 /W:5 /J + } + + } + #Copy Unattend file to the USB drive. + if ($CopyUnattend) { + # WriteLog "Copying Unattend folder to $DeployPartitionDriveLetter" + # Copy-Item -Path "$FFUDevelopmentPath\Unattend" -Destination $DeployPartitionDriveLetter -Recurse -Force + $DeployUnattendPath = "$DeployPartitionDriveLetter\unattend" + WriteLog "Copying unattend file to $DeployUnattendPath" + New-Item -Path $DeployUnattendPath -ItemType Directory | Out-Null + if ($WindowsArch -eq 'x64') { + Copy-Item -Path "$FFUDevelopmentPath\unattend\unattend_x64.xml" -Destination "$DeployUnattendPath\Unattend.xml" -Force | Out-Null + } + else { + Copy-Item -Path "$FFUDevelopmentPath\unattend\unattend_arm64.xml" -Destination "$DeployUnattendPath\Unattend.xml" -Force | Out-Null + } + WriteLog 'Copy completed' + } + #Copy PPKG folder in the FFU folder to the USB drive. Can use copy-item as it's a small folder + if ($CopyPPKG) { + WriteLog "Copying PPKG folder to $DeployPartitionDriveLetter" + Copy-Item -Path "$FFUDevelopmentPath\PPKG" -Destination $DeployPartitionDriveLetter -Recurse -Force + } + #Copy Autopilot folder in the FFU folder to the USB drive. Can use copy-item as it's a small folder + if ($CopyAutopilot) { + WriteLog "Copying Autopilot folder to $DeployPartitionDriveLetter" + Copy-Item -Path "$FFUDevelopmentPath\Autopilot" -Destination $DeployPartitionDriveLetter -Recurse -Force + } + } + else { + WriteLog "No FFU file selected. Skipping copy." + } + } + + Set-Volume -FileSystemLabel "TempBoot" -NewFileSystemLabel "Boot" + Set-Volume -FileSystemLabel "TempDeploy" -NewFileSystemLabel "Deploy" + + if ($USBDrivesCount -gt 1) { + & mountvol $BootPartitionDriveLetter /D + & mountvol $DeployPartitionDriveLetter /D + } + + WriteLog "Drive $counter completed" + } + + WriteLog "USB Drives completed" +} + + +function Get-FFUEnvironment { + WriteLog 'Dirty.txt file detected. Last run did not complete succesfully. Will clean environment' + # Check for running VMs that start with '_FFU-' and are in the 'Off' state + $vms = Get-VM + + # Loop through each VM + foreach ($vm in $vms) { + if ($vm.Name.StartsWith("_FFU-")) { + if ($vm.State -eq 'Running') { + Stop-VM -Name $vm.Name -TurnOff -Force + } + # If conditions are met, delete the VM + Remove-FFUVM -VMName $vm.Name + } + } + # Check for MSFT Virtual disks where location contains FFUDevelopment in the path + $disks = Get-Disk -FriendlyName *virtual* + foreach ($disk in $disks) { + $diskNumber = $disk.Number + $vhdLocation = $disk.Location + if ($vhdLocation -like "*FFUDevelopment*") { + WriteLog "Dismounting Virtual Disk $diskNumber with Location $vhdLocation" + Dismount-ScratchVhdx -VhdxPath $vhdLocation + $parentFolder = Split-Path -Parent $vhdLocation + WriteLog "Removing folder $parentFolder" + Remove-Item -Path $parentFolder -Recurse -Force + } + } + + # Check for mounted DiskImages + $volumes = Get-Volume | Where-Object { $_.DriveType -eq 'CD-ROM' } + foreach ($volume in $volumes) { + $letter = $volume.DriveLetter + WriteLog "Dismounting DiskImage for volume $letter" + Get-Volume $letter | Get-DiskImage | Dismount-DiskImage | Out-Null + WriteLog "Dismounting complete" + } + + # Remove unused mountpoints + WriteLog 'Remove unused mountpoints' + Invoke-Process cmd "/c mountvol /r" + WriteLog 'Removal complete' + + # Check for content in the VM folder and delete any folders that start with _FFU- + $folders = Get-ChildItem -Path $VMLocation -Directory + foreach ($folder in $folders) { + if ($folder.Name -like '_FFU-*') { + WriteLog "Removing folder $($folder.FullName)" + Remove-Item -Path $folder.FullName -Recurse -Force + } + } + + # Remove orphaned mounted images + $mountedImages = Get-WindowsImage -Mounted + if ($mountedImages) { + foreach ($image in $mountedImages) { + $mountPath = $image.Path + WriteLog "Dismounting image at $mountPath" + Dismount-WindowsImage -Path $mountPath -discard | Out-null + WriteLog "Successfully dismounted image at $mountPath" + } + } + + # Remove Mount folder if it exists + if (Test-Path -Path "$FFUDevelopmentPath\Mount") { + WriteLog "Remove $FFUDevelopmentPath\Mount folder" + Remove-Item -Path "$FFUDevelopmentPath\Mount" -Recurse -Force + WriteLog 'Folder removed' + } + + #Clear any corrupt Windows mount points + WriteLog 'Clearing any corrupt Windows mount points' + Clear-WindowsCorruptMountPoint | Out-null + WriteLog 'Complete' + + #Clean up registry + if (Test-Path -Path 'HKLM:\FFU') { + Writelog 'Found HKLM:\FFU, removing it' + Invoke-Process reg "unload HKLM\FFU" + } + + #Remove FFU User and Share + $UserExists = Get-LocalUser -Name $UserName -ErrorAction SilentlyContinue + if ($UserExists) { + WriteLog "Removing FFU User and Share" + Remove-FFUUserShare + WriteLog 'Removal complete' + } + #Clean up $KBPath + If (Test-Path -Path $KBPath) { + WriteLog "Removing $KBPath" + Remove-Item -Path $KBPath -Recurse -Force -ErrorAction SilentlyContinue + WriteLog 'Removal complete' + } + #Clean up $DefenderPath + If (Test-Path -Path $DefenderPath) { + WriteLog "Removing $DefenderPath" + Remove-Item -Path $DefenderPath -Recurse -Force -ErrorAction SilentlyContinue + WriteLog 'Removal complete' + } + #Clean up $OneDrivePath + If (Test-Path -Path $OneDrivePath) { + WriteLog "Removing $OneDrivePath" + Remove-Item -Path $OneDrivePath -Recurse -Force -ErrorAction SilentlyContinue + WriteLog 'Removal complete' + } + #Clean up $EdgePath + If (Test-Path -Path $EdgePath) { + WriteLog "Removing $EdgePath" + Remove-Item -Path $EdgePath -Recurse -Force -ErrorAction SilentlyContinue + WriteLog 'Removal complete' + } + if (Test-Path -Path "$AppsPath\Win32" -PathType Container) { + WriteLog "Cleaning up Win32 folder" + Remove-Item -Path "$AppsPath\Win32" -Recurse -Force -ErrorAction SilentlyContinue + } + if (Test-Path -Path "$AppsPath\MSStore" -PathType Container) { + WriteLog "Cleaning up MSStore folder" + Remove-Item -Path "$AppsPath\MSStore" -Recurse -Force -ErrorAction SilentlyContinue + } + Clear-InstallAppsandSysprep + Writelog 'Removing dirty.txt file' + Remove-Item -Path "$FFUDevelopmentPath\dirty.txt" -Force + WriteLog "Cleanup complete" +} +function Remove-FFU { + #Remove all FFU files in the FFUCaptureLocation + WriteLog "Removing all FFU files in $FFUCaptureLocation" + Remove-Item -Path $FFUCaptureLocation\*.ffu -Force + WriteLog "Removal complete" +} +function Clear-InstallAppsandSysprep { + $cmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" + WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to remove win32 app install commands" + $cmdContent -notmatch "REM Win32*" | Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" + $cmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" + $cmdContent -notmatch "D:\\win32*" | Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" + $cmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" + WriteLog "Setting MSStore installation condition to false" + $cmdContent -replace 'set "INSTALL_STOREAPPS=true"', 'set "INSTALL_STOREAPPS=false"' | Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" + if ($UpdateLatestDefender) { + WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to remove Defender Platform Update" + $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" + $CmdContent -notmatch 'd:\\Defender*' | Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" + # #Remove $DefenderPath + # WriteLog "Removing $DefenderPath" + # Remove-Item -Path $DefenderPath -Recurse -Force + # WriteLog "Removal complete" + + } + if ($UpdateOneDrive) { + WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to remove OneDrive install" + $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" + $CmdContent -notmatch 'd:\\OneDrive*' | Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" + # #Remove $OneDrivePath + # WriteLog "Removing $OneDrivePath" + # Remove-Item -Path $OneDrivePath -Recurse -Force + # WriteLog "Removal complete" + } + if ($UpdateEdge) { + WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to remove Edge install" + $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" + $CmdContent -notmatch 'd:\\Edge*' | Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" + # #Remove $EdgePath + # WriteLog "Removing $EdgePath" + # Remove-Item -Path $EdgePath -Recurse -Force + # WriteLog "Removal complete" + } +} + +###END FUNCTIONS + +#Remove old log file if found +if (Test-Path -Path $Logfile) { + Remove-item -Path $LogFile -Force +} +$startTime = Get-Date +Write-Host "FFU build process started at" $startTime +Write-Host "This process can take 20 minutes or more. Please do not close this window or any additional windows that pop up" +Write-Host "To track progress, please open the log file $Logfile or use the -Verbose parameter next time" + +WriteLog 'Begin Logging' + +#Override $InstallApps value if using ESD to build FFU. This is due to a strange issue where building the FFU +#from vhdx doesn't work (you get an older style OOBE screen and get stuck in an OOBE reboot loop when hitting next). +#This behavior doesn't happen with WIM files. +If (-not ($ISOPath) -and (-not ($InstallApps))) { + $InstallApps = $true + WriteLog "Script will download Windows media. Setting `$InstallApps to `$true to build VM to capture FFU. Must do this when using MCT ESD." +} + +if (($InstallOffice -eq $true) -and ($InstallApps -eq $false)) { + throw "If variable InstallOffice is set to `$true, InstallApps must also be set to `$true." +} +if (($InstallApps -and ($VMSwitchName -eq ''))) { + throw "If variable InstallApps is set to `$true, VMSwitchName must also be set to capture the FFU. Please set -VMSwitchName and try again." +} + +if (($InstallApps -and ($VMHostIPAddress -eq ''))) { + throw "If variable InstallApps is set to `$true, VMHostIPAddress must also be set to capture the FFU. Please set -VMHostIPAddress and try again." +} + +if (-not ($ISOPath) -and ($OptionalFeatures -like '*netfx3*')) { + throw "netfx3 specified as an optional feature, however Windows ISO isn't defined. Unable to get netfx3 source files from downloaded ESD media. Please specify a Windows ISO in the ISOPath parameter." +} +if (($LogicalSectorSizeBytes -eq 4096) -and ($installdrivers -eq $true)) { + $installdrivers = $false + $CopyDrivers = $true + WriteLog 'LogicalSectorSizeBytes is set to 4096, which is not supported for driver injection. Setting $installdrivers to $false' + WriteLog 'As a workaround, setting -copydrivers $true to copy drivers to the deploy partition drivers folder' + WriteLog 'We are investigating this issue and will update the script if/when we have a fix' +} +if ($BuildUSBDrive -eq $true) { + $USBDrives, $USBDrivesCount = Get-USBDrive +} +if (($InstallApps -eq $false) -and (($UpdateLatestDefender -eq $true) -or ($UpdateOneDrive -eq $true) -or ($UpdateEdge -eq $true))) { + WriteLog 'You have selected to update Defender, OneDrive, or Edge, however you are setting InstallApps to false. These updates require the InstallApps variable to be set to true. Please set InstallApps to true and try again.' + throw "InstallApps variable must be set to `$true to update Defender, OneDrive, or Edge" +} +if (($WindowsArch -eq 'ARM64') -and ($InstallOffice -eq $true)) { + $InstallOffice = $false + WriteLog 'M365 Apps/Office currently fails to install on ARM64 VMs without an internet connection. Setting InstallOffice to false' +} + +if (($WindowsArch -eq 'ARM64') -and ($UpdateOneDrive -eq $true)) { + $UpdateOneDrive = $false + WriteLog 'OneDrive currently fails to install on ARM64 VMs (even with the OneDrive ARM setup files). Setting UpdateOneDrive to false' +} +# if(($WindowsArch -eq 'ARM64') -and ($UpdateLatestDefender -eq $true)){ +# $UpdateLatestDefender = $false +# WriteLog 'Defender ARM and x64 updates currently fail to install on ARM64 VMs. Setting UpdateLatestDefender to false' +# } + +#Get script variable values +LogVariableValues + +#Check if environment is dirty +If (Test-Path -Path "$FFUDevelopmentPath\dirty.txt") { + Get-FFUEnvironment +} +WriteLog 'Creating dirty.txt file' +New-Item -Path .\ -Name "dirty.txt" -ItemType "file" | Out-Null + +#Get drivers first since user could be prompted for additional info +if (($make -and $model) -and ($installdrivers -or $copydrivers)) { + try { + if ($Make -eq 'HP'){ + WriteLog 'Getting HP drivers' + Get-HPDrivers -Make $Make -Model $Model -WindowsArch $WindowsArch -WindowsRelease $WindowsRelease -WindowsVersion $WindowsVersion + WriteLog 'Getting HP drivers completed successfully' + } + if ($make -eq 'Microsoft'){ + WriteLog 'Getting Microsoft drivers' + Get-MicrosoftDrivers -Make $Make -Model $Model -WindowsArch $WindowsArch -WindowsRelease $WindowsRelease + WriteLog 'Getting Microsoft drivers completed successfully' + } + if ($make -eq 'Lenovo'){ + WriteLog 'Getting Lenovo drivers' + Get-LenovoDrivers -Model $Model -WindowsArch $WindowsArch -WindowsRelease $WindowsRelease + WriteLog 'Getting Lenovo drivers completed successfully' + } + if ($make -eq 'Dell'){ + WriteLog 'Getting Dell drivers' + #Dell mixes Win10 and 11 drivers, hence no WindowsRelease parameter + Get-DellDrivers -Model $Model -WindowsArch $WindowsArch + WriteLog 'Getting Dell drivers completed successfully' + } + } + catch { + Writelog "Getting drivers failed with error $_" + throw $_ + } + +} + +#Get Windows ADK +try { + $adkPath = Get-ADK + #Need to use the Deployment and Imaging tools environment to use dism from the Sept 2023 ADK to optimize FFU + $DandIEnv = "$adkPath`Assessment and Deployment Kit\Deployment Tools\DandISetEnv.bat" +} +catch { + WriteLog 'ADK not found' + throw $_ +} + +#Create apps ISO for Office and/or 3rd party apps +if ($InstallApps) { + try { + #Make sure InstallAppsandSysprep.cmd file exists + WriteLog "InstallApps variable set to true, verifying $AppsPath\InstallAppsandSysprep.cmd exists" + if (-not (Test-Path -Path "$AppsPath\InstallAppsandSysprep.cmd")) { + Write-Host "$AppsPath\InstallAppsandSysprep.cmd is missing, exiting script" + WriteLog "$AppsPath\InstallAppsandSysprep.cmd is missing, exiting script" + exit + } + WriteLog "$AppsPath\InstallAppsandSysprep.cmd found" + If (Test-Path -Path "$AppsPath\AppList.json"){ + WriteLog "$AppsPath\AppList.json found, checking for winget apps to install" + Get-Apps -AppList "$AppsPath\AppList.json" + } + + if (-not $InstallOffice) { + #Modify InstallAppsandSysprep.cmd to REM out the office install command + $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" + $UpdatedcmdContent = $CmdContent -replace '^(d:\\Office\\setup.exe /configure d:\\office\\DeployFFU.xml)', ("REM d:\Office\setup.exe /configure d:\office\DeployFFU.xml") + Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" -Value $UpdatedcmdContent + } + + if ($InstallOffice) { + WriteLog 'Downloading M365 Apps/Office' + Get-Office + WriteLog 'Downloading M365 Apps/Office completed successfully' + } + + #Update Latest Defender Platform and Definitions - these can't be serviced into the VHDX, will be saved to AppsPath + if ($UpdateLatestDefender) { + WriteLog "`$UpdateLatestDefender is set to true, checking for latest Defender Platform and Definitions" + $Name = "Update for Microsoft Defender Antivirus antimalware platform" + #Check if $DefenderPath exists, if not, create it + If (-not (Test-Path -Path $DefenderPath)) { + WriteLog "Creating $DefenderPath" + New-Item -Path $DefenderPath -ItemType Directory -Force | Out-Null + } + WriteLog "Searching for $Name from Microsoft Update Catalog and saving to $DefenderPath" + $KBFilePath = Save-KB -Name $Name -Path $DefenderPath + WriteLog "Latest Defender Platform and Definitions saved to $DefenderPath\$KBFilePath" + + #Modify InstallAppsandSysprep.cmd to add in $KBFilePath on the line after REM Install Defender Update Platform + WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to include Defender Platform Update" + $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" + $UpdatedcmdContent = $CmdContent -replace '^(REM Install Defender Platform Update)', ("REM Install Defender Platform Update`r`nd:\Defender\$KBFilePath") + Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" -Value $UpdatedcmdContent + WriteLog "Update complete" + + #Get Windows Security platform update + $Name = "Windows Security platform definition updates" + WriteLog "Searching for $Name from Microsoft Update Catalog and saving to $DefenderPath" + $KBFilePath = Save-KB -Name $Name -Path $DefenderPath + WriteLog "Latest Security Platform Update saved to $DefenderPath\$KBFilePath" + #Modify InstallAppsandSysprep.cmd to add in $KBFilePath on the line after REM Install Windows Security Platform Update + WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to include Windows Security Platform Update" + $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" + $UpdatedcmdContent = $CmdContent -replace '^(REM Install Windows Security Platform Update)', ("REM Install Windows Security Platform Update`r`nd:\Defender\$KBFilePath") + Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" -Value $UpdatedcmdContent + WriteLog "Update complete" + + #Download latest Defender Definitions + WriteLog "Downloading latest Defender Definitions" + # Defender def updates can be found https://www.microsoft.com/en-us/wdsi/defenderupdates + if ($WindowsArch -eq 'x64') { + $DefenderDefURL = 'https://go.microsoft.com/fwlink/?LinkID=121721&arch=x64' + } + if ($WindowsArch -eq 'ARM64') { + $DefenderDefURL = 'https://go.microsoft.com/fwlink/?LinkID=121721&arch=arm64' + } + try { + WriteLog "Defender definitions URL is $DefenderDefURL" + Start-BitsTransferWithRetry -Source $DefenderDefURL -Destination "$DefenderPath\mpam-fe.exe" + WriteLog "Defender Definitions downloaded to $DefenderPath\mpam-fe.exe" + } + catch { + Write-Host "Downloading Defender Definitions Failed" + WriteLog "Downloading Defender Definitions Failed with error $_" + throw $_ + } + + #Modify InstallAppsandSysprep.cmd to add in $DefenderPath on the line after REM Install Defender Definitions + WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to include Defender Definitions" + $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" + $UpdatedcmdContent = $CmdContent -replace '^(REM Install Defender Definitions)', ("REM Install Defender Definitions`r`nd:\Defender\mpam-fe.exe") + Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" -Value $UpdatedcmdContent + WriteLog "Update complete" + } + #Download and Install OneDrive Per Machine + if ($UpdateOneDrive) { + WriteLog "`$UpdateOneDrive is set to true, checking for latest OneDrive client" + #Check if $OneDrivePath exists, if not, create it + If (-not (Test-Path -Path $OneDrivePath)) { + WriteLog "Creating $OneDrivePath" + New-Item -Path $OneDrivePath -ItemType Directory -Force | Out-Null + } + WriteLog "Downloading latest OneDrive client" + if($WindowsArch -eq 'x64') + { + $OneDriveURL = 'https://go.microsoft.com/fwlink/?linkid=844652' + } + elseif($WindowsArch -eq 'ARM64') + { + $OneDriveURL = 'https://go.microsoft.com/fwlink/?linkid=2271260' + } + try { + Start-BitsTransferWithRetry -Source $OneDriveURL -Destination "$OneDrivePath\OneDriveSetup.exe" + WriteLog "OneDrive client downloaded to $OneDrivePath\OneDriveSetup.exe" + } + catch { + Write-Host "Downloading OneDrive client Failed" + WriteLog "Downloading OneDrive client Failed with error $_" + throw $_ + } + + #Modify InstallAppsandSysprep.cmd to add in $OneDrivePath on the line after REM Install Defender Definitions + WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to include OneDrive client" + $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" + $UpdatedcmdContent = $CmdContent -replace '^(REM Install OneDrive Per Machine)', ("REM Install OneDrive Per Machine`r`nd:\OneDrive\OneDriveSetup.exe /allusers") + Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" -Value $UpdatedcmdContent + WriteLog "Update complete" + } + + #Download and Install Edge Stable + if ($UpdateEdge) { + WriteLog "`$UpdateEdge is set to true, checking for latest Edge Stable $WindowsArch release" + $Name = "microsoft edge stable -extended $WindowsArch" + #Check if $EdgePath exists, if not, create it + If (-not (Test-Path -Path $EdgePath)) { + WriteLog "Creating $EdgePath" + New-Item -Path $EdgePath -ItemType Directory -Force | Out-Null + } + WriteLog "Searching for $Name from Microsoft Update Catalog and saving to $EdgePath" + $KBFilePath = Save-KB -Name $Name -Path $EdgePath + $EdgeCABFilePath = "$EdgePath\$KBFilePath" + WriteLog "Latest Edge Stable $WindowsArch release saved to $EdgeCABFilePath" + + #Extract Edge cab file to same folder as $EdgeFilePath + $EdgeMSIFileName = "MicrosoftEdgeEnterprise$WindowsArch.msi" + $EdgeFullFilePath = "$EdgePath\$EdgeMSIFileName" + WriteLog "Expanding $EdgeCABFilePath" + Invoke-Process Expand "$EdgeCABFilePath -F:*.msi $EdgeFullFilePath" + WriteLog "Expansion complete" + + #Remove Edge CAB file + WriteLog "Removing $EdgeCABFilePath" + Remove-Item -Path $EdgeCABFilePath -Force + WriteLog "Removal complete" + + #Modify InstallAppsandSysprep.cmd to add in $KBFilePath on the line after REM Install Edge Stable + WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to include Edge Stable $WindowsArch release" + $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" + $UpdatedcmdContent = $CmdContent -replace '^(REM Install Edge Stable)', ("REM Install Edge Stable`r`nd:\Edge\$EdgeMSIFileName /quiet /norestart") + Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" -Value $UpdatedcmdContent + WriteLog "Update complete" + } + #Create Apps ISO + WriteLog "Creating $AppsISO file" + New-AppsISO + WriteLog "$AppsISO created successfully" + } + catch { + Write-Host "Creating Apps ISO Failed" + WriteLog "Creating Apps ISO Failed with error $_" + throw $_ + } +} + +#Create VHDX +try { + + if ($ISOPath) { + $wimPath = Get-WimFromISO + } + else { + $wimPath = Get-WindowsESD -WindowsRelease $WindowsRelease -WindowsArch $WindowsArch -WindowsLang $WindowsLang -MediaType $mediaType + } + #If index not specified by user, try and find based on WindowsSKU + if (-not($index) -and ($WindowsSKU)) { + $index = Get-Index -WindowsImagePath $wimPath -WindowsSKU $WindowsSKU + } + + $vhdxDisk = New-ScratchVhdx -VhdxPath $VHDXPath -SizeBytes $disksize -LogicalSectorSizeBytes $LogicalSectorSizeBytes + + $systemPartitionDriveLetter = New-SystemPartition -VhdxDisk $vhdxDisk + + New-MSRPartition -VhdxDisk $vhdxDisk + + $osPartition = New-OSPartition -VhdxDisk $vhdxDisk -OSPartitionSize $OSPartitionSize -WimPath $WimPath -WimIndex $index + $osPartitionDriveLetter = $osPartition[1].DriveLetter + $WindowsPartition = $osPartitionDriveLetter + ":\" + + #$recoveryPartition = New-RecoveryPartition -VhdxDisk $vhdxDisk -OsPartition $osPartition[1] -RecoveryPartitionSize $RecoveryPartitionSize -DataPartition $dataPartition + $recoveryPartition = New-RecoveryPartition -VhdxDisk $vhdxDisk -OsPartition $osPartition[1] -RecoveryPartitionSize $RecoveryPartitionSize -DataPartition $dataPartition + + WriteLog "All necessary partitions created." + + Add-BootFiles -OsPartitionDriveLetter $osPartitionDriveLetter -SystemPartitionDriveLetter $systemPartitionDriveLetter[1] + + #Update latest Cumulative Update + #Changed to use MU Catalog instead of using Get-LatestWindowsKB + #The Windows release info page is updated later than the MU Catalog + if ($UpdateLatestCU) { + Writelog "`$UpdateLatestCU is set to true, checking for latest CU" + $Name = """Cumulative update for Windows $WindowsRelease Version $WindowsVersion for $WindowsArch""" + #Check if $KBPath exists, if not, create it + If (-not (Test-Path -Path $KBPath)) { + WriteLog "Creating $KBPath" + New-Item -Path $KBPath -ItemType Directory -Force | Out-Null + } + WriteLog "Searching for $name from Microsoft Update Catalog and saving to $KBPath" + $KBFilePath = Save-KB -Name $Name -Path $KBPath + WriteLog "Latest CU saved to $KBPath\$KBFilePath" + } + + + #Update Latest .NET Framework + if ($UpdateLatestNet) { + Writelog "`$UpdateLatestNet is set to true, checking for latest .NET Framework" + $Name = "Cumulative update for .net framework windows $WindowsRelease $WindowsVersion $WindowsArch -preview" + #Check if $KBPath exists, if not, create it + If (-not (Test-Path -Path $KBPath)) { + WriteLog "Creating $KBPath" + New-Item -Path $KBPath -ItemType Directory -Force | Out-Null + } + WriteLog "Searching for $name from Microsoft Update Catalog and saving to $KBPath" + $KBFilePath = Save-KB -Name $Name -Path $KBPath + WriteLog "Latest .NET saved to $KBPath\$KBFilePath" + } + #Update Latest Security Platform Update + if ($UpdateSecurityPlatform) { + WriteLog "`$UpdateSecurityPlatform is set to true, checking for latest Security Platform Update" + $Name = "Windows Security platform definition updates" + #Check if $KBPath exists, if not, create it + If (-not (Test-Path -Path $KBPath)) { + WriteLog "Creating $KBPath" + New-Item -Path $KBPath -ItemType Directory -Force | Out-Null + } + WriteLog "Searching for $Name from Microsoft Update Catalog and saving to $KBPath" + $KBFilePath = Save-KB -Name $Name -Path $KBPath + WriteLog "Latest Security Platform Update saved to $KBPath\$KBFilePath" + } + + + #Add Windows packages + if ($UpdateLatestCU -or $UpdateLatestNet) { + try { + WriteLog "Adding KBs to $WindowsPartition" + WriteLog 'This can take 10+ minutes depending on how old the media is and the size of the KB. Please be patient' + Add-WindowsPackage -Path $WindowsPartition -PackagePath $KBPath -PreventPending | Out-Null + WriteLog "KBs added to $WindowsPartition" + WriteLog "Removing $KBPath" + Remove-Item -Path $KBPath -Recurse -Force | Out-Null + WriteLog "Clean Up the WinSxS Folder" + Dism /Image:$WindowsPartition /Cleanup-Image /StartComponentCleanup /ResetBase | Out-Null + WriteLog "Clean Up the WinSxS Folder completed" + } + catch { + Write-Host "Adding KB to VHDX failed with error $_" + WriteLog "Adding KB to VHDX failed with error $_" + throw $_ + } + } + + + #Enable Windows Optional Features (e.g. .Net3, etc) + If ($OptionalFeatures) { + $Source = Join-Path (Split-Path $wimpath) "sxs" + Enable-WindowsFeaturesByName -FeatureNames $OptionalFeatures -Source $Source + } + + #Set Product key + If ($ProductKey) { + WriteLog "Setting Windows Product Key" + Set-WindowsProductKey -Path $WindowsPartition -ProductKey $ProductKey + } + If ($ISOPath) { + WriteLog 'Dismounting Windows ISO' + Dismount-DiskImage -ImagePath $ISOPath | Out-null + WriteLog 'Done' + } + else { + #Remove ESD file + Remove-Item -Path $wimPath -Force + } + + + If ($InstallApps) { + #Copy Unattend file so VM Boots into Audit Mode + WriteLog 'Copying unattend file to boot to audit mode' + New-Item -Path "$($osPartitionDriveLetter):\Windows\Panther\unattend" -ItemType Directory | Out-Null + if($WindowsArch -eq 'x64'){ + Copy-Item -Path "$FFUDevelopmentPath\BuildFFUUnattend\unattend_x64.xml" -Destination "$($osPartitionDriveLetter):\Windows\Panther\Unattend\Unattend.xml" -Force | Out-Null + } + else { + Copy-Item -Path "$FFUDevelopmentPath\BuildFFUUnattend\unattend_arm64.xml" -Destination "$($osPartitionDriveLetter):\Windows\Panther\Unattend\Unattend.xml" -Force | Out-Null + } + WriteLog 'Copy completed' + Dismount-ScratchVhdx -VhdxPath $VHDXPath + } +} +catch { + Write-Host 'Creating VHDX Failed' + WriteLog "Creating VHDX Failed with error $_" + WriteLog "Dismounting $VHDXPath" + Dismount-ScratchVhdx -VhdxPath $VHDXPath + WriteLog "Removing $VMPath" + Remove-Item -Path $VMPath -Force -Recurse | Out-Null + WriteLog 'Removal complete' + If ($ISOPath) { + WriteLog 'Dismounting Windows ISO' + Dismount-DiskImage -ImagePath $ISOPath | Out-null + WriteLog 'Done' + } + else { + #Remove ESD file + WriteLog "Deleting ESD file" + Remove-Item -Path $wimPath -Force + WriteLog "ESD File deleted" + } + throw $_ + +} + +#If installing apps (Office or 3rd party), we need to build a VM and capture that FFU, if not, just cut the FFU from the VHDX file +if ($InstallApps) { + #Create VM and attach VHDX + try { + WriteLog 'Creating new FFU VM' + $FFUVM = New-FFUVM + WriteLog 'FFU VM Created' + } + catch { + Write-Host 'VM creation failed' + Writelog "VM creation failed with error $_" + Remove-FFUVM -VMName $VMName + throw $_ + + } + #Create ffu user and share to capture FFU to + try { + Set-CaptureFFU + } + catch { + Write-Host 'Set-CaptureFFU function failed' + WriteLog "Set-CaptureFFU function failed with error $_" + Remove-FFUVM -VMName $VMName + throw $_ + + } + If ($CreateCaptureMedia) { + #Create Capture Media + try { + #This should happen while the FFUVM is building + New-PEMedia -Capture $true + } + catch { + Write-Host 'Creating capture media failed' + WriteLog "Creating capture media failed with error $_" + Remove-FFUVM -VMName $VMName + throw $_ + + } + } +} +#Capture FFU file +try { + #Check for FFU Folder and create it if it's missing + 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" + } + #Check if VM is done provisioning + If ($InstallApps) { + do { + $FFUVM = Get-VM -Name $FFUVM.Name + Start-Sleep -Seconds 10 + WriteLog 'Waiting for VM to shutdown' + } while ($FFUVM.State -ne 'Off') + WriteLog 'VM Shutdown' + Optimize-FFUCaptureDrive -VhdxPath $VHDXPath + #Capture FFU file + New-FFU $FFUVM.Name + } + else { + New-FFU + } +} +Catch { + Write-Host 'Capturing FFU file failed' + Writelog "Capturing FFU file failed with error $_" + If ($InstallApps) { + Remove-FFUVM -VMName $VMName + } + else { + Remove-FFUVM + } + + throw $_ + +} +#Clean up ffu_user and Share and clean up apps +If ($InstallApps) { + try { + Remove-FFUUserShare + } + catch { + Write-Host 'Cleaning up FFU User and/or share failed' + WriteLog "Cleaning up FFU User and/or share failed with error $_" + Remove-FFUVM -VMName $VMName + throw $_ + } + #Clean up InstallAppsandSysprep.cmd + try { + WriteLog "Cleaning up $AppsPath\InstallAppsandSysprep.cmd" + Clear-InstallAppsandSysprep + } + catch { + Write-Host 'Cleaning up InstallAppsandSysprep.cmd failed' + Writelog "Cleaning up InstallAppsandSysprep.cmd failed with error $_" + throw $_ + } + try { + if (Test-Path -Path "$AppsPath\Win32" -PathType Container) { + WriteLog "Cleaning up Win32 folder" + Remove-Item -Path "$AppsPath\Win32" -Recurse -Force + } + if (Test-Path -Path "$AppsPath\MSStore" -PathType Container) { + WriteLog "Cleaning up MSStore folder" + Remove-Item -Path "$AppsPath\MSStore" -Recurse -Force + } + } + catch { + WriteLog "$_" + throw $_ + } +} +#Clean up VM or VHDX +try { + Remove-FFUVM + WriteLog 'FFU build complete!' +} +catch { + Write-Host 'VM or vhdx cleanup failed' + Writelog "VM or vhdx cleanup failed with error $_" + throw $_ +} + +#Clean up InstallAppsandSysprep.cmd +try { + WriteLog "Cleaning up $AppsPath\InstallAppsandSysprep.cmd" + Clear-InstallAppsandSysprep +} +catch { + Write-Host 'Cleaning up InstallAppsandSysprep.cmd failed' + Writelog "Cleaning up InstallAppsandSysprep.cmd failed with error $_" + throw $_ +} +try { + if (Test-Path -Path "$AppsPath\Win32" -PathType Container) { + WriteLog "Cleaning up Win32 folder" + Remove-Item -Path "$AppsPath\Win32" -Recurse -Force + } + if (Test-Path -Path "$AppsPath\MSStore" -PathType Container) { + WriteLog "Cleaning up MSStore folder" + Remove-Item -Path "$AppsPath\MSStore" -Recurse -Force + } +} +catch { + WriteLog "$_" + throw $_ +} +#Create Deployment Media +If ($CreateDeploymentMedia) { + try { + New-PEMedia -Deploy $true + } + catch { + Write-Host 'Creating deployment media failed' + WriteLog "Creating deployment media failed with error $_" + throw $_ + + } +} +If ($BuildUSBDrive) { + try { + If (Test-Path -Path $DeployISO) { + New-DeploymentUSB -CopyFFU + } + else { + WriteLog "$BuildUSBDrive set to true, however unable to find $DeployISO. USB drive not built." + } + + } + catch { + Write-Host 'Building USB deployment drive failed' + Writelog "Building USB deployment drive failed with error $_" + throw $_ + } +} +If ($RemoveFFU) { + try { + Remove-FFU + } + catch { + Write-Host 'Removing FFU files failed' + Writelog "Removing FFU files failed with error $_" + throw $_ + } + +} +If ($CleanupCaptureISO) { + try { + If (Test-Path -Path $CaptureISO) { + WriteLog "Removing $CaptureISO" + Remove-Item -Path $CaptureISO -Force + WriteLog "Removal complete" + } + } + catch { + Writelog "Removing $CaptureISO failed with error $_" + throw $_ + } +} +If ($CleanupDeployISO) { + try { + If (Test-Path -Path $DeployISO) { + WriteLog "Removing $DeployISO" + Remove-Item -Path $DeployISO -Force + WriteLog "Removal complete" + } + } + catch { + Writelog "Removing $DeployISO failed with error $_" + throw $_ + } +} +If ($CleanupAppsISO) { + try { + If (Test-Path -Path $AppsISO) { + WriteLog "Removing $AppsISO" + Remove-Item -Path $AppsISO -Force + WriteLog "Removal complete" + } + } + catch { + Writelog "Removing $AppsISO failed with error $_" + throw $_ + } +If ($CleanupDrivers){ + try { + If (Test-Path -Path $Driversfolder\$Make) { + WriteLog "Removing $Driversfolder\$Make" + Remove-Item -Path $Driversfolder\$Make -Force -Recurse + WriteLog "Removal complete" + } + } + catch { + Writelog "Removing $Driversfolder\$Make failed with error $_" + throw $_ + } + +} +} +#Clean up dirty.txt file +Remove-Item -Path .\dirty.txt -Force | out-null +if ($VerbosePreference -ne 'Continue'){ + Write-Host 'Script complete' +} +# Record the end time +$endTime = Get-Date +Write-Host "FFU build process completed at" $endTime + +# Calculate the total run time +$runTime = $endTime - $startTime + +# Format the runtime as minutes and seconds +$runTimeFormatted = 'Duration: {0:mm} min {0:ss} sec' -f $runTime + +if ($VerbosePreference -ne 'Continue'){ + Write-Host $runTimeFormatted +} +WriteLog 'Script complete' +WriteLog $runTimeFormatted diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1 index 96d5779..2acbdcf 100644 --- a/FFUDevelopment/BuildFFUVM.ps1 +++ b/FFUDevelopment/BuildFFUVM.ps1 @@ -106,6 +106,9 @@ When set to $true, will remove the FFU file from the $FFUDevelopmentPath\FFU fol .PARAMETER UpdateLatestCU When set to $true, will download and install the latest cumulative update for Windows 10/11. Default is $false. +.PARAMETER UpdatePreviewCU +When set to $true, will download and install the latest Preview cumulative update for Windows 10/11. Default is $false. + .PARAMETER UpdateLatestNet When set to $true, will download and install the latest .NET Framework for Windows 10/11. Default is $false. @@ -304,6 +307,7 @@ param( [bool]$CopyPEDrivers, [bool]$RemoveFFU, [bool]$UpdateLatestCU, + [bool]$UpdatePreviewCU, [bool]$UpdateLatestNet, [bool]$UpdateLatestDefender, [bool]$UpdateEdge, @@ -3749,10 +3753,10 @@ try { Add-BootFiles -OsPartitionDriveLetter $osPartitionDriveLetter -SystemPartitionDriveLetter $systemPartitionDriveLetter[1] - #Update latest Cumulative Update + #Update latest Cumulative Update if both $UpdateLatestCU is $true and $UpdatePreviewCU is $false #Changed to use MU Catalog instead of using Get-LatestWindowsKB #The Windows release info page is updated later than the MU Catalog - if ($UpdateLatestCU) { + if ($UpdateLatestCU -and -not $UpdatePreviewCU) { Writelog "`$UpdateLatestCU is set to true, checking for latest CU" $Name = """Cumulative update for Windows $WindowsRelease Version $WindowsVersion for $WindowsArch""" #Check if $KBPath exists, if not, create it @@ -3765,6 +3769,20 @@ try { WriteLog "Latest CU saved to $KBPath\$KBFilePath" } + #Update Latest Preview Cumlative Update + #will take Precendence over $UpdateLastestCU if both were set to $true + if ($UpdatePreviewCU) { + Writelog "`$UpdatePreviewCU is set to true, checking for latest Preview CU" + $Name = """Cumulative update Preview for Windows $WindowsRelease Version $WindowsVersion for $WindowsArch""" + #Check if $KBPath exists, if not, create it + If (-not (Test-Path -Path $KBPath)) { + WriteLog "Creating $KBPath" + New-Item -Path $KBPath -ItemType Directory -Force | Out-Null + } + WriteLog "Searching for $name from Microsoft Update Catalog and saving to $KBPath" + $KBFilePath = Save-KB -Name $Name -Path $KBPath + WriteLog "Latest Preview CU saved to $KBPath\$KBFilePath" + } #Update Latest .NET Framework if ($UpdateLatestNet) { @@ -3795,7 +3813,7 @@ try { #Add Windows packages - if ($UpdateLatestCU -or $UpdateLatestNet) { + if ($UpdateLatestCU -or $UpdateLatestNet -or $UpdatePreviewCU ) { try { WriteLog "Adding KBs to $WindowsPartition" WriteLog 'This can take 10+ minutes depending on how old the media is and the size of the KB. Please be patient' From d60b0301c59b328aabd00fa91b6ff7cd2bf3e3fb Mon Sep 17 00:00:00 2001 From: Doctair Date: Mon, 12 Aug 2024 10:15:45 -0400 Subject: [PATCH 03/14] Remove a temp copy of BUildScript --- FFUDevelopment/BuildFFUVM - Copy.ps1 | 4137 -------------------------- 1 file changed, 4137 deletions(-) delete mode 100644 FFUDevelopment/BuildFFUVM - Copy.ps1 diff --git a/FFUDevelopment/BuildFFUVM - Copy.ps1 b/FFUDevelopment/BuildFFUVM - Copy.ps1 deleted file mode 100644 index 96d5779..0000000 --- a/FFUDevelopment/BuildFFUVM - Copy.ps1 +++ /dev/null @@ -1,4137 +0,0 @@ - -#Requires -Modules Hyper-V, Storage -#Requires -PSEdition Desktop -#Requires -RunAsAdministrator - -<# -.SYNOPSIS -A PowerShell script to create a Windows 10/11 FFU file. - -.DESCRIPTION -This script creates a Windows 10/11 FFU and USB drive to help quickly get a Windows device reimaged. FFU can be customized with drivers, apps, and additional settings. - -.PARAMETER ISOPath -Path to the Windows 10/11 ISO file. - -.PARAMETER WindowsSKU -Edition of Windows 10/11 to be installed, e.g., accepted values are: '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' - -.PARAMETER FFUDevelopmentPath -Path to the FFU development folder (default is C:\FFUDevelopment). - -.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. No VM is created. - -.PARAMETER InstallOffice -Install Microsoft Office if set to $true. The script will download the latest ODT and Office files in the $FFUDevelopmentPath\Apps\Office folder and install Office in the FFU via VM - -.PARAMETER InstallDrivers -Install device drivers from the specified $FFUDevelopmentPath\Drivers folder if set to $true. Download the drivers and put them in the Drivers folder. The script will recurse the drivers folder and add the drivers to the FFU. - -.PARAMETER Memory -Amount of memory to allocate for the virtual machine. Recommended to use 8GB if possible, especially for Windows 11. Use 4GB if necesary. - -.PARAMETER Disksize -Size of the virtual hard disk for the virtual machine. Default is a 30GB dynamic disk. - -.PARAMETER Processors -Number of virtual processors for the virtual machine. Recommended to use at least 4. - -.PARAMETER VMSwitchName -Name of the Hyper-V virtual switch. If $InstallApps is set to $true, this must be set. This is required to capture the FFU from the VM. The default is *external*, but you will likely need to change this. - -.PARAMETER VMLocation -Default is $FFUDevelopmentPath\VM. This is the location of the VHDX that gets created where Windows will be installed to. - -.PARAMETER FFUPrefix -Prefix for the generated FFU file. Default is _FFU - -.PARAMETER FFUCaptureLocation -Path to the folder where the captured FFU will be stored. Default is $FFUDevelopmentPath\FFU - -.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 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. The script will not auto detect your IP (depending on your network adapters, it may not find the correct IP). - -.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 CreateDeploymentMedia -When set to $true, this will create WinPE deployment media for use when deploying to a physical device. - -.PARAMETER OptionalFeatures -Provide a semi-colon separated list of Windows optional features you want to include in the FFU (e.g. netfx3;TFTP) - -.PARAMETER ProductKey -Product key for the Windows 10/11 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. - -.PARAMETER BuildUSBDrive -When set to $true, will partition and format a USB drive and copy the captured FFU to the drive. If you'd like to customize the drive to add drivers, provisioning packages, name prefix, etc. You'll need to do that afterward. - -.PARAMETER WindowsRelease -Integer value of 10 or 11. This is used to identify which release of Windows to download. Default is 11. - -.PARAMETER WindowsVersion -String value of the Windows version to download. This is used to identify which version of Windows to download. Default is 23h2. - -.PARAMETER WindowsArch -String value of x86 or x64. This is used to identify which architecture of Windows to download. Default is x64. - -.PARAMETER WindowsLang -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. - -.PARAMETER MediaType -String value of either business or consumer. This is used to identify which media type to download. Default is consumer. - -.PARAMETER LogicalSectorBytes -unit32 value of 512 or 4096. Not recommended to change from 512. Might be useful for 4kn drives, but needs more testing. Default is 512. - -.PARAMETER Optimize -When set to $true, will optimize the FFU file. Default is $true. - -.PARAMETER CopyDrivers -When set to $true, will copy the drivers from the $FFUDevelopmentPath\Drivers folder to the Drivers folder on the deploy partition of the USB drive. Default is $false. - -.PARAMETER CopyPEDrivers -When set to $true, will copy the drivers from the $FFUDevelopmentPath\PEDrivers folder to the WinPE deployment media. Default is $false. - -.PARAMETER RemoveFFU -When set to $true, will remove the FFU file from the $FFUDevelopmentPath\FFU folder after it has been copied to the USB drive. Default is $false. - -.PARAMETER UpdateLatestCU -When set to $true, will download and install the latest cumulative update for Windows 10/11. Default is $false. - -.PARAMETER UpdateLatestNet -When set to $true, will download and install the latest .NET Framework for Windows 10/11. Default is $false. - -.PARAMETER UpdateLatestDefender -When set to $true, will download and install the latest Windows Defender definitions and Defender platform update. Default is $false. - -.PARAMETER UpdateEdge -When set to $true, will download and install the latest Microsoft Edge for Windows 10/11. Default is $false. - -.PARAMETER UpdateOneDrive -When set to $true, will download and install the latest OneDrive for Windows 10/11 and install it as a per machine installation instead of per user. Default is $false. - -.PARAMETER CopyPPKG -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. - -.PARAMETER CopyAutopilot -When set to $true, will copy the $FFUDevelopmentPath\Autopilot folder to the Deployment partition of the USB drive. Default is $false. - -.PARAMETER CompactOS -When set to $true, will compact the OS when building the FFU. 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 CleanupDeployISO -When set to $true, will remove the WinPE deployment ISO after the FFU has been captured. Default is $true. - -.PARAMETER CleanupAppsISO -When set to $true, will remove the Apps ISO after the FFU has been captured. Default is $true. - -.PARAMETER DriversFolder -Path to the drivers folder. Default is $FFUDevelopmentPath\Drivers. - -.PARAMETER CleanupDrivers -When set to $true, will remove the drivers folder after the FFU has been captured. Default is $true. - -.PARAMETER UserAgent -User agent string to use when downloading files. - -.PARAMETER Headers -Headers to use when downloading files. - -.PARAMETER AllowExternalHardDiskMedia -When set to $true, will allow the use of media identified as External Hard Disk media via WMI class Win32_DiskDrive. Default is not defined. - -.PARAMETER PromptExternalHardDiskMedia -When set to $true, will prompt the user to confirm the use of media identified as External Hard Disk media via WMI class Win32_DiskDrive. Default is $true. - -.PARAMETER Make -Make of the device to download drivers. Accepted values are: 'Microsoft', 'Dell', 'HP', 'Lenovo' - -.PARAMETER Model -Model of the device to download drivers. This is required if Make is set. - -.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 - -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 - -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 - -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 - -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 - -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 - -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 - -.NOTES - Additional notes about your script. - -.LINK - https://github.com/rbalsleyMSFT/FFU -#> - - -[CmdletBinding()] -param( - [Parameter(Mandatory = $false, Position = 0)] - [ValidateScript({ Test-Path $_ })] - [string]$ISOPath, - [ValidateScript({ - $allowedSKUs = @('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') - if ($allowedSKUs -contains $_) { $true } else { throw "Invalid WindowsSKU value. Allowed values: $($allowedSKUs -join ', ')" } - return $true - })] - [string]$WindowsSKU = 'Pro', - [ValidateScript({ Test-Path $_ })] - [string]$FFUDevelopmentPath = $PSScriptRoot, - [bool]$InstallApps, - [bool]$InstallOffice, - [ValidateSet('Microsoft', 'Dell', 'HP', 'Lenovo')] - [string]$Make, - [string]$Model, - [Parameter(Mandatory = $false)] - [ValidateScript({ - if ($Make) { - return $true - } - if ($_ -and (!(Test-Path -Path '.\Drivers') -or ((Get-ChildItem -Path '.\Drivers' -Recurse | Measure-Object -Property Length -Sum).Sum -lt 1MB))) { - throw 'InstallDrivers is set to $true, but either the Drivers folder is missing or empty' - } - return $true - })] - [bool]$InstallDrivers, - [uint64]$Memory = 4GB, - [uint64]$Disksize = 30GB, - [int]$Processors = 4, - [string]$VMSwitchName, - [string]$VMLocation, - [string]$FFUPrefix = '_FFU', - [string]$FFUCaptureLocation, - [String]$ShareName = "FFUCaptureShare", - [string]$Username = "ffu_user", - [Parameter(Mandatory = $false)] - [string]$VMHostIPAddress, - [bool]$CreateCaptureMedia = $true, - [bool]$CreateDeploymentMedia, - [ValidateScript({ - $allowedFeatures = @("Windows-Defender-Default-Definitions", "Printing-PrintToPDFServices-Features", "Printing-XPSServices-Features", "TelnetClient", "TFTP", - "TIFFIFilter", "LegacyComponents", "DirectPlay", "MSRDC-Infrastructure", "Windows-Identity-Foundation", "MicrosoftWindowsPowerShellV2Root", "MicrosoftWindowsPowerShellV2", - "SimpleTCP", "NetFx4-AdvSrvs", "NetFx4Extended-ASPNET45", "WCF-Services45", "WCF-HTTP-Activation45", "WCF-TCP-Activation45", "WCF-Pipe-Activation45", "WCF-MSMQ-Activation45", - "WCF-TCP-PortSharing45", "IIS-WebServerRole", "IIS-WebServer", "IIS-CommonHttpFeatures", "IIS-HttpErrors", "IIS-HttpRedirect", "IIS-ApplicationDevelopment", "IIS-Security", - "IIS-RequestFiltering", "IIS-NetFxExtensibility", "IIS-NetFxExtensibility45", "IIS-HealthAndDiagnostics", "IIS-HttpLogging", "IIS-LoggingLibraries", "IIS-RequestMonitor", - "IIS-HttpTracing", "IIS-URLAuthorization", "IIS-IPSecurity", "IIS-Performance", "IIS-HttpCompressionDynamic", "IIS-WebServerManagementTools", "IIS-ManagementScriptingTools", - "IIS-IIS6ManagementCompatibility", "IIS-Metabase", "WAS-WindowsActivationService", "WAS-ProcessModel", "WAS-NetFxEnvironment", "WAS-ConfigurationAPI", "IIS-HostableWebCore", - "WCF-HTTP-Activation", "WCF-NonHTTP-Activation", "IIS-StaticContent", "IIS-DefaultDocument", "IIS-DirectoryBrowsing", "IIS-WebDAV", "IIS-WebSockets", "IIS-ApplicationInit", - "IIS-ISAPIFilter", "IIS-ISAPIExtensions", "IIS-ASPNET", "IIS-ASPNET45", "IIS-ASP", "IIS-CGI", "IIS-ServerSideIncludes", "IIS-CustomLogging", "IIS-BasicAuthentication", - "IIS-HttpCompressionStatic", "IIS-ManagementConsole", "IIS-ManagementService", "IIS-WMICompatibility", "IIS-LegacyScripts", "IIS-LegacySnapIn", "IIS-FTPServer", "IIS-FTPSvc", - "IIS-FTPExtensibility", "MSMQ-Container", "MSMQ-DCOMProxy", "MSMQ-Server", "MSMQ-ADIntegration", "MSMQ-HTTP", "MSMQ-Multicast", "MSMQ-Triggers", "IIS-CertProvider", - "IIS-WindowsAuthentication", "IIS-DigestAuthentication", "IIS-ClientCertificateMappingAuthentication", "IIS-IISCertificateMappingAuthentication", "IIS-ODBCLogging", - "NetFx3", "SMB1Protocol-Deprecation", "MediaPlayback", "WindowsMediaPlayer", "Client-DeviceLockdown", "Client-EmbeddedShellLauncher", "Client-EmbeddedBootExp", - "Client-EmbeddedLogon", "Client-KeyboardFilter", "Client-UnifiedWriteFilter", "HostGuardian", "MultiPoint-Connector", "MultiPoint-Connector-Services", "MultiPoint-Tools" - , "AppServerClient", "SearchEngine-Client-Package", "WorkFolders-Client", "Printing-Foundation-Features", "Printing-Foundation-InternetPrinting-Client", - "Printing-Foundation-LPDPrintService", "Printing-Foundation-LPRPortMonitor", "HypervisorPlatform", "VirtualMachinePlatform", "Microsoft-Windows-Subsystem-Linux", - "Client-ProjFS", "Containers-DisposableClientVM", 'Containers-DisposableClientVM', 'Microsoft-Hyper-V-All', 'Microsoft-Hyper-V', 'Microsoft-Hyper-V-Tools-All', - 'Microsoft-Hyper-V-Management-PowerShell', 'Microsoft-Hyper-V-Hypervisor', 'Microsoft-Hyper-V-Services', 'Microsoft-Hyper-V-Management-Clients', 'DataCenterBridging', - 'DirectoryServices-ADAM-Client', 'Windows-Defender-ApplicationGuard', 'ServicesForNFS-ClientOnly', 'ClientForNFS-Infrastructure', 'NFS-Administration', 'Containers', 'Containers-HNS', - 'Containers-SDN', 'SMB1Protocol', 'SMB1Protocol-Client', 'SMB1Protocol-Server', 'SmbDirect') - $inputFeatures = $_ -split ';' - foreach ($feature in $inputFeatures) { - if (-not ($allowedFeatures -contains $feature)) { - throw "Invalid optional feature '$feature'. Allowed values: $($allowedFeatures -join ', ')" - } - } - return $true - })] - [string]$OptionalFeatures, - [string]$ProductKey, - [bool]$BuildUSBDrive, - [Parameter(Mandatory = $false)] - [ValidateSet(10, 11)] - [int]$WindowsRelease = 11, - [Parameter(Mandatory = $false)] - [string]$WindowsVersion = '23h2', - [Parameter(Mandatory = $false)] - [ValidateSet('x86', 'x64', 'arm64')] - [string]$WindowsArch = 'x64', - [ValidateScript({ - $allowedLang = @('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') - if ($allowedLang -contains $_) { $true } else { throw "Invalid WindowsLang value. Allowed values: $($allowedLang -join ', ')" } - return $true - })] - [Parameter(Mandatory = $false)] - [string]$WindowsLang = 'en-us', - [Parameter(Mandatory = $false)] - [ValidateSet('consumer', 'business')] - [string]$MediaType = 'consumer', - [ValidateSet(512, 4096)] - [uint32]$LogicalSectorSizeBytes = 512, - [bool]$Optimize = $true, - [Parameter(Mandatory = $false)] - [ValidateScript({ - if ($Make) { - return $true - } - if ($_ -and (!(Test-Path -Path '.\Drivers') -or ((Get-ChildItem -Path '.\Drivers' -Recurse | Measure-Object -Property Length -Sum).Sum -lt 1MB))) { - throw 'CopyDrivers is set to $true, but either the Drivers folder is missing or empty' - } - return $true - })] - [bool]$CopyDrivers, - [bool]$CopyPEDrivers, - [bool]$RemoveFFU, - [bool]$UpdateLatestCU, - [bool]$UpdateLatestNet, - [bool]$UpdateLatestDefender, - [bool]$UpdateEdge, - [bool]$UpdateOneDrive, - [bool]$CopyPPKG, - [bool]$CopyUnattend, - [bool]$CopyAutopilot, - [bool]$CompactOS = $true, - [bool]$CleanupCaptureISO = $true, - [bool]$CleanupDeployISO = $true, - [bool]$CleanupAppsISO = $true, - [string]$DriversFolder, - [bool]$CleanupDrivers = $true, - [string]$UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.0', - #Microsoft sites will intermittently fail on downloads. These headers are to help with that. - $Headers = @{ - "Accept" = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7" - "Accept-Encoding" = "gzip, deflate, br, zstd" - "Accept-Language" = "en-US,en;q=0.9" - "Priority" = "u=0, i" - "Sec-Ch-Ua" = "`"Microsoft Edge`";v=`"125`", `"Chromium`";v=`"125`", `"Not.A/Brand`";v=`"24`"" - "Sec-Ch-Ua-Mobile" = "?0" - "Sec-Ch-Ua-Platform" = "`"Windows`"" - "Sec-Fetch-Dest" = "document" - "Sec-Fetch-Mode" = "navigate" - "Sec-Fetch-Site" = "none" - "Sec-Fetch-User" = "?1" - "Upgrade-Insecure-Requests" = "1" - }, - [bool]$AllowExternalHardDiskMedia, - [bool]$PromptExternalHardDiskMedia = $true -) -$version = '2408.2' - -#Check if Hyper-V feature is installed (requires only checks the module) -$osInfo = Get-WmiObject -Class Win32_OperatingSystem -$isServer = $osInfo.Caption -match 'server' - -if ($isServer) { - $hyperVFeature = Get-WindowsFeature -Name Hyper-V - if ($hyperVFeature.InstallState -ne "Installed") { - Write-Host "Hyper-V feature is not installed. Please install it before running this script." - exit - } -} -else { - $hyperVFeature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All - if ($hyperVFeature.State -ne "Enabled") { - Write-Host "Hyper-V feature is not enabled. Please enable it before running this script." - exit - } -} - -# Set default values for variables that depend on other parameters -if (-not $AppsISO) { $AppsISO = "$FFUDevelopmentPath\Apps.iso" } -if (-not $AppsPath) { $AppsPath = "$FFUDevelopmentPath\Apps" } -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 $rand) { $rand = Get-Random } -if (-not $VMLocation) { $VMLocation = "$FFUDevelopmentPath\VM" } -if (-not $VMName) { $VMName = "$FFUPrefix-$rand" } -if (-not $VMPath) { $VMPath = "$VMLocation\$VMName" } -if (-not $VHDXPath) { $VHDXPath = "$VMPath\$VMName.vhdx" } -if (-not $FFUCaptureLocation) { $FFUCaptureLocation = "$FFUDevelopmentPath\FFU" } -if (-not $LogFile) { $LogFile = "$FFUDevelopmentPath\FFUDevelopment.log" } -if (-not $KBPath) { $KBPath = "$FFUDevelopmentPath\KB" } -if (-not $DefenderPath) { $DefenderPath = "$AppsPath\Defender" } -if (-not $OneDrivePath) { $OneDrivePath = "$AppsPath\OneDrive" } -if (-not $EdgePath) { $EdgePath = "$AppsPath\Edge" } -if (-not $DriversFolder) { $DriversFolder = "$FFUDevelopmentPath\Drivers" } - -#FUNCTIONS -function WriteLog($LogText) { - Add-Content -path $LogFile -value "$((Get-Date).ToString()) $LogText" -Force -ErrorAction SilentlyContinue - Write-Verbose $LogText -} - -function LogVariableValues { - $excludedVariables = @( - 'PSBoundParameters', - 'PSScriptRoot', - 'PSCommandPath', - 'MyInvocation', - '?', - 'ConsoleFileName', - 'ExecutionContext', - 'false', - 'HOME', - 'Host', - 'hyperVFeature', - 'input', - 'MaximumAliasCount', - 'MaximumDriveCount', - 'MaximumErrorCount', - 'MaximumFunctionCount', - 'MaximumVariableCount', - 'null', - 'PID', - 'PSCmdlet', - 'PSCulture', - 'PSUICulture', - 'PSVersionTable', - 'ShellId', - 'true' - ) - - $allVariables = Get-Variable -Scope Script | Where-Object { $_.Name -notin $excludedVariables } - Writelog "Script version: $version" - WriteLog 'Logging variables' - foreach ($variable in $allVariables) { - $variableName = $variable.Name - $variableValue = $variable.Value - if ($null -ne $variableValue) { - WriteLog "[VAR]$variableName`: $variableValue" - } - else { - WriteLog "[VAR]Variable $variableName not found or not set" - } - } - WriteLog 'End logging variables' -} - -function Invoke-Process { - [CmdletBinding(SupportsShouldProcess)] - param - ( - [Parameter(Mandatory)] - [ValidateNotNullOrEmpty()] - [string]$FilePath, - - [Parameter()] - [ValidateNotNullOrEmpty()] - [string]$ArgumentList - ) - - $ErrorActionPreference = 'Stop' - - try { - $stdOutTempFile = "$env:TEMP\$((New-Guid).Guid)" - $stdErrTempFile = "$env:TEMP\$((New-Guid).Guid)" - - $startProcessParams = @{ - FilePath = $FilePath - ArgumentList = $ArgumentList - RedirectStandardError = $stdErrTempFile - RedirectStandardOutput = $stdOutTempFile - Wait = $true; - PassThru = $true; - NoNewWindow = $true; - } - if ($PSCmdlet.ShouldProcess("Process [$($FilePath)]", "Run with args: [$($ArgumentList)]")) { - $cmd = Start-Process @startProcessParams - $cmdOutput = Get-Content -Path $stdOutTempFile -Raw - $cmdError = Get-Content -Path $stdErrTempFile -Raw - if ($cmd.ExitCode -ne 0) { - if ($cmdError) { - throw $cmdError.Trim() - } - if ($cmdOutput) { - throw $cmdOutput.Trim() - } - } - else { - if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) { - WriteLog $cmdOutput - } - } - } - } - catch { - #$PSCmdlet.ThrowTerminatingError($_) - WriteLog $_ - Write-Host "Script failed - $Logfile for more info" - throw $_ - - } - finally { - Remove-Item -Path $stdOutTempFile, $stdErrTempFile -Force -ErrorAction Ignore - - } - -} - -function Test-Url { - param ( - [Parameter(Mandatory = $true)] - [string]$Url - ) - try { - # Create a web request and check the response - $request = [System.Net.WebRequest]::Create($Url) - $request.Method = 'HEAD' - $response = $request.GetResponse() - return $true - } - catch { - return $false - } -} - -# Function to download a file using BITS with retry and error handling -function Start-BitsTransferWithRetry { - param ( - [Parameter(Mandatory = $true)] - [string]$Source, - [Parameter(Mandatory = $true)] - [string]$Destination, - [int]$Retries = 3 - ) - - $attempt = 0 - while ($attempt -lt $Retries) { - try { - $OriginalVerbosePreference = $VerbosePreference - $VerbosePreference = 'SilentlyContinue' - $ProgressPreference = 'SilentlyContinue' - Start-BitsTransfer -Source $Source -Destination $Destination -ErrorAction Stop - $ProgressPreference = 'Continue' - $VerbosePreference = $OriginalVerbosePreference - return - } - catch { - $attempt++ - WriteLog "Attempt $attempt of $Retries failed to download $Source. Retrying..." - Start-Sleep -Seconds 5 - } - } - WriteLog "Failed to download $Source after $Retries attempts." - return $false -} - -function Get-MicrosoftDrivers { - param ( - [string]$Make, - [string]$Model, - [int]$WindowsRelease - ) - - $url = "https://support.microsoft.com/en-us/surface/download-drivers-and-firmware-for-surface-09bb2e09-2a4b-cb69-0951-078a7739e120" - - # Download the webpage content - WriteLog "Getting Surface driver information from $url" - $OriginalVerbosePreference = $VerbosePreference - $VerbosePreference = 'SilentlyContinue' - $webContent = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $Headers -UserAgent $UserAgent - $VerbosePreference = $OriginalVerbosePreference - WriteLog "Complete" - - # Parse the content of the relevant nested divs - WriteLog "Parsing web content for models and download links" - $html = $webContent.Content - $nestedDivPattern = '
    (.*?)
    ' - $nestedDivMatches = [regex]::Matches($html, $nestedDivPattern, [System.Text.RegularExpressions.RegexOptions]::Singleline) - - $models = @() - $modelPattern = '

    (.*?)

    \s*\s*\s*

    \s*\|]', '_' - WriteLog "Downloading driver: $Name" - $Category = $update.Category - $Category = $Category -replace '[\\\/\:\*\?\"\<\>\|]', '_' - $Version = $update.Version - $Version = $Version -replace '[\\\/\:\*\?\"\<\>\|]', '_' - $DriverUrl = "https://$($update.URL)" - WriteLog "Driver URL: $DriverUrl" - $DriverFileName = [System.IO.Path]::GetFileName($DriverUrl) - $downloadFolder = "$DriversFolder\$ProductName\$Category" - $DriverFilePath = Join-Path -Path $downloadFolder -ChildPath $DriverFileName - - if (Test-Path -Path $DriverFilePath) { - WriteLog "Driver already downloaded: $DriverFilePath, skipping" - continue - } - - if (-not (Test-Path -Path $downloadFolder)) { - WriteLog "Creating download folder: $downloadFolder" - New-Item -Path $downloadFolder -ItemType Directory -Force | Out-Null - WriteLog "Download folder created" - } - - # Download the driver with retry - WriteLog "Downloading driver to: $DriverFilePath" - Start-BitsTransferWithRetry -Source $DriverUrl -Destination $DriverFilePath - WriteLog 'Driver downloaded' - - # Make folder for extraction - $extractFolder = "$downloadFolder\$Name\$Version\" + $DriverFileName.TrimEnd('.exe') - Writelog "Creating extraction folder: $extractFolder" - New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null - WriteLog 'Extraction folder created' - - # Extract the driver - $arguments = "/s /e /f `"$extractFolder`"" - WriteLog "Extracting driver" - Invoke-Process -FilePath $DriverFilePath -ArgumentList $arguments - WriteLog "Driver extracted to: $extractFolder" - - # Delete the .exe driver file after extraction - Remove-Item -Path $DriverFilePath -Force - WriteLog "Driver installation file deleted: $DriverFilePath" - } - # Clean up the downloaded cab and xml files - Remove-Item -Path $DriverCabFile, $DriverXmlFile, $PlatformListCab, $PlatformListXml -Force - WriteLog "Driver cab and xml files deleted" -} -function Get-LenovoDrivers { - param ( - [Parameter()] - [string]$Model, - [Parameter()] - [ValidateSet("x64", "x86", "ARM64")] - [string]$WindowsArch, - [Parameter()] - [ValidateSet(10, 11)] - [int]$WindowsRelease - ) - - function Get-LenovoPSREF { - param ( - [string]$ModelName - ) - - $url = "https://psref.lenovo.com/api/search/DefinitionFilterAndSearch/Suggest?kw=$ModelName" - WriteLog "Querying Lenovo PSREF API for model: $ModelName" - $OriginalVerbosePreference = $VerbosePreference - $VerbosePreference = 'SilentlyContinue' - $response = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $Headers -UserAgent $UserAgent - $VerbosePreference = $OriginalVerbosePreference - WriteLog "Complete" - - $jsonResponse = $response.Content | ConvertFrom-Json - - $products = @() - foreach ($item in $jsonResponse.data) { - $productName = $item.ProductName - $machineTypes = $item.MachineType -split " / " - - foreach ($machineType in $machineTypes) { - if ($machineType -eq $ModelName) { - WriteLog "Model name entered is a matching machine type" - $products = @() - $products += [pscustomobject]@{ - ProductName = $productName - MachineType = $machineType - } - WriteLog "Product Name: $productName Machine Type: $machineType" - return $products - } - $products += [pscustomobject]@{ - ProductName = $productName - MachineType = $machineType - } - } - } - - return ,$products - } - - # Parse the Lenovo PSREF page for the model - $machineTypes = Get-LenovoPSREF -ModelName $Model - if ($machineTypes.ProductName.Count -eq 0) { - WriteLog "No machine types found for model: $Model" - WriteLog "Enter a valid model or machine type in the -model parameter" - exit - } elseif ($machineTypes.ProductName.Count -eq 1) { - $machineType = $machineTypes[0].MachineType - $model = $machineTypes[0].ProductName - } else { - if ($VerbosePreference -ne 'Continue'){ - Write-Output "Multiple machine types found for model: $Model" - } - WriteLog "Multiple machine types found for model: $Model" - for ($i = 0; $i -lt $machineTypes.ProductName.Count; $i++) { - if ($VerbosePreference -ne 'Continue'){ - Write-Output "$($i + 1). $($machineTypes[$i].ProductName) ($($machineTypes[$i].MachineType))" - } - WriteLog "$($i + 1). $($machineTypes[$i].ProductName) ($($machineTypes[$i].MachineType))" - } - $selection = Read-Host "Enter the number of the model you want to select" - $machineType = $machineTypes[$selection - 1].MachineType - WriteLog "Selected machine type: $machineType" - $model = $machineTypes[$selection - 1].ProductName - WriteLog "Selected model: $model" - } - - - # Construct the catalog URL based on Windows release and machine type - $ModelRelease = $machineType + "_Win" + $WindowsRelease - $CatalogUrl = "https://download.lenovo.com/catalog/$ModelRelease.xml" - WriteLog "Lenovo Driver catalog URL: $CatalogUrl" - - if (-not (Test-Url -Url $catalogUrl)) { - Write-Error "Lenovo Driver catalog URL is not accessible: $catalogUrl" - WriteLog "Lenovo Driver catalog URL is not accessible: $catalogUrl" - exit - } - - # Create the folder structure for the Lenovo drivers - $driversFolder = "$DriversFolder\$Make" - if (-not (Test-Path -Path $DriversFolder)) { - WriteLog "Creating Drivers folder: $DriversFolder" - New-Item -Path $DriversFolder -ItemType Directory -Force | Out-Null - WriteLog "Drivers folder created" - } - - # Download and parse the Lenovo catalog XML - $LenovoCatalogXML = "$DriversFolder\$ModelRelease.xml" - WriteLog "Downloading $catalogUrl to $LenovoCatalogXML" - Start-BitsTransferWithRetry -Source $catalogUrl -Destination $LenovoCatalogXML - WriteLog "Download Complete" - $xmlContent = [xml](Get-Content -Path $LenovoCatalogXML) - - WriteLog "Parsing Lenovo catalog XML" - # Process each package in the catalog - foreach ($package in $xmlContent.packages.package) { - $packageUrl = $package.location - $category = $package.category - - #If category starts with BIOS, skip the package - if ($category -like 'BIOS*') { - continue - } - - #If category name is 'Motherboard Devices Backplanes core chipset onboard video PCIe switches', truncate to 'Motherboard Devices' to shorten path - if ($category -eq 'Motherboard Devices Backplanes core chipset onboard video PCIe switches') { - $category = 'Motherboard Devices' - } - - $packageName = [System.IO.Path]::GetFileName($packageUrl) - #Remove the filename from the $packageURL - $baseURL = $packageUrl -replace $packageName, "" - - # Download the package XML - $packageXMLPath = "$DriversFolder\$packageName" - WriteLog "Downloading $category package XML $packageUrl to $packageXMLPath" - If ((Start-BitsTransferWithRetry -Source $packageUrl -Destination $packageXMLPath) -eq $false) { - Write-Output "Failed to download $category package XML: $packageXMLPath" - WriteLog "Failed to download $category package XML: $packageXMLPath" - continue - } - - # Load the package XML content - $packageXmlContent = [xml](Get-Content -Path $packageXMLPath) - $packageType = $packageXmlContent.Package.PackageType.type - $packageTitle = $packageXmlContent.Package.title.InnerText - - # Fix the name for drivers that contain illegal characters for folder name purposes - $packageTitle = $packageTitle -replace '[\\\/\:\*\?\"\<\>\|]', '_' - - # If ' - ' is in the package title, truncate the title to the first part of the string. - $packageTitle = $packageTitle -replace ' - .*', '' - - #Check if packagetype = 2. If packagetype is not 2, skip the package. $packageType is a System.Xml.XmlElement. - #This filters out Firmware, BIOS, and other non-INF drivers - if ($packageType -ne 2) { - Remove-Item -Path $packageXMLPath -Force - continue - } - - # Extract the driver file name and the extract command - $driverFileName = $packageXmlContent.Package.Files.Installer.File.Name - $extractCommand = $packageXmlContent.Package.ExtractCommand - - #if extract command is empty/missing, skip the package - if (!($extractCommand)) { - Remove-Item -Path $packageXMLPath -Force - continue - } - - # Create the download URL and folder structure - $driverUrl = $baseUrl + $driverFileName - $downloadFolder = "$DriversFolder\$Model\$Category\$packageTitle" - $driverFilePath = Join-Path -Path $downloadFolder -ChildPath $driverFileName - - # Check if file has already been downloaded - if (Test-Path -Path $driverFilePath) { - Write-Output "Driver already downloaded: $driverFilePath skipping" - WriteLog "Driver already downloaded: $driverFilePath skipping" - continue - } - - if (-not (Test-Path -Path $downloadFolder)) { - WriteLog "Creating download folder: $downloadFolder" - New-Item -Path $downloadFolder -ItemType Directory -Force | Out-Null - WriteLog "Download folder created" - } - - # Download the driver with retry - WriteLog "Downloading driver: $driverUrl to $driverFilePath" - Start-BitsTransferWithRetry -Source $driverUrl -Destination $driverFilePath - WriteLog "Driver downloaded" - - # Make folder for extraction - $extractFolder = $downloadFolder + "\" + $driverFileName.TrimEnd($driverFileName[-4..-1]) - WriteLog "Creating extract folder: $extractFolder" - New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null - WriteLog "Extract folder created" - - # Modify the extract command - $modifiedExtractCommand = $extractCommand -replace '%PACKAGEPATH%', "`"$extractFolder`"" - - # Extract the driver - # Start-Process -FilePath $driverFilePath -ArgumentList $modifiedExtractCommand -Wait -NoNewWindow - WriteLog "Extracting driver: $driverFilePath to $extractFolder" - Invoke-Process -FilePath $driverFilePath -ArgumentList $modifiedExtractCommand - WriteLog "Driver extracted" - - # Delete the .exe driver file after extraction - WriteLog "Deleting driver installation file: $driverFilePath" - Remove-Item -Path $driverFilePath -Force - WriteLog "Driver installation file deleted: $driverFilePath" - - # Delete the package XML file after extraction - WriteLog "Deleting package XML file: $packageXMLPath" - Remove-Item -Path $packageXMLPath -Force - WriteLog "Package XML file deleted" - } - - #Delete the catalog XML file after processing - WriteLog "Deleting catalog XML file: $LenovoCatalogXML" - Remove-Item -Path $LenovoCatalogXML -Force - WriteLog "Catalog XML file deleted" -} - -function Get-DellDrivers { - param ( - [Parameter(Mandatory = $true)] - [string]$Model, - [Parameter(Mandatory = $true)] - [ValidateSet("x64", "x86", "ARM64")] - [string]$WindowsArch - ) - - $catalogUrl = "http://downloads.dell.com/catalog/CatalogPC.cab" - if (-not (Test-Url -Url $catalogUrl)) { - WriteLog "Dell Catalog cab URL is not accessible: $catalogUrl Exiting" - if ($VerbosePreference -ne 'Continue') { - Write-Host "Dell Catalog cab URL is not accessible: $catalogUrl Exiting" - } - exit - } - - if (-not (Test-Path -Path $DriversFolder)) { - WriteLog "Creating Drivers folder: $DriversFolder" - New-Item -Path $DriversFolder -ItemType Directory -Force | Out-Null - WriteLog "Drivers folder created" - } - - $DriversFolder = "$DriversFolder\$Make" - WriteLog "Creating Dell Drivers folder: $DriversFolder" - New-Item -Path $DriversFolder -ItemType Directory -Force | Out-Null - WriteLog "Dell Drivers folder created" - - $DellCabFile = "$DriversFolder\CatalogPC.cab" - WriteLog "Downloading Dell Catalog cab file: $catalogUrl to $DellCabFile" - Start-BitsTransferWithRetry -Source $catalogUrl -Destination $DellCabFile - WriteLog "Dell Catalog cab file downloaded" - - $DellCatalogXML = "$DriversFolder\CatalogPC.XML" - WriteLog "Extracting Dell Catalog cab file to $DellCatalogXML" - Invoke-Process -FilePath Expand.exe -ArgumentList "$DellCabFile $DellCatalogXML" - WriteLog "Dell Catalog cab file extracted" - - $xmlContent = [xml](Get-Content -Path $DellCatalogXML) - $baseLocation = "https://" + $xmlContent.manifest.baseLocation + "/" - $latestDrivers = @{} - - $softwareComponents = $xmlContent.Manifest.SoftwareComponent | Where-Object { $_.ComponentType.value -eq "DRVR" } - foreach ($component in $softwareComponents) { - $models = $component.SupportedSystems.Brand.Model - foreach ($item in $models) { - if ($item.Display.'#cdata-section' -match $Model) { - $validOS = $component.SupportedOperatingSystems.OperatingSystem | Where-Object { $_.osArch -eq $WindowsArch } - if ($validOS) { - $driverPath = $component.path - $downloadUrl = $baseLocation + $driverPath - $driverFileName = [System.IO.Path]::GetFileName($driverPath) - $name = $component.Name.Display.'#cdata-section' - $name = $name -replace '[\\\/\:\*\?\"\<\>\|]', '_' - $name = $name -replace '[\,]', '-' - $category = $component.Category.Display.'#cdata-section' - $version = [version]$component.vendorVersion - $namePrefix = ($name -split '-')[0] - - # Use hash table to store the latest driver for each category to prevent downloading older driver versions - if ($latestDrivers[$category]) { - if ($latestDrivers[$category][$namePrefix]) { - if ($latestDrivers[$category][$namePrefix].Version -lt $version) { - $latestDrivers[$category][$namePrefix] = [PSCustomObject]@{ - Name = $name; - DownloadUrl = $downloadUrl; - DriverFileName = $driverFileName; - Version = $version; - Category = $category - } - } - } - else { - $latestDrivers[$category][$namePrefix] = [PSCustomObject]@{ - Name = $name; - DownloadUrl = $downloadUrl; - DriverFileName = $driverFileName; - Version = $version; - Category = $category - } - } - } - else { - $latestDrivers[$category] = @{} - $latestDrivers[$category][$namePrefix] = [PSCustomObject]@{ - Name = $name; - DownloadUrl = $downloadUrl; - DriverFileName = $driverFileName; - Version = $version; - Category = $category - } - } - } - } - } - } - - foreach ($category in $latestDrivers.Keys) { - foreach ($driver in $latestDrivers[$category].Values) { - $downloadFolder = "$DriversFolder\$Model\$($driver.Category)" - $driverFilePath = Join-Path -Path $downloadFolder -ChildPath $driver.DriverFileName - - if (Test-Path -Path $driverFilePath) { - Write-Output "Driver already downloaded: $driverFilePath skipping" - continue - } - - WriteLog "Downloading driver: $($driver.Name)" - if (-not (Test-Path -Path $downloadFolder)) { - WriteLog "Creating download folder: $downloadFolder" - New-Item -Path $downloadFolder -ItemType Directory -Force | Out-Null - WriteLog "Download folder created" - } - - WriteLog "Downloading driver: $($driver.DownloadUrl) to $driverFilePath" - try{ - Start-BitsTransferWithRetry -Source $driver.DownloadUrl -Destination $driverFilePath - WriteLog "Driver downloaded" - }catch{ - WriteLog "Failed to download driver: $($driver.DownloadUrl) to $driverFilePath" - continue - } - - - $extractFolder = $downloadFolder + "\" + $driver.DriverFileName.TrimEnd($driver.DriverFileName[-4..-1]) - WriteLog "Creating extraction folder: $extractFolder" - New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null - WriteLog "Extraction folder created" - - $arguments = "/s /e=`"$extractFolder`"" - WriteLog "Extracting driver: $driverFilePath to $extractFolder" - Invoke-Process -FilePath $driverFilePath -ArgumentList $arguments - WriteLog "Driver extracted" - - WriteLog "Deleting driver file: $driverFilePath" - Remove-Item -Path $driverFilePath -Force - WriteLog "Driver file deleted" - } - } -} -function Get-ADKURL { - param ( - [ValidateSet("Windows ADK", "WinPE add-on")] - [string]$ADKOption - ) - - # Define base pattern for URL scraping - $basePattern = '

  • Download the ' - - # Define specific URL patterns based on ADK options - $ADKUrlPattern = @{ - "Windows ADK" = $basePattern + "Windows ADK" - "WinPE add-on" = $basePattern + "Windows PE add-on for the Windows ADK" - }[$ADKOption] - - try { - # Retrieve content of Microsoft documentation page - $OriginalVerbosePreference = $VerbosePreference - $VerbosePreference = 'SilentlyContinue' - $ADKWebPage = Invoke-RestMethod "https://learn.microsoft.com/en-us/windows-hardware/get-started/adk-install" -Headers $Headers -UserAgent $UserAgent - $VerbosePreference = $OriginalVerbosePreference - - # Extract download URL based on specified pattern - $ADKMatch = [regex]::Match($ADKWebPage, $ADKUrlPattern) - - if (-not $ADKMatch.Success) { - WriteLog "Failed to retrieve ADK download URL. Pattern match failed." - return - } - - # Extract FWlink from the matched pattern - $ADKFWLink = $ADKMatch.Groups[1].Value - - if ($null -eq $ADKFWLink) { - WriteLog "FWLink for $ADKOption not found." - return - } - - # Retrieve headers of the FWlink URL - $OriginalVerbosePreference = $VerbosePreference - $VerbosePreference = 'SilentlyContinue' - $FWLinkRequest = Invoke-WebRequest -Uri $ADKFWLink -Method Head -MaximumRedirection 0 -ErrorAction SilentlyContinue - $VerbosePreference = $OriginalVerbosePreference - - if ($FWLinkRequest.StatusCode -ne 302) { - WriteLog "Failed to retrieve ADK download URL. Unexpected status code: $($FWLinkRequest.StatusCode)" - return - } - - # Get the ADK link redirected to by the FWlink - $ADKUrl = $FWLinkRequest.Headers.Location - return $ADKUrl - } - catch { - WriteLog $_ - Write-Error "Error occurred while retrieving ADK download URL" - throw $_ - } -} -function Install-ADK { - param ( - [ValidateSet("Windows ADK", "WinPE add-on")] - [string]$ADKOption - ) - - try { - $ADKUrl = Get-ADKURL -ADKOption $ADKOption - - if ($null -eq $ADKUrl) { - throw "Failed to retrieve URL for $ADKOption. Please manually install it." - } - - # Select the installer based on the ADK option specified - $installer = @{ - "Windows ADK" = "adksetup.exe" - "WinPE add-on" = "adkwinpesetup.exe" - }[$ADKOption] - - # Select the feature based on the ADK option specified - $feature = @{ - "Windows ADK" = "OptionId.DeploymentTools" - "WinPE add-on" = "OptionId.WindowsPreinstallationEnvironment" - }[$ADKOption] - - $installerLocation = Join-Path $env:TEMP $installer - - WriteLog "Downloading $ADKOption from $ADKUrl to $installerLocation" - Start-BitsTransferWithRetry -Source $ADKUrl -Destination $installerLocation -ErrorAction Stop - WriteLog "$ADKOption downloaded to $installerLocation" - - WriteLog "Installing $ADKOption with $feature enabled" - Invoke-Process $installerLocation "/quiet /installpath ""%ProgramFiles(x86)%\Windows Kits\10"" /features $feature" - - WriteLog "$ADKOption installation completed." - WriteLog "Removing $installer from $installerLocation" - # Clean up downloaded installation file - Remove-Item -Path $installerLocation -Force -ErrorAction SilentlyContinue - } - catch { - WriteLog $_ - Write-Error "Error occurred while installing $ADKOption. Please manually install it." - throw $_ - } -} -function Get-InstalledProgramRegKey { - param ( - [string]$DisplayName - ) - - $uninstallRegPath = "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall" - $uninstallRegKeys = Get-ChildItem -Path $uninstallRegPath -Recurse - - foreach ($regKey in $uninstallRegKeys) { - try { - $regValue = $regKey.GetValue("DisplayName") - if ($regValue -eq $DisplayName) { - return $regKey - } - } - catch { - WriteLog $_ - throw "Error retrieving installed program info for $DisplayName : $_" - } - } -} - -function Uninstall-ADK { - param ( - [ValidateSet("Windows ADK", "WinPE add-on")] - [string]$ADKOption - ) - - # Match name as it appears in the registry - $displayName = switch ($ADKOption) { - "Windows ADK" { "Windows Assessment and Deployment Kit" } - "WinPE add-on" { "Windows Assessment and Deployment Kit Windows Preinstallation Environment Add-ons" } - } - - try { - $adkRegKey = Get-InstalledProgramRegKey -DisplayName $displayName - - if (-not $adkRegKey) { - WriteLog "$ADKOption is not installed." - return - } - - $adkBundleCachePath = $adkRegKey.GetValue("BundleCachePath") - WriteLog "Uninstalling $ADKOption..." - Invoke-Process $adkBundleCachePath "/uninstall /quiet" - WriteLog "$ADKOption uninstalled successfully." - } - catch { - WriteLog $_ - Write-Error "Error occurred while uninstalling $ADKOption. Please manually uninstall it." - throw $_ - } -} - -function Confirm-ADKVersionIsLatest { - param ( - [ValidateSet("Windows ADK", "WinPE add-on")] - [string]$ADKOption - ) - - $displayName = switch ($ADKOption) { - "Windows ADK" { "Windows Assessment and Deployment Kit" } - "WinPE add-on" { "Windows Assessment and Deployment Kit Windows Preinstallation Environment Add-ons" } - } - - try { - $adkRegKey = Get-InstalledProgramRegKey -DisplayName $displayName - - if (-not $adkRegKey) { - return $false - } - - $installedADKVersion = $adkRegKey.GetValue("DisplayVersion") - - # Retrieve content of Microsoft documentation page - $adkWebPage = Invoke-RestMethod "https://learn.microsoft.com/en-us/windows-hardware/get-started/adk-install" -Headers $Headers -UserAgent $UserAgent - # Specify regex pattern for ADK version - $adkVersionPattern = 'ADK\s+(\d+(\.\d+)+)' - # Check for regex pattern match - $adkVersionMatch = [regex]::Match($adkWebPage, $adkVersionPattern) - - if (-not $adkVersionMatch.Success) { - WriteLog "Failed to retrieve latest ADK version from web page." - return $false - } - - # Extract ADK version from the matched pattern - $latestADKVersion = $adkVersionMatch.Groups[1].Value - - if ($installedADKVersion -eq $latestADKVersion) { - WriteLog "Installed $ADKOption version $installedADKVersion is the latest." - return $true - } - else { - WriteLog "Installed $ADKOption version $installedADKVersion is not the latest ($latestADKVersion)" - return $false - } - } - catch { - WriteLog "An error occurred while confirming the ADK version: $_" - return $false - } -} - -function Get-ADK { - # Check if latest ADK and WinPE add-on are installed - $latestADKInstalled = Confirm-ADKVersionIsLatest -ADKOption "Windows ADK" - $latestWinPEInstalled = Confirm-ADKVersionIsLatest -ADKOption "WinPE add-on" - - # Uninstall older versions and install latest versions if necessary - if (-not $latestADKInstalled) { - Uninstall-ADK -ADKOption "Windows ADK" - Install-ADK -ADKOption "Windows ADK" - } - - if (-not $latestWinPEInstalled) { - Uninstall-ADK -ADKOption "WinPE add-on" - Install-ADK -ADKOption "WinPE add-on" - } - - # Define registry path - $adkPathKey = "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows Kits\Installed Roots" - $adkPathName = "KitsRoot10" - - # Check if ADK installation path exists in registry - $adkPathNameExists = (Get-ItemProperty -Path $adkPathKey -Name $adkPathName -ErrorAction SilentlyContinue) - - if ($adkPathNameExists) { - # Get the ADK installation path - WriteLog 'Get ADK Path' - $adkPath = (Get-ItemProperty -Path $adkPathKey -Name $adkPathName).$adkPathName - WriteLog "ADK located at $adkPath" - } - else { - throw "Windows ADK installation path could not be found." - } - - # If ADK was already installed, then check if the Windows Deployment Tools feature is also installed - $deploymentToolsRegKey = Get-InstalledProgramRegKey -DisplayName "Windows Deployment Tools" - - if (-not $deploymentToolsRegKey) { - WriteLog "ADK is installed, but the Windows Deployment Tools feature is not installed." - $adkRegKey = Get-InstalledProgramRegKey -DisplayName "Windows Assessment and Deployment Kit" - $adkBundleCachePath = $adkRegKey.GetValue("BundleCachePath") - if ($adkBundleCachePath) { - WriteLog "Installing Windows Deployment Tools..." - $adkInstallPath = $adkPath.TrimEnd('\') - Invoke-Process $adkBundleCachePath "/quiet /installpath ""$adkInstallPath"" /features OptionId.DeploymentTools" - WriteLog "Windows Deployment Tools installed successfully." - } - else { - throw "Failed to retrieve path to adksetup.exe to install the Windows Deployment Tools. Please manually install it." - } - } - return $adkPath -} -function Get-WindowsESD { - param( - [Parameter(Mandatory = $false)] - [ValidateSet(10, 11)] - [int]$WindowsRelease, - - [Parameter(Mandatory = $false)] - [ValidateSet('x86', 'x64', 'ARM64')] - [string]$WindowsArch, - - [Parameter(Mandatory = $false)] - [string]$WindowsLang, - - [Parameter(Mandatory = $false)] - [ValidateSet('consumer', 'business')] - [string]$MediaType - ) - WriteLog "Downloading Windows $WindowsRelease ESD file" - WriteLog "Windows Architecture: $WindowsArch" - WriteLog "Windows Language: $WindowsLang" - WriteLog "Windows Media Type: $MediaType" - - # Select cab file URL based on Windows Release - $cabFileUrl = if ($WindowsRelease -eq 10) { - 'https://go.microsoft.com/fwlink/?LinkId=841361' - } - else { - 'https://go.microsoft.com/fwlink/?LinkId=2156292' - } - - # Download cab file - WriteLog "Downloading Cab file" - $cabFilePath = Join-Path $PSScriptRoot "tempCabFile.cab" - $OriginalVerbosePreference = $VerbosePreference - $VerbosePreference = 'SilentlyContinue' - Invoke-WebRequest -Uri $cabFileUrl -OutFile $cabFilePath -Headers $Headers -UserAgent $UserAgent - $VerbosePreference = $OriginalVerbosePreference - WriteLog "Download succeeded" - - # Extract XML from cab file - WriteLog "Extracting Products XML from cab" - $xmlFilePath = Join-Path $PSScriptRoot "products.xml" - Invoke-Process Expand "-F:*.xml $cabFilePath $xmlFilePath" - WriteLog "Products XML extracted" - - # Load XML content - [xml]$xmlContent = Get-Content -Path $xmlFilePath - - # Define the client type to look for in the FilePath - $clientType = if ($MediaType -eq 'consumer') { 'CLIENTCONSUMER' } else { 'CLIENTBUSINESS' } - - # Find FilePath values based on WindowsArch, WindowsLang, and MediaType - foreach ($file in $xmlContent.MCT.Catalogs.Catalog.PublishedMedia.Files.File) { - if ($file.Architecture -eq $WindowsArch -and $file.LanguageCode -eq $WindowsLang -and $file.FilePath -like "*$clientType*") { - $esdFilePath = Join-Path $PSScriptRoot (Split-Path $file.FilePath -Leaf) - #Download if ESD file doesn't already exist - If (-not (Test-Path $esdFilePath)) { - #Required to fix slow downloads - $ProgressPreference = 'SilentlyContinue' - WriteLog "Downloading $($file.filePath) to $esdFIlePath" - $OriginalVerbosePreference = $VerbosePreference - $VerbosePreference = 'SilentlyContinue' - Invoke-WebRequest -Uri $file.FilePath -OutFile $esdFilePath -Headers $Headers -UserAgent $UserAgent - $VerbosePreference = $OriginalVerbosePreference - WriteLog "Download succeeded" - #Set back to show progress - $ProgressPreference = 'Continue' - WriteLog "Cleanup cab and xml file" - Remove-Item -Path $cabFilePath -Force - Remove-Item -Path $xmlFilePath -Force - WriteLog "Cleanup done" - } - return $esdFilePath - } - } -} - - - -function Get-ODTURL { - - # [String]$MSWebPage = Invoke-RestMethod 'https://www.microsoft.com/en-us/download/confirmation.aspx?id=49117' - [String]$MSWebPage = Invoke-RestMethod 'https://www.microsoft.com/en-us/download/confirmation.aspx?id=49117' -Headers $Headers -UserAgent $UserAgent - - $MSWebPage | ForEach-Object { - if ($_ -match 'url=(https://.*officedeploymenttool.*\.exe)') { - $matches[1] - } - } -} - -function Get-Office { - #Download ODT - $ODTUrl = Get-ODTURL - $ODTInstallFile = "$env:TEMP\odtsetup.exe" - WriteLog "Downloading Office Deployment Toolkit from $ODTUrl to $ODTInstallFile" - $OriginalVerbosePreference = $VerbosePreference - $VerbosePreference = 'SilentlyContinue' - Invoke-WebRequest -Uri $ODTUrl -OutFile $ODTInstallFile -Headers $Headers -UserAgent $UserAgent - $VerbosePreference = $OriginalVerbosePreference - - # Extract ODT - WriteLog "Extracting ODT to $OfficePath" - Invoke-Process $ODTInstallFile "/extract:$OfficePath /quiet" - - # Run setup.exe with config.xml and modify xml file to download to $OfficePath - $ConfigXml = "$OfficePath\DownloadFFU.xml" - $xmlContent = [xml](Get-Content $ConfigXml) - $xmlContent.Configuration.Add.SourcePath = $OfficePath - $xmlContent.Save($ConfigXml) - WriteLog "Downloading M365 Apps/Office to $OfficePath" - Invoke-Process $OfficePath\setup.exe "/download $ConfigXml" - - WriteLog "Cleaning up ODT default config files and checking InstallAppsandSysprep.cmd file for proper command line" - #Clean up default configuration files - Remove-Item -Path "$OfficePath\configuration*" -Force - - #Read the contents of the InstallAppsandSysprep.cmd file - $content = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - - #Update the InstallAppsandSysprep.cmd file with the Office install command - $officeCommand = "d:\Office\setup.exe /configure d:\Office\DeployFFU.xml" - - # Check if Office command is not commented out or missing and fix it if it is - if ($content[3] -ne $officeCommand) { - $content[3] = $officeCommand - - # Write the modified content back to the file - Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" -Value $content - } -} - -function Install-WinGet { - param ( - [string]$Architecture - ) - $packages = @( - @{Name = "VCLibs"; Url = "https://aka.ms/Microsoft.VCLibs.$Architecture.14.00.Desktop.appx"; File = "Microsoft.VCLibs.$Architecture.14.00.Desktop.appx"}, - @{Name = "UIXaml"; Url = "https://github.com/microsoft/microsoft-ui-xaml/releases/download/v2.8.6/Microsoft.UI.Xaml.2.8.$Architecture.appx"; File = "Microsoft.UI.Xaml.2.8.$Architecture.appx"}, - @{Name = "WinGet"; Url = "https://aka.ms/getwinget"; File = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle"} - ) - foreach ($package in $packages) { - $destination = Join-Path -Path $env:TEMP -ChildPath $package.File - WriteLog "Downloading $($package.Name) from $($package.Url) to $destination" - Start-BitsTransferWithRetry -Source $package.Url -Destination $destination - WriteLog "Installing $($package.Name)..." - Add-AppxPackage -Path $destination -ErrorAction SilentlyContinue - WriteLog "Removing $($package.Name)..." - Remove-Item -Path $destination -Force -ErrorAction SilentlyContinue - } - WriteLog "WinGet installation complete." -} - -function Confirm-WinGetInstallation { - WriteLog 'Checking if WinGet is installed...' - $wingetPath = "$env:LOCALAPPDATA\Microsoft\WindowsApps\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\winget.exe" - $minVersion = [version]"1.8.1911" - if (-not (Test-Path -Path $wingetPath -PathType Leaf)) { - WriteLog "WinGet is not installed. Downloading WinGet..." - Install-WinGet -Architecture $WindowsArch - return - } - if (-not (Get-Command -Name winget -ErrorAction SilentlyContinue)) { - WriteLog "WinGet not found. Downloading WinGet..." - Install-WinGet -Architecture $WindowsArch - return - } - $wingetVersion = & winget.exe --version - WriteLog "Installed version of WinGet: $wingetVersion" - if ($wingetVersion -match 'v?(\d+\.\d+\.\d+)' -and [version]$matches[1] -lt $minVersion) { - WriteLog "The installed version of WinGet $($matches[1]) does not support downloading MSStore apps. Downloading the latest version of WinGet..." - Install-WinGet -Architecture $WindowsArch - return - } -} - -function Add-Win32SilentInstallCommand { - param ( - [string]$AppFolder, - [string]$AppFolderPath - ) - $appName = $AppFolder - $installerPath = Get-ChildItem -Path "$appFolderPath\*" -Include "*.exe", "*.msi" -File -ErrorAction Stop - if (-not $installerPath) { - WriteLog "No win32 app installers were found. Skipping the inclusion of $AppFolder" - Remove-Item -Path $AppFolderPath -Recurse -Force - return $false - } - $yamlFile = Get-ChildItem -Path "$appFolderPath\*" -Include "*.yaml" -File -ErrorAction Stop - $yamlContent = Get-Content -Path $yamlFile -Raw - $silentInstallSwitch = [regex]::Match($yamlContent, 'Silent:\s*(.+)').Groups[1].Value.Replace("'", "").Trim() - if (-not $silentInstallSwitch) { - WriteLog "Silent install switch for $appName could not be found. Skipping the inclusion of $appName." - Remove-Item -Path $appFolderPath -Recurse -Force - return $false - } - $installer = Split-Path -Path $installerPath -Leaf - if ($installerPath.Extension -eq ".exe") { - $silentInstallCommand = "`"D:\win32\$appFolder\$installer`" $silentInstallSwitch" - } - elseif ($installerPath.Extension -eq ".msi") { - $silentInstallCommand = "msiexec /i `"D:\win32\$appFolder\$installer`" $silentInstallSwitch" - } - $cmdFile = "$AppsPath\InstallAppsandSysprep.cmd" - $cmdContent = Get-Content -Path $cmdFile - $UpdatedcmdContent = $CmdContent -replace '^(REM Winget Win32 Apps)', ("REM Winget Win32 Apps`r`nREM Win32 $($AppName)`r`n$($silentInstallCommand.Trim())") - WriteLog "Writing silent install command for $appName to InstallAppsandSysprep.cmd" - Set-Content -Path $cmdFile -Value $UpdatedcmdContent -} - -function Set-InstallStoreAppsFlag { - $cmdPath = "$AppsPath\InstallAppsandSysprep.cmd" - $cmdContent = Get-Content -Path $cmdPath - if ($cmdContent -match 'set "INSTALL_STOREAPPS=false"') { - WriteLog "Setting INSTALL_STOREAPPS flag to true in InstallAppsandSysprep.cmd file." - $updatedcmdContent = $cmdContent -replace 'set "INSTALL_STOREAPPS=false"', 'set "INSTALL_STOREAPPS=true"' - Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" -Value $updatedcmdContent - } -} - -function Get-WinGetApp { - param ( - [string]$WinGetAppName, - [string]$WinGetAppId - ) - $wingetSearchResult = & winget.exe search --id "$WinGetAppId" --exact --accept-source-agreements --source winget - if ($wingetSearchResult -contains "No package found matching input criteria.") { - WriteLog "$WinGetAppName not found in WinGet repository. Skipping download." - } - $appFolderPath = Join-Path -Path "$AppsPath\Win32" -ChildPath $WinGetAppName - WriteLog "Creating $appFolderPath" - New-Item -Path $appFolderPath -ItemType Directory -Force | Out-Null - WriteLog "Downloading $WinGetAppName to $appFolderPath" - $downloadParams = @( - "download", - "--id", "$WinGetAppId", - "--exact", - "--download-directory", "$appFolderPath", - "--accept-package-agreements", - "--accept-source-agreements", - "--source", "winget", - "--scope", "machine", - "--architecture", "$WindowsArch" - ) - WriteLog "winget command: winget.exe $downloadParams" - $wingetDownloadResult = & winget.exe @downloadParams | Out-String - if ($wingetDownloadResult -match "No applicable installer found") { - WriteLog "No installer found for $WindowsArch architecture. Attempting to download without specifying architecture..." - $downloadParams = $downloadParams | Where-Object { $_ -notmatch "--architecture" -and $_ -notmatch "$WindowsArch" } - $wingetDownloadResult = & winget.exe @downloadParams | Out-String - if ($wingetDownloadResult -match "Installer downloaded") { - WriteLog "Downloaded $WinGetAppName without specifying architecture." - } - } - if ($wingetDownloadResult -notmatch "Installer downloaded") { - WriteLog "No installer found for $WinGetAppName. Skipping download." - Remove-Item -Path $appFolderPath -Recurse -Force - } - WriteLog "$WinGetAppName downloaded to $appFolderPath" - $installerPath = Get-ChildItem -Path "$appFolderPath\*" -Exclude "*.yaml", "*.xml" -File -ErrorAction Stop - $uwpExtensions = @(".appx", ".appxbundle", ".msix", ".msixbundle") - if ($uwpExtensions -contains $installerPath.Extension) { - $NewAppPath = "$AppsPath\MSStore\$WinGetAppName" - Writelog "$WinGetAppName is a UWP app. Moving to $NewAppPath" - WriteLog "Creating $NewAppPath" - New-Item -Path "$AppsPath\MSStore\$WinGetAppName" -ItemType Directory -Force | Out-Null - WriteLog "Moving $WinGetAppName to $NewAppPath" - Move-Item -Path "$appFolderPath\*" -Destination "$AppsPath\MSStore\$WinGetAppName" -Force - WriteLog "Removing $appFolderPath" - Remove-Item -Path $appFolderPath -Force - WriteLog "$WinGetAppName moved to $NewAppPath" - Set-InstallStoreAppsFlag - } - else { - Add-Win32SilentInstallCommand -AppFolder $WinGetAppName -AppFolderPath $appFolderPath - } -} - -function Get-StoreApp { - param ( - [string]$StoreAppName, - [string]$StoreAppId - ) - $wingetSearchResult = & winget.exe search "$StoreAppId" --accept-source-agreements --source msstore - if ($wingetSearchResult -contains "No package found matching input criteria.") { - WriteLog "$StoreAppName not found in WinGet repository. Skipping download." - return - } - WriteLog "Checking if $StoreAppName is a win32 app..." - $appIsWin32 = $StoreAppId.StartsWith("XP") - if ($appIsWin32) { - WriteLog "$StoreAppName is a win32 app. Adding to $AppsPath\win32 folder" - $appFolderPath = Join-Path -Path "$AppsPath\win32" -ChildPath $StoreAppName - } - else { - WriteLog "$StoreAppName is not a win32 app." - $appFolderPath = Join-Path -Path "$AppsPath\MSStore" -ChildPath $StoreAppName - } - New-Item -Path $appFolderPath -ItemType Directory -Force | Out-Null - WriteLog "Downloading $StoreAppName for $WindowsArch architecture..." - $downloadParams = @( - "download", "$StoreAppId", - "--download-directory", "$appFolderPath", - "--accept-package-agreements", - "--accept-source-agreements", - "--source", "msstore", - "--scope", "machine", - "--architecture", "$WindowsArch" - ) - WriteLog 'MSStore app downloads require authentication with an Entra ID account. You may be prompted twice for credentials, once for the app and another for the license file.' - WriteLog "Attempting to download $StoreAppName and dependencies for $WindowsArch architecture..." - $wingetDownloadResult = & winget.exe @downloadParams | Out-String - # For some apps, specifying the architecture leads to no results found for the app. In those cases, the command will be run without the architecture parameter. - if ($wingetDownloadResult -match "No applicable installer found") { - WriteLog "No installer found for $WindowsArch architecture. Attempting to download without specifying architecture..." - $downloadParams = $downloadParams | Where-Object { $_ -notmatch "--architecture" -and $_ -notmatch "$WindowsArch" } - $wingetDownloadResult = & winget.exe @downloadParams | Out-String - if ($wingetDownloadResult -match "Microsoft Store package download completed") { - WriteLog "Downloaded $StoreAppName without specifying architecture." - } - } - if ($wingetDownloadResult -notmatch "Installer downloaded|Microsoft Store package download completed") { - WriteLog "Download not supported for $StoreAppName. Skipping download." - Remove-Item -Path $appFolderPath -Recurse -Force - return - } - if ($appIsWin32) { - Add-Win32SilentInstallCommand -AppFolder $StoreAppName -AppFolderPath $appFolderPath - } - Set-InstallStoreAppsFlag - # If $WindowsArch -eq 'ARM64', remove all dependency files that are not ARM64 - if ($WindowsArch -eq 'ARM64') { - WriteLog 'Windows architecture is ARM64. Removing dependencies that are not ARM64.' - $dependencies = Get-ChildItem -Path "$appFolderPath\Dependencies" -ErrorAction SilentlyContinue - if ($dependencies) { - foreach ($dependency in $dependencies) { - if ($dependency.Name -notmatch 'ARM64') { - WriteLog "Removing dependency file $($dependency.FullName)" - Remove-Item -Path $dependency.FullName -Recurse -Force - } - } - } - } - WriteLog "$StoreAppName has completed downloading. Identifying the latest version of $StoreAppName." - $packages = Get-ChildItem -Path "$appFolderPath\*" -Exclude "Dependencies\*", "*.xml", "*.yaml" -File -ErrorAction Stop - # WinGet downloads multiple versions of certain store apps. The latest version of the package will be determined based on the date of the file signature. - $latestPackage = $packages | Sort-Object { (Get-AuthenticodeSignature $_.FullName).SignerCertificate.NotBefore } -Descending | Select-Object -First 1 - # Removing all packages that are not the latest version - WriteLog "Latest version of $StoreAppName has been identified as $latestPackage. Removing old versions of $StoreAppName that may have downloaded." - foreach ($package in $packages) { - if ($package.FullName -ne $latestPackage) { - try { - WriteLog "Removing $($package.FullName)" - Remove-Item -Path $package.FullName -Force - } - catch { - WriteLog "Failed to delete: $($package.FullName) - $_" - throw $_ - } - } - } -} - -function Get-Apps { - param ( - [string]$AppList - ) - $apps = Get-Content -Path $AppList -Raw | ConvertFrom-Json - if (-not $apps) { - WriteLog "No apps were specified in AppList.json file." - return - } - $wingetApps = $apps.apps | Where-Object { $_.source -eq "winget" } - # List each Winget app in the AppList.json file - if ($wingetApps) { - WriteLog 'Winget apps to be installed:' - foreach ($wingetapp in $wingetApps){ - WriteLog "$($wingetapp.Name)" - } - } - $StoreApps = $apps.apps | Where-Object { $_.source -eq "msstore" } - # List each Store app in the AppList.json file - if ($StoreApps) { - WriteLog 'Store apps to be installed:' - foreach ($StoreApp in $StoreApps){ - WriteLog "$($StoreApp.Name)" - } - } - Confirm-WinGetInstallation - $win32Folder = Join-Path -Path $AppsPath -ChildPath "Win32" - $storeAppsFolder = Join-Path -Path $AppsPath -ChildPath "MSStore" - if ($wingetApps) { - if (-not (Test-Path -Path $win32Folder -PathType Container)) { - WriteLog "Creating folder for Winget Win32 apps: $win32Folder" - New-Item -Path $win32Folder -ItemType Directory -Force | Out-Null - WriteLog "Folder created successfully." - } - foreach ($wingetApp in $wingetApps) { - try { - Get-WinGetApp -WinGetAppName $wingetApp.Name -WinGetAppId $wingetApp.Id - } - catch { - WriteLog "Error occurred while processing $wingetApp : $_" - throw $_ - } - } - } - if ($storeApps) { - if (-not (Test-Path -Path $storeAppsFolder -PathType Container)) { - New-Item -Path $storeAppsFolder -ItemType Directory -Force | Out-Null - } - foreach ($storeApp in $storeApps) { - try { - Get-StoreApp -StoreAppName $storeApp.Name -StoreAppId $storeApp.Id - } - catch { - WriteLog "Error occurred while processing $storeApp : $_" - throw $_ - } - } - } -} - -function Get-KBLink { - param( - [Parameter(Mandatory)] - [string]$Name - ) - $OriginalVerbosePreference = $VerbosePreference - $VerbosePreference = 'SilentlyContinue' - $results = Invoke-WebRequest -Uri "http://www.catalog.update.microsoft.com/Search.aspx?q=$Name" -Headers $Headers -UserAgent $UserAgent - $VerbosePreference = $OriginalVerbosePreference - $kbids = $results.InputFields | - Where-Object { $_.type -eq 'Button' -and $_.Value -eq 'Download' } | - Select-Object -ExpandProperty ID - - Write-Verbose -Message "$kbids" - - if (-not $kbids) { - Write-Warning -Message "No results found for $Name" - return - } - - $guids = $results.Links | - Where-Object ID -match '_link' | - Where-Object { $_.OuterHTML -match ( "(?=.*" + ( $Filter -join ")(?=.*" ) + ")" ) } | - ForEach-Object { $_.id.replace('_link', '') } | - Where-Object { $_ -in $kbids } - - if (-not $guids) { - Write-Warning -Message "No file found for $Name" - return - } - - foreach ($guid in $guids) { - Write-Verbose -Message "Downloading information for $guid" - $post = @{ size = 0; updateID = $guid; uidInfo = $guid } | ConvertTo-Json -Compress - $body = @{ updateIDs = "[$post]" } - $OriginalVerbosePreference = $VerbosePreference - $VerbosePreference = 'SilentlyContinue' - $links = Invoke-WebRequest -Uri 'https://www.catalog.update.microsoft.com/DownloadDialog.aspx' -Method Post -Body $body -Headers $Headers -UserAgent $UserAgent | - Select-Object -ExpandProperty Content | - Select-String -AllMatches -Pattern "http[s]?://[^']*\.microsoft\.com/[^']*|http[s]?://[^']*\.windowsupdate\.com/[^']*" | - Select-Object -Unique - $VerbosePreference = $OriginalVerbosePreference - - foreach ($link in $links) { - $link.matches.value - #Filter out cab files - # #if ($link -notmatch '\.cab') { - # $link.matches.value - # } - - } - } -} -function Get-LatestWindowsKB { - param ( - [ValidateSet(10, 11)] - [int]$WindowsRelease - ) - - # Define the URL of the update history page based on the Windows release - if ($WindowsRelease -eq 11) { - $updateHistoryUrl = 'https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information' - } - else { - $updateHistoryUrl = 'https://learn.microsoft.com/en-us/windows/release-health/release-information' - } - - # Use Invoke-WebRequest to fetch the content of the page - $OriginalVerbosePreference = $VerbosePreference - $VerbosePreference = 'SilentlyContinue' - $response = Invoke-WebRequest -Uri $updateHistoryUrl -Headers $Headers -UserAgent $UserAgent - $VerbosePreference = $OriginalVerbosePreference - - # Use a regular expression to find the KB article number - $kbArticleRegex = 'KB\d+' - $kbArticle = [regex]::Match($response.Content, $kbArticleRegex).Value - - return $kbArticle -} - -function Save-KB { - [CmdletBinding()] - param( - [string[]]$Name, - [string]$Path - ) - - if ($WindowsArch -eq 'x64') { - [array]$WindowsArch = @("x64", "amd64") - } - #Keep for now, will remove in future - # foreach ($kb in $name) { - # $links = Get-KBLink -Name $kb - # foreach ($link in $links) { - # #Check if $WindowsArch is an array - # if ($WindowsArch -is [array]) { - # #Some file names include either x64 or amd64 - # if ($link -match $WindowsArch[0] -or $link -match $WindowsArch[1]) { - # Start-BitsTransferWithRetry -Source $link -Destination $Path - # $fileName = ($link -split '/')[-1] - # break - # } - # # elseif (!($link -match 'x64' -or $link -match 'amd64' -or $link -match 'x86' -or $link -match 'arm64')) { - # # Write-Host "No architecture found in $link, assume it's for all architectures" - # # Start-BitsTransfer -Source $link -Destination $Path - # # $fileName = ($link -split '/')[-1] - # # break - # # } - # elseif (!($link -match 'x64' -or $link -match 'amd64' -or $link -match 'x86' -or $link -match 'arm64')) { - # WriteLog "No architecture found in $link, assume this is for all architectures" - # #FIX: 3/22/2024 - the SecurityHealthSetup fix was updated and now includes two files (one is x64 and the other is arm64) - # #Unfortunately there is no easy way to determine the architecture from the file name - # #There is a support doc that include links to download, but it's out of date (n-1) - # #https://support.microsoft.com/en-us/topic/windows-security-update-a6ac7d2e-b1bf-44c0-a028-41720a242da3 - # #These files don't change that often, so will check the link above to see when it updates and may use that - # #For now this is hard-coded for these specific file names - # if ($link -match 'security'){ - # #Make sure we're getting the correct architecture for the Security Health Setup update - # WriteLog "Link: $link matches security" - # if ($WindowsArch -eq 'x64'){ - # if ($link -match 'securityhealthsetup_e1'){ - # Writelog "Downloading $Link for $WindowsArch to $Path" - # Start-BitsTransferWithRetry -Source $link -Destination $Path - # $fileName = ($link -split '/')[-1] - # Writelog "Returning $fileName" - # break - # } - # } - # elseif ($WindowsArch -eq 'arm64'){ - # if ($link -match 'securityhealthsetup_25'){ - # Writelog "Downloading $Link for $WindowsArch to $Path" - # Start-BitsTransferWithRetry -Source $link -Destination $Path - # $fileName = ($link -split '/')[-1] - # Writelog "Returning $fileName" - # break - # } - # } - # continue - # } - # Start-BitsTransferWithRetry -Source $link -Destination $Path - # $fileName = ($link -split '/')[-1] - # } - # } - # else { - # if ($link -match $WindowsArch) { - # Start-BitsTransferWithRetry -Source $link -Destination $Path - # $fileName = ($link -split '/')[-1] - # break - # } - # } - # } - # } - foreach ($kb in $name) { - $links = Get-KBLink -Name $kb - foreach ($link in $links) { - if (!($link -match 'x64' -or $link -match 'amd64' -or $link -match 'x86' -or $link -match 'arm64')) { - WriteLog "No architecture found in $link, assume this is for all architectures" - #FIX: 3/22/2024 - the SecurityHealthSetup fix was updated and now includes two files (one is x64 and the other is arm64) - #Unfortunately there is no easy way to determine the architecture from the file name - #There is a support doc that include links to download, but it's out of date (n-1) - #https://support.microsoft.com/en-us/topic/windows-security-update-a6ac7d2e-b1bf-44c0-a028-41720a242da3 - #These files don't change that often, so will check the link above to see when it updates and may use that - #For now this is hard-coded for these specific file names - if ($link -match 'security') { - #Make sure we're getting the correct architecture for the Security Health Setup update - WriteLog "Link: $link matches security" - if ($WindowsArch -eq 'x64') { - if ($link -match 'securityhealthsetup_e1') { - Writelog "Downloading $Link for $WindowsArch to $Path" - Start-BitsTransferWithRetry -Source $link -Destination $Path - $fileName = ($link -split '/')[-1] - Writelog "Returning $fileName" - break - } - } - if ($WindowsArch -eq 'arm64') { - if ($link -match 'securityhealthsetup_25') { - Writelog "Downloading $Link for $WindowsArch to $Path" - Start-BitsTransferWithRetry -Source $link -Destination $Path - $fileName = ($link -split '/')[-1] - Writelog "Returning $fileName" - break - } - } - } - } - - if ($link -match 'x64' -or $link -match 'amd64') { - if($WindowsArch -is [array]) { - if ($link -match $WindowsArch[0] -or $link -match $WindowsArch[1]) { - Writelog "Downloading $Link for $WindowsArch to $Path" - Start-BitsTransferWithRetry -Source $link -Destination $Path - $fileName = ($link -split '/')[-1] - Writelog "Returning $fileName" - break - } - } - - } - if ($link -match 'arm64') { - if ($WindowsArch -eq 'arm64') { - Writelog "Downloading $Link for $WindowsArch to $Path" - Start-BitsTransferWithRetry -Source $link -Destination $Path - $fileName = ($link -split '/')[-1] - Writelog "Returning $fileName" - break - } - } - } - } - return $fileName -} - -function New-AppsISO { - #Create Apps ISO file - $OSCDIMG = "$adkpath`Assessment and Deployment Kit\Deployment Tools\amd64\Oscdimg\oscdimg.exe" - #Start-Process -FilePath $OSCDIMG -ArgumentList "-n -m -d $Appspath $AppsISO" -wait - Invoke-Process $OSCDIMG "-n -m -d $Appspath $AppsISO" - - #Remove the Office Download and ODT - if ($InstallOffice) { - $ODTPath = "$AppsPath\Office" - $OfficeDownloadPath = "$ODTPath\Office" - WriteLog 'Cleaning up Office and ODT download' - Remove-Item -Path $OfficeDownloadPath -Recurse -Force - Remove-Item -Path "$ODTPath\setup.exe" - } -} -function Get-WimFromISO { - #Mount ISO, get Wim file - $mountResult = Mount-DiskImage -ImagePath $isoPath -PassThru - $sourcesFolder = ($mountResult | Get-Volume).DriveLetter + ":\sources\" - - # Check for install.wim or install.esd - $wimPath = (Get-ChildItem $sourcesFolder\install.* | Where-Object { $_.Name -match "install\.(wim|esd)" }).FullName - - if ($wimPath) { - WriteLog "The path to the install file is: $wimPath" - } - else { - WriteLog "No install.wim or install.esd file found in: $sourcesFolder" - } - - return $wimPath -} - - -function Get-WimIndex { - param ( - [Parameter(Mandatory = $true)] - [string]$WindowsSKU - ) - WriteLog "Getting WIM Index for Windows SKU: $WindowsSKU" - - If ($ISOPath) { - $wimindex = switch ($WindowsSKU) { - 'Home' { 1 } - 'Home_N' { 2 } - 'Home_SL' { 3 } - 'EDU' { 4 } - 'EDU_N' { 5 } - 'Pro' { 6 } - 'Pro_N' { 7 } - 'Pro_EDU' { 8 } - 'Pro_Edu_N' { 9 } - 'Pro_WKS' { 10 } - 'Pro_WKS_N' { 11 } - Default { 6 } - } - } - - Writelog "WIM Index: $wimindex" - return $WimIndex -} - -function Get-Index { - param( - [Parameter(Mandatory = $true)] - [string]$WindowsImagePath, - - [Parameter(Mandatory = $true)] - [string]$WindowsSKU - ) - - - # Get the available indexes using Get-WindowsImage - $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 - if($ISOPath){ - $imageIndex = $imageIndexes | Where-Object ImageIndex -eq 1 - $WindowsImage = $imageIndex.ImageName.Substring(0, 10) - } - else{ - $imageIndex = $imageIndexes | Where-Object ImageIndex -eq 4 - $WindowsImage = $imageIndex.ImageName.Substring(0, 10) - } - - # Concatenate $WindowsImage and $WindowsSKU (E.g. Windows 11 Pro) - $ImageNameToFind = "$WindowsImage $WindowsSKU" - - # Find the ImageName in all of the indexes in the image - $matchingImageIndex = $imageIndexes | Where-Object ImageName -eq $ImageNameToFind - - # Return the index that matches exactly - if ($matchingImageIndex) { - return $matchingImageIndex.ImageIndex - } - else { - # Look for either the number 10 or 11 in the ImageName - $relevantImageIndexes = $imageIndexes | Where-Object { ($_.ImageName -like "*10*") -or ($_.ImageName -like "*11*") } - - while ($true) { - # 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:" - - $i = 1 - $relevantImageIndexes | ForEach-Object { - Write-Host "$i. $($_.ImageName)" - $i++ - } - - # Ask for user input - $inputValue = Read-Host "Enter the number of the ImageName you want to use" - - # Get selected ImageName based on user input - $selectedImage = $relevantImageIndexes[$inputValue - 1] - - if ($selectedImage) { - return $selectedImage.ImageIndex - } - else { - Write-Host "Invalid selection, please try again." - } - } - } -} - -#Create VHDX -function New-ScratchVhdx { - param( - [Parameter(Mandatory = $true)] - [string]$VhdxPath, - [uint64]$SizeBytes = 30GB, - [uint32]$LogicalSectorSizeBytes, - [switch]$Dynamic, - [Microsoft.PowerShell.Cmdletization.GeneratedTypes.Disk.PartitionStyle]$PartitionStyle = [Microsoft.PowerShell.Cmdletization.GeneratedTypes.Disk.PartitionStyle]::GPT - ) - - WriteLog "Creating new Scratch VHDX..." - - $newVHDX = New-VHD -Path $VhdxPath -SizeBytes $disksize -LogicalSectorSizeBytes $LogicalSectorSizeBytes -Dynamic:($Dynamic.IsPresent) - $toReturn = $newVHDX | Mount-VHD -Passthru | Initialize-Disk -PassThru -PartitionStyle GPT - - #Remove auto-created partition so we can create the correct partition layout - remove-partition $toreturn.DiskNumber -PartitionNumber 1 -Confirm:$False - - Writelog "Done." - return $toReturn -} -#Add System Partition -function New-SystemPartition { - param( - [Parameter(Mandatory = $true)] - [ciminstance]$VhdxDisk, - [uint64]$SystemPartitionSize = 260MB - ) - - WriteLog "Creating System partition..." - - $sysPartition = $VhdxDisk | New-Partition -DriveLetter 'S' -Size $SystemPartitionSize -GptType "{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}" -IsHidden - $sysPartition | Format-Volume -FileSystem FAT32 -Force -NewFileSystemLabel "System" - - WriteLog 'Done.' - return $sysPartition.DriveLetter -} -#Add MSRPartition -function New-MSRPartition { - param( - [Parameter(Mandatory = $true)] - [ciminstance]$VhdxDisk - ) - - WriteLog "Creating MSR partition..." - - # $toReturn = $VhdxDisk | New-Partition -AssignDriveLetter -Size 16MB -GptType "{e3c9e316-0b5c-4db8-817d-f92df00215ae}" -IsHidden | Out-Null - $toReturn = $VhdxDisk | New-Partition -Size 16MB -GptType "{e3c9e316-0b5c-4db8-817d-f92df00215ae}" -IsHidden | Out-Null - - WriteLog "Done." - - return $toReturn -} -#Add OS Partition -function New-OSPartition { - param( - [Parameter(Mandatory = $true)] - [ciminstance]$VhdxDisk, - [Parameter(Mandatory = $true)] - [string]$WimPath, - [uint32]$WimIndex, - [uint64]$OSPartitionSize = 0 - ) - - WriteLog "Creating OS partition..." - - if ($OSPartitionSize -gt 0) { - $osPartition = $vhdxDisk | New-Partition -DriveLetter 'W' -Size $OSPartitionSize -GptType "{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}" - } - else { - $osPartition = $vhdxDisk | New-Partition -DriveLetter 'W' -UseMaximumSize -GptType "{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}" - } - - $osPartition | Format-Volume -FileSystem NTFS -Confirm:$false -Force -NewFileSystemLabel "Windows" - WriteLog 'Done' - Writelog "OS partition at drive $($osPartition.DriveLetter):" - - WriteLog "Writing Windows at $WimPath to OS partition at drive $($osPartition.DriveLetter):..." - - #Server 2019 is missing the Windows Overlay Filter (wof.sys), likely other Server SKUs are missing it as well. Script will error if trying to use the -compact switch on Server OSes - if ((Get-CimInstance Win32_OperatingSystem).Caption -match "Server") { - WriteLog (Expand-WindowsImage -ImagePath $WimPath -Index $WimIndex -ApplyPath "$($osPartition.DriveLetter):\") - } - if ($CompactOS) { - WriteLog '$CompactOS is set to true, using -Compact switch to apply the WIM file to the OS partition.' - WriteLog (Expand-WindowsImage -ImagePath $WimPath -Index $WimIndex -ApplyPath "$($osPartition.DriveLetter):\" -Compact) - } - else { - WriteLog (Expand-WindowsImage -ImagePath $WimPath -Index $WimIndex -ApplyPath "$($osPartition.DriveLetter):\") - } - - WriteLog 'Done' - return $osPartition -} -#Add Recovery partition -function New-RecoveryPartition { - param( - [Parameter(Mandatory = $true)] - [ciminstance]$VhdxDisk, - [Parameter(Mandatory = $true)] - $OsPartition, - [uint64]$RecoveryPartitionSize = 0, - [ciminstance]$DataPartition - ) - - WriteLog "Creating empty Recovery partition (to be filled on first boot automatically)..." - - $calculatedRecoverySize = 0 - $recoveryPartition = $null - - if ($RecoveryPartitionSize -gt 0) { - $calculatedRecoverySize = $RecoveryPartitionSize - } - else { - $winReWim = Get-ChildItem "$($OsPartition.DriveLetter):\Windows\System32\Recovery\Winre.wim" - - if (($null -ne $winReWim) -and ($winReWim.Count -eq 1)) { - # Wim size + 100MB is minimum WinRE partition size. - # NTFS and other partitioning size differences account for about 17MB of space that's unavailable. - # Adding 32MB as a buffer to ensure there's enough space to account for NTFS file system overhead. - # Adding 250MB as per recommendations from - # https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/configure-uefigpt-based-hard-drive-partitions?view=windows-11#recovery-tools-partition - $calculatedRecoverySize = $winReWim.Length + 250MB + 32MB - - WriteLog "Calculated space needed for recovery in bytes: $calculatedRecoverySize" - - if ($null -ne $DataPartition) { - $DataPartition | Resize-Partition -Size ($DataPartition.Size - $calculatedRecoverySize) - WriteLog "Data partition shrunk by $calculatedRecoverySize bytes for Recovery partition." - } - else { - $newOsPartitionSize = [math]::Floor(($OsPartition.Size - $calculatedRecoverySize) / 4096) * 4096 - $OsPartition | Resize-Partition -Size $newOsPartitionSize - WriteLog "OS partition shrunk by $calculatedRecoverySize bytes for Recovery partition." - } - - $recoveryPartition = $VhdxDisk | New-Partition -DriveLetter 'R' -UseMaximumSize -GptType "{de94bba4-06d1-4d40-a16a-bfd50179d6ac}" ` - | Format-Volume -FileSystem NTFS -Confirm:$false -Force -NewFileSystemLabel 'Recovery' - - WriteLog "Done. Recovery partition at drive $($recoveryPartition.DriveLetter):" - } - else { - WriteLog "No WinRE.WIM found in the OS partition under \Windows\System32\Recovery." - WriteLog "Skipping creating the Recovery partition." - WriteLog "If a Recovery partition is desired, please re-run the script setting the -RecoveryPartitionSize flag as appropriate." - } - } - - return $recoveryPartition -} -#Add boot files -function Add-BootFiles { - param( - [Parameter(Mandatory = $true)] - [string]$OsPartitionDriveLetter, - [Parameter(Mandatory = $true)] - [string]$SystemPartitionDriveLetter, - [string]$FirmwareType = 'UEFI' - ) - - WriteLog "Adding boot files for `"$($OsPartitionDriveLetter):\Windows`" to System partition `"$($SystemPartitionDriveLetter):`"..." - Invoke-Process bcdboot "$($OsPartitionDriveLetter):\Windows /S $($SystemPartitionDriveLetter): /F $FirmwareType" - WriteLog "Done." -} - -function Enable-WindowsFeaturesByName { - param ( - [Parameter(Mandatory = $true)] - [string]$FeatureNames, - [Parameter(Mandatory = $true)] - [string]$Source - ) - - $FeaturesArray = $FeatureNames.Split(';') - - # Looping through each feature and enabling it - foreach ($FeatureName in $FeaturesArray) { - WriteLog "Enabling Windows Optional feature: $FeatureName" - Enable-WindowsOptionalFeature -Path $WindowsPartition -FeatureName $FeatureName -All -Source $Source | Out-Null - WriteLog "Done" - } -} - -#Dismount VHDX -function Dismount-ScratchVhdx { - param( - [Parameter(Mandatory = $true)] - [string]$VhdxPath - ) - - if (Test-Path $VhdxPath) { - WriteLog "Dismounting scratch VHDX..." - Dismount-VHD -Path $VhdxPath - WriteLog "Done." - } -} - -function New-FFUVM { - #Create new Gen2 VM - $VM = New-VM -Name $VMName -Path $VMPath -MemoryStartupBytes $memory -VHDPath $VHDXPath -Generation 2 - Set-VMProcessor -VMName $VMName -Count $processors - - #Mount AppsISO - Add-VMDvdDrive -VMName $VMName -Path $AppsISO - - #Set Hard Drive as boot device - $VMHardDiskDrive = Get-VMHarddiskdrive -VMName $VMName - Set-VMFirmware -VMName $VMName -FirstBootDevice $VMHardDiskDrive - Set-VM -Name $VMName -AutomaticCheckpointsEnabled $false -StaticMemory - - #Configure TPM - New-HgsGuardian -Name $VMName -GenerateCertificates - $owner = get-hgsguardian -Name $VMName - $kp = New-HgsKeyProtector -Owner $owner -AllowUntrustedRoot - Set-VMKeyProtector -VMName $VMName -KeyProtector $kp.RawData - Enable-VMTPM -VMName $VMName - - #Connect to VM - WriteLog "Starting vmconnect localhost $VMName" - & vmconnect localhost "$VMName" - - #Start VM - Start-VM -Name $VMName - - return $VM -} - -Function Set-CaptureFFU { - $CaptureFFUScriptPath = "$FFUDevelopmentPath\WinPECaptureFFUFiles\CaptureFFU.ps1" - - 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 \\\ /user: - $SharePath = "\\$VMHostIPAddress\$ShareName /user:$UserName $Password" - $SharePath = "net use W: " + $SharePath - - # Update CaptureFFU.ps1 script - if (Test-Path -Path $CaptureFFUScriptPath) { - $ScriptContent = Get-Content -Path $CaptureFFUScriptPath - $UpdatedContent = $ScriptContent -replace '(net use).*', ("$SharePath") - WriteLog 'Updating share command in CaptureFFU.ps1 script with new share information' - Set-Content -Path $CaptureFFUScriptPath -Value $UpdatedContent - WriteLog 'Update complete' - } - else { - throw "CaptureFFU.ps1 script not found at $CaptureFFUScriptPath" - } -} - -function New-PEMedia { - param ( - [Parameter()] - [bool]$Capture, - [Parameter()] - [bool]$Deploy - ) - #Need to use the Demployment and Imaging tools environment to create winPE media - $DandIEnv = "$adkPath`Assessment and Deployment Kit\Deployment Tools\DandISetEnv.bat" - $WinPEFFUPath = "$FFUDevelopmentPath\WinPE" - - If (Test-path -Path "$WinPEFFUPath") { - WriteLog "Removing old WinPE path at $WinPEFFUPath" - Remove-Item -Path "$WinPEFFUPath" -Recurse -Force | out-null - } - - WriteLog "Copying WinPE files to $WinPEFFUPath" - if($WindowsArch -eq 'x64') { - & cmd /c """$DandIEnv"" && copype amd64 $WinPEFFUPath" | Out-Null - } - elseif($WindowsArch -eq 'arm64') { - & cmd /c """$DandIEnv"" && copype arm64 $WinPEFFUPath" | Out-Null - } - #Invoke-Process cmd "/c ""$DandIEnv"" && copype amd64 $WinPEFFUPath" - WriteLog 'Files copied successfully' - - WriteLog 'Mounting WinPE media to add WinPE optional components' - Mount-WindowsImage -ImagePath "$WinPEFFUPath\media\sources\boot.wim" -Index 1 -Path "$WinPEFFUPath\mount" | Out-Null - WriteLog 'Mounting complete' - - $Packages = @( - "WinPE-WMI.cab", - "en-us\WinPE-WMI_en-us.cab", - "WinPE-NetFX.cab", - "en-us\WinPE-NetFX_en-us.cab", - "WinPE-Scripting.cab", - "en-us\WinPE-Scripting_en-us.cab", - "WinPE-PowerShell.cab", - "en-us\WinPE-PowerShell_en-us.cab", - "WinPE-StorageWMI.cab", - "en-us\WinPE-StorageWMI_en-us.cab", - "WinPE-DismCmdlets.cab", - "en-us\WinPE-DismCmdlets_en-us.cab" - ) - - if($WindowsArch -eq 'x64'){ - $PackagePathBase = "$adkPath`Assessment and Deployment Kit\Windows Preinstallation Environment\amd64\WinPE_OCs\" - } - elseif($WindowsArch -eq 'arm64'){ - $PackagePathBase = "$adkPath`Assessment and Deployment Kit\Windows Preinstallation Environment\arm64\WinPE_OCs\" - } - - - foreach ($Package in $Packages) { - $PackagePath = Join-Path $PackagePathBase $Package - WriteLog "Adding Package $Package" - Add-WindowsPackage -Path "$WinPEFFUPath\mount" -PackagePath $PackagePath | Out-Null - WriteLog "Adding package complete" - } - If ($Capture) { - WriteLog "Copying $FFUDevelopmentPath\WinPECaptureFFUFiles\* to WinPE capture media" - Copy-Item -Path "$FFUDevelopmentPath\WinPECaptureFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | out-null - WriteLog "Copy complete" - #Remove Bootfix.bin - for BIOS systems, shouldn't be needed, but doesn't hurt to remove for our purposes - #Remove-Item -Path "$WinPEFFUPath\media\boot\bootfix.bin" -Force | Out-null - # $WinPEISOName = 'WinPE_FFU_Capture.iso' - $WinPEISOFile = $CaptureISO - # $Capture = $false - } - If ($Deploy) { - WriteLog "Copying $FFUDevelopmentPath\WinPEDeployFFUFiles\* to WinPE deploy media" - Copy-Item -Path "$FFUDevelopmentPath\WinPEDeployFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | Out-Null - WriteLog 'Copy complete' - #If $CopyPEDrivers = $true, add drivers to WinPE media using dism - if ($CopyPEDrivers) { - WriteLog "Adding drivers to WinPE media" - try { - Add-WindowsDriver -Path "$WinPEFFUPath\Mount" -Driver "$FFUDevelopmentPath\PEDrivers" -Recurse -ErrorAction SilentlyContinue | Out-null - } - catch { - WriteLog 'Some drivers failed to be added to the FFU. This can be expected. Continuing.' - } - WriteLog "Adding drivers complete" - } - # $WinPEISOName = 'WinPE_FFU_Deploy.iso' - $WinPEISOFile = $DeployISO - - # $Deploy = $false - } - WriteLog 'Dismounting WinPE media' - Dismount-WindowsImage -Path "$WinPEFFUPath\mount" -Save | Out-Null - WriteLog 'Dismount complete' - #Make ISO - if ($WindowsArch -eq 'x64') { - $OSCDIMGPath = "$adkPath`Assessment and Deployment Kit\Deployment Tools\amd64\Oscdimg" - } - elseif ($WindowsArch -eq 'arm64') { - $OSCDIMGPath = "$adkPath`Assessment and Deployment Kit\Deployment Tools\arm64\Oscdimg" - } - $OSCDIMG = "$OSCDIMGPath\oscdimg.exe" - WriteLog "Creating WinPE ISO at $WinPEISOFile" - # & "$OSCDIMG" -m -o -u2 -udfver102 -bootdata:2`#p0,e,b$OSCDIMGPath\etfsboot.com`#pEF,e,b$OSCDIMGPath\Efisys_noprompt.bin $WinPEFFUPath\media $FFUDevelopmentPath\$WinPEISOName | Out-null - if($WindowsArch -eq 'x64'){ - if($Capture){ - $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys_noprompt.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`"" - } - if($Deploy){ - $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`"" - } - } - elseif($WindowsArch -eq 'arm64'){ - if($Capture){ - $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys_noprompt.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`"" - } - if($Deploy){ - $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`"" - } - - } - Invoke-Process $OSCDIMG $OSCDIMGArgs - WriteLog "ISO created successfully" - WriteLog "Cleaning up $WinPEFFUPath" - Remove-Item -Path "$WinPEFFUPath" -Recurse -Force - WriteLog 'Cleanup complete' -} - -function Optimize-FFUCaptureDrive { - param ( - [string]$VhdxPath - ) - try { - WriteLog 'Mounting VHDX for volume optimization' - Mount-VHD -Path $VhdxPath - WriteLog 'Defragmenting Windows partition...' - Optimize-Volume -DriveLetter W -Defrag -NormalPriority -Verbose - WriteLog 'Performing slab consolidation on Windows partition...' - Optimize-Volume -DriveLetter W -SlabConsolidate -NormalPriority -Verbose - WriteLog 'Dismounting VHDX' - Dismount-ScratchVhdx -VhdxPath $VhdxPath - WriteLog 'Mounting VHDX as read-only for optimization' - Mount-VHD -Path $VhdxPath -NoDriveLetter -ReadOnly - WriteLog 'Optimizing VHDX in full mode...' - Optimize-VHD -Path $VhdxPath -Mode Full - WriteLog 'Dismounting VHDX' - Dismount-ScratchVhdx -VhdxPath $VhdxPath - } catch { - throw $_ - } -} - -function New-FFU { - param ( - [Parameter(Mandatory = $false)] - [string]$VMName - ) - #If $InstallApps = $true, configure the VM - If ($InstallApps) { - WriteLog 'Creating FFU from VM' - WriteLog "Setting $CaptureISO as first boot device" - $VMDVDDrive = Get-VMDvdDrive -VMName $VMName - Set-VMFirmware -VMName $VMName -FirstBootDevice $VMDVDDrive - Set-VMDvdDrive -VMName $VMName -Path $CaptureISO - $VMSwitch = Get-VMSwitch -name $VMSwitchName - WriteLog "Setting $($VMSwitch.Name) as VMSwitch" - get-vm $VMName | Get-VMNetworkAdapter | Connect-VMNetworkAdapter -SwitchName $VMSwitch.Name - WriteLog "Configuring VM complete" - - #Start VM - WriteLog "Starting VM" - Start-VM -Name $VMName - - # Wait for the VM to turn off - do { - $FFUVM = Get-VM -Name $VMName - Start-Sleep -Seconds 5 - } while ($FFUVM.State -ne 'Off') - WriteLog "VM Shutdown" - # Check for .ffu files in the FFUDevelopment folder - WriteLog "Checking for FFU Files" - $FFUFiles = Get-ChildItem -Path $FFUCaptureLocation -Filter "*.ffu" -File - - # If there's more than one .ffu file, get the most recent and store its path in $FFUFile - if ($FFUFiles.Count -gt 0) { - WriteLog 'Getting the most recent FFU file' - $FFUFile = ($FFUFiles | Sort-Object -Property LastWriteTime -Descending | Select-Object -First 1).FullName - WriteLog "Most recent .ffu file: $FFUFile" - } - else { - WriteLog "No .ffu files found in $FFUFolderPath" - throw $_ - } - } - elseif (-not $InstallApps) { - #Get Windows Version Information from the VHDX - $winverinfo = Get-WindowsVersionInfo - $FFUFileName = "$($winverinfo.Name)`_$($winverinfo.DisplayVersion)`_$($winverinfo.SKU)`_$($winverinfo.BuildDate).ffu" - WriteLog "FFU file name: $FFUFileName" - $FFUFile = "$FFUCaptureLocation\$FFUFileName" - #Capture the FFU - Invoke-Process cmd "/c ""$DandIEnv"" && dism /Capture-FFU /ImageFile:$FFUFile /CaptureDrive:\\.\PhysicalDrive$($vhdxDisk.DiskNumber) /Name:$($winverinfo.Name)$($winverinfo.DisplayVersion)$($winverinfo.SKU) /Compress:Default" - # Invoke-Process cmd "/c dism /Capture-FFU /ImageFile:$FFUFile /CaptureDrive:\\.\PhysicalDrive$($vhdxDisk.DiskNumber) /Name:$($winverinfo.Name)$($winverinfo.DisplayVersion)$($winverinfo.SKU) /Compress:Default" - WriteLog 'FFU Capture complete' - Dismount-ScratchVhdx -VhdxPath $VHDXPath - } - - #Without this 120 second sleep, we sometimes see an error when mounting the FFU due to a file handle lock. Needed for both driver and optimize steps. - WriteLog 'Sleeping 2 minutes to prevent file handle lock' - Start-Sleep 120 - - #Add drivers - If ($InstallDrivers) { - WriteLog 'Adding drivers' - WriteLog "Creating $FFUDevelopmentPath\Mount directory" - New-Item -Path "$FFUDevelopmentPath\Mount" -ItemType Directory -Force | Out-Null - WriteLog "Created $FFUDevelopmentPath\Mount directory" - WriteLog "Mounting $FFUFile to $FFUDevelopmentPath\Mount" - Mount-WindowsImage -ImagePath $FFUFile -Index 1 -Path "$FFUDevelopmentPath\Mount" | Out-null - WriteLog 'Mounting complete' - WriteLog 'Adding drivers - This will take a few minutes, please be patient' - try { - Add-WindowsDriver -Path "$FFUDevelopmentPath\Mount" -Driver "$DriversFolder" -Recurse -ErrorAction SilentlyContinue | Out-null - } - catch { - WriteLog 'Some drivers failed to be added to the FFU. This can be expected. Continuing.' - } - WriteLog 'Adding drivers complete' - WriteLog "Dismount $FFUDevelopmentPath\Mount" - Dismount-WindowsImage -Path "$FFUDevelopmentPath\Mount" -Save | Out-Null - WriteLog 'Dismount complete' - WriteLog "Remove $FFUDevelopmentPath\Mount folder" - Remove-Item -Path "$FFUDevelopmentPath\Mount" -Recurse -Force | Out-null - WriteLog 'Folder removed' - } - #Optimize FFU - if ($Optimize -eq $true) { - WriteLog 'Optimizing FFU - This will take a few minutes, please be patient' - #Need to use ADK version of DISM to address bug in DISM - perhaps Windows 11 24H2 will fix this - Invoke-Process cmd "/c ""$DandIEnv"" && dism /optimize-ffu /imagefile:$FFUFile" - #Invoke-Process cmd "/c dism /optimize-ffu /imagefile:$FFUFile" - WriteLog 'Optimizing FFU complete' - } - - -} -function Remove-FFUVM { - param ( - [Parameter(Mandatory = $false)] - [string]$VMName - ) - #Get the VM object and remove the VM, the HGSGuardian, and the certs - If ($VMName) { - $FFUVM = get-vm $VMName | Where-Object { $_.state -ne 'running' } - } - If ($null -ne $FFUVM) { - WriteLog 'Cleaning up VM' - $certPath = 'Cert:\LocalMachine\Shielded VM Local Certificates\' - $VMName = $FFUVM.Name - WriteLog "Removing VM: $VMName" - Remove-VM -Name $VMName -Force - WriteLog 'Removal complete' - WriteLog "Removing $VMPath" - Remove-Item -Path $VMPath -Force -Recurse - WriteLog 'Removal complete' - WriteLog "Removing HGSGuardian for $VMName" - Remove-HgsGuardian -Name $VMName -WarningAction SilentlyContinue - WriteLog 'Removal complete' - WriteLog 'Cleaning up HGS Guardian certs' - $certs = Get-ChildItem -Path $certPath -Recurse | Where-Object { $_.Subject -like "*$VMName*" } - foreach ($cert in $Certs) { - Remove-item -Path $cert.PSPath -force | Out-Null - } - WriteLog 'Cert removal complete' - } - #If just building the FFU from vhdx, remove the vhdx path - If (-not $InstallApps -and $vhdxDisk) { - WriteLog 'Cleaning up VHDX' - WriteLog "Removing $VMPath" - Remove-Item -Path $VMPath -Force -Recurse | Out-Null - WriteLog 'Removal complete' - } - - #Remove orphaned mounted images - $mountedImages = Get-WindowsImage -Mounted - if ($mountedImages) { - foreach ($image in $mountedImages) { - $mountPath = $image.Path - WriteLog "Dismounting image at $mountPath" - Dismount-WindowsImage -Path $mountPath -discard - WriteLog "Successfully dismounted image at $mountPath" - } - } - #Remove Mount folder if it exists - If (Test-Path -Path $FFUDevelopmentPath\Mount) { - WriteLog "Remove $FFUDevelopmentPath\Mount folder" - Remove-Item -Path "$FFUDevelopmentPath\Mount" -Recurse -Force - WriteLog 'Folder removed' - } - #Remove unused mountpoints - WriteLog 'Remove unused mountpoints' - Invoke-Process cmd "/c mountvol /r" - WriteLog 'Removal complete' -} -Function Remove-FFUUserShare { - WriteLog "Removing $ShareName" - Remove-SmbShare -Name $ShareName -Force | Out-null - WriteLog 'Removal complete' - WriteLog "Removing $Username" - Remove-LocalUser -Name $Username | Out-Null - WriteLog 'Removal complete' -} - -Function Get-WindowsVersionInfo { - #This sleep prevents CBS/CSI corruption which causes issues with Windows update after deployment. Capturing from very fast disks (NVME) can cause the capture to happen faster than Windows is ready for. This seems to affect VHDX-only captures, not VM captures. - WriteLog 'Sleep 60 seconds before opening registry to grab Windows version info ' - Start-sleep 60 - WriteLog "Getting Windows Version info" - #Load Registry Hive - $Software = "$osPartitionDriveLetter`:\Windows\System32\config\software" - WriteLog "Loading Software registry hive" - Invoke-Process reg "load HKLM\FFU $Software" - - #Find Windows version values - $SKU = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'EditionID' - WriteLog "Windows SKU: $SKU" - [int]$CurrentBuild = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'CurrentBuild' - WriteLog "Windows Build: $CurrentBuild" - $DisplayVersion = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'DisplayVersion' - WriteLog "Windows Version: $DisplayVersion" - $BuildDate = Get-Date -uformat %b%Y - - $SKU = switch ($SKU) { - Core { 'Home' } - Professional { 'Pro' } - ProfessionalEducation { 'Pro_Edu' } - Enterprise { 'Ent' } - Education { 'Edu' } - ProfessionalWorkstation { 'Pro_Wks' } - } - WriteLog "Windows SKU Modified to: $SKU" - - if ($CurrentBuild -ge 22000) { - $Name = 'Win11' - } - else { - $Name = 'Win10' - } - - WriteLog "Unloading registry" - Invoke-Process reg "unload HKLM\FFU" - #This prevents Critical Process Died errors you can have during deployment of the FFU. Capturing from very fast disks (NVME) can cause the capture to happen faster than Windows is ready for. - WriteLog 'Sleep 60 seconds to allow registry to completely unload' - Start-sleep 60 - - return @{ - - DisplayVersion = $DisplayVersion - BuildDate = $buildDate - Name = $Name - SKU = $SKU - } -} -Function Get-USBDrive { - # Log the start of the USB drive check - WriteLog 'Checking for USB drives' - - # Check if external hard disk media is allowed - If ($AllowExternalHardDiskMedia) { - # 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]$ExternalHardDiskDrives = $USBDrives | Where-Object { $_.MediaType -eq 'External hard disk media' } - $ExternalCount = $ExternalHardDiskDrives.Count - $USBDrivesCount = $USBDrives.Count - - # Check if user should be prompted for external hard disk media - if ($PromptExternalHardDiskMedia) { - if ($ExternalHardDiskDrives) { - # Log and warn about found external hard disk media drives - if ($VerbosePreference -ne 'Continue') { - Write-Warning 'Found external hard disk media drives' - Write-Warning 'Will prompt for user input to select the drive to use to prevent accidental data loss' - Write-Warning 'If you do not want to be prompted for this in the future, set -PromptExternalHardDiskMedia to $false' - } - WriteLog 'Found external hard disk media drives' - WriteLog 'Will prompt for user input to select the drive to use to prevent accidental data loss' - WriteLog 'If you do not want to be prompted for this in the future, set -PromptExternalHardDiskMedia to $false' - - # Prepare output for user selection - $Output = @() - for ($i = 0; $i -lt $ExternalHardDiskDrives.Count; $i++) { - $ExternalDiskNumber = $ExternalHardDiskDrives[$i].Index - $ExternalDisk = Get-Disk -Number $ExternalDiskNumber - $Index = $i + 1 - $Name = $ExternalDisk.FriendlyName - $SerialNumber = $ExternalHardDiskDrives[$i].serialnumber - $PartitionStyle = $ExternalDisk.PartitionStyle - $Status = $ExternalDisk.OperationalStatus - $Properties = [ordered]@{ - 'Drive Number' = $Index - 'Drive Name' = $Name - 'Serial Number' = $SerialNumber - 'Partition Style' = $PartitionStyle - 'Status' = $Status - } - $Output += New-Object PSObject -Property $Properties - } - - # Format and display the output - $FormattedOutput = $Output | Format-Table -AutoSize -Property 'Drive Number', 'Drive Name', 'Serial Number', 'Partition Style', 'Status' | Out-String - if ($VerbosePreference -ne 'Continue') { - $FormattedOutput | Out-Host - } - WriteLog $FormattedOutput - - # Prompt user to select a drive - do { - $inputChoice = Read-Host "Enter the number corresponding to the external hard disk media drive you want to use" - if ($inputChoice -match '^\d+$') { - $inputChoice = [int]$inputChoice - if ($inputChoice -ge 1 -and $inputChoice -le $ExternalCount) { - $SelectedIndex = $inputChoice - 1 - $ExternalDiskNumber = $ExternalHardDiskDrives[$SelectedIndex].Index - $ExternalDisk = Get-Disk -Number $ExternalDiskNumber - $USBDrives = $ExternalHardDiskDrives[$SelectedIndex] - $USBDrivesCount = $USBDrives.Count - if ($VerbosePreference -ne 'Continue') { - Write-Host "Drive $inputChoice was selected" - } - WriteLog "Drive $inputChoice was selected" - } - else { - # Handle invalid selection - if ($VerbosePreference -ne 'Continue') { - Write-Host "Invalid selection. Please try again." - } - WriteLog "Invalid selection. Please try again." - } - - # Check if the selected drive is offline - if ($ExternalDisk.OperationalStatus -eq 'Offline') { - if ($VerbosePreference -ne 'Continue') { - Write-Error "Selected Drive is in an Offline State. Please check the drive status in Disk Manager and try again." - } - WriteLog "Selected Drive is in an Offline State. Please check the drive status in Disk Manager and try again." - exit 1 - } - } - else { - # Handle invalid input - if ($VerbosePreference -ne 'Continue') { - Write-Host "Invalid selection. Please try again." - } - WriteLog "Invalid selection. Please try again." - } - } while ($null -eq $selectedIndex) - } - } - else { - # Log the count of found USB drives - if ($VerbosePreference -ne 'Continue') { - Write-Host "Found $USBDrivesCount total USB drives" - If ($ExternalCount -gt 0) { - Write-Host "$ExternalCount are external drives" - } - } - WriteLog "Found $USBDrivesCount total USB drives" - If ($ExternalCount -gt 0) { - WriteLog "$ExternalCount are external drives" - } - } - } - else { - # Get only removable media drives - [array]$USBDrives = (Get-WmiObject -Class Win32_DiskDrive -Filter "MediaType='Removable Media'") - $USBDrivesCount = $USBDrives.Count - WriteLog "Found $USBDrivesCount Removable USB drives" - } - - # Check if any USB drives were found - if ($null -eq $USBDrives) { - WriteLog "No removable USB drive found. Exiting" - Write-Error "No removable USB drive found. Exiting" - exit 1 - } - - # Return the found USB drives and their count - return $USBDrives, $USBDrivesCount -} -Function New-DeploymentUSB { - param( - [switch]$CopyFFU - ) - WriteLog "CopyFFU is set to $CopyFFU" - $BuildUSBPath = $PSScriptRoot - WriteLog "BuildUSBPath is $BuildUSBPath" - - $SelectedFFUFile = $null - - # Check if the CopyFFU switch is present - if ($CopyFFU.IsPresent) { - # Get all FFU files in the specified directory - $FFUFiles = Get-ChildItem -Path "$BuildUSBPath\FFU" -Filter "*.ffu" - $FFUCount = $FFUFiles.count - - # If there is exactly one FFU file, select it - if ($FFUCount -eq 1) { - $SelectedFFUFile = $FFUFiles.FullName - } - # If there are multiple FFU files, prompt the user to select one - elseif ($FFUCount -gt 1) { - WriteLog "Found $FFUCount FFU files" - if($VerbosePreference -ne 'Continue'){ - Write-Host "Found $FFUCount FFU files" - } - $output = @() - # Create a table of FFU files with their index, name, and last modified date - for ($i = 0; $i -lt $FFUCount; $i++) { - $index = $i + 1 - $name = $FFUFiles[$i].Name - $modified = $FFUFiles[$i].LastWriteTime - $Properties = [ordered]@{ - 'FFU Number' = $index - 'FFU Name' = $name - 'Last Modified' = $modified - } - $output += New-Object PSObject -Property $Properties - } - $output | Format-Table -AutoSize -Property 'FFU Number', 'FFU Name', 'Last Modified' - - # Loop until a valid FFU file is selected - do { - $inputChoice = Read-Host "Enter the number corresponding to the FFU file you want to copy or 'A' to copy all FFU files" - # Check if the input is a valid number or 'A' - if ($inputChoice -match '^\d+$' -or $inputChoice -eq 'A') { - if ($inputChoice -eq 'A') { - # Select all FFU files - $SelectedFFUFile = $FFUFiles.FullName - if ($VerbosePreference -ne 'Continue') { - Write-Host 'Will copy all FFU files' - } - WriteLog 'Will copy all FFU Files' - } - else { - # Convert input to integer and validate the selection - $inputChoice = [int]$inputChoice - if ($inputChoice -ge 1 -and $inputChoice -le $FFUCount) { - $selectedIndex = $inputChoice - 1 - $SelectedFFUFile = $FFUFiles[$selectedIndex].FullName - if ($VerbosePreference -ne 'Continue') { - Write-Host "$SelectedFFUFile was selected" - } - WriteLog "$SelectedFFUFile was selected" - } - else { - # Handle invalid selection - if ($VerbosePreference -ne 'Continue') { - Write-Host "Invalid selection. Please try again." - } - WriteLog "Invalid selection. Please try again." - } - } - } - else { - # Handle invalid input - if ($VerbosePreference -ne 'Continue') { - Write-Host "Invalid selection. Please try again." - } - WriteLog "Invalid selection. Please try again." - } - } while ($null -eq $SelectedFFUFile) - - } - else { - # Handle case where no FFU files are found - WriteLog "No FFU files found in the current directory." - Write-Error "No FFU files found in the current directory." - Return - } - } - $counter = 0 - - foreach ($USBDrive in $USBDrives) { - $Counter++ - WriteLog "Formatting USB drive $Counter out of $USBDrivesCount" - $DiskNumber = $USBDrive.DeviceID.Replace("\\.\PHYSICALDRIVE", "") - WriteLog "Physical Disk number is $DiskNumber for USB drive $Counter out of $USBDrivesCount" - - $ScriptBlock = { - param($DiskNumber) - $Disk = Get-Disk -Number $DiskNumber - # Clear-Disk -Number $DiskNumber -RemoveData -RemoveOEM -Confirm:$false - # Clear-disk has an unusual behavior where it sets external hard disk media as RAW, however removable media is set as MBR. - if ($Disk.PartitionStyle -ne "RAW") { - $Disk | Clear-Disk -RemoveData -RemoveOEM -Confirm:$false - $Disk = Get-Disk -Number $DiskNumber - } - - if($Disk.PartitionStyle -eq "RAW") { - $Disk | Initialize-Disk -PartitionStyle MBR -Confirm:$false - } - elseif($Disk.PartitionStyle -ne "RAW"){ - $Disk | Get-Partition | Remove-Partition -Confirm:$false - $Disk | Set-Disk -PartitionStyle MBR - } - # Get-Disk $DiskNumber | Get-Partition | Remove-Partition - $BootPartition = $Disk | New-Partition -Size 2GB -IsActive -AssignDriveLetter - $DeployPartition = $Disk | New-Partition -UseMaximumSize -AssignDriveLetter - Format-Volume -Partition $BootPartition -FileSystem FAT32 -NewFileSystemLabel "TempBoot" -Confirm:$false - Format-Volume -Partition $DeployPartition -FileSystem NTFS -NewFileSystemLabel "TempDeploy" -Confirm:$false - } - - WriteLog 'Partitioning USB Drive' - Invoke-Command -ScriptBlock $ScriptBlock -ArgumentList $DiskNumber | Out-null - WriteLog 'Done' - - # $BootPartitionDriveLetter = (Get-WmiObject -Class win32_volume -Filter "Label='TempBoot' AND DriveType=2 AND DriveLetter IS NOT NULL").Name - $BootPartitionDriveLetter = (Get-WmiObject -Class win32_volume -Filter "Label='TempBoot' AND DriveLetter IS NOT NULL").Name - $ISOMountPoint = (Mount-DiskImage -ImagePath $DeployISO -PassThru | Get-Volume).DriveLetter + ":\" - WriteLog "Copying WinPE files to $BootPartitionDriveLetter" - robocopy "$ISOMountPoint" "$BootPartitionDriveLetter" /E /COPYALL /R:5 /W:5 /J - Dismount-DiskImage -ImagePath $DeployISO | Out-Null - - if ($CopyFFU.IsPresent) { - if ($null -ne $SelectedFFUFile) { - # $DeployPartitionDriveLetter = (Get-WmiObject -Class win32_volume -Filter "Label='TempDeploy' AND DriveType=2 AND DriveLetter IS NOT NULL").Name - $DeployPartitionDriveLetter = (Get-WmiObject -Class win32_volume -Filter "Label='TempDeploy' AND DriveLetter IS NOT NULL").Name - if ($SelectedFFUFile -is [array]) { - WriteLog "Copying multiple FFU files to $DeployPartitionDriveLetter. This could take a few minutes." - foreach ($FFUFile in $SelectedFFUFile) { - robocopy $(Split-Path $FFUFile -Parent) $DeployPartitionDriveLetter $(Split-Path $FFUFile -Leaf) /COPYALL /R:5 /W:5 /J - } - } - else { - WriteLog ("Copying " + $SelectedFFUFile + " to $DeployPartitionDriveLetter. This could take a few minutes.") - robocopy $(Split-Path $SelectedFFUFile -Parent) $DeployPartitionDriveLetter $(Split-Path $SelectedFFUFile -Leaf) /COPYALL /R:5 /W:5 /J - } - #Copy drivers using robocopy due to potential size - if ($CopyDrivers) { - WriteLog "Copying drivers to $DeployPartitionDriveLetter\Drivers" - if ($Make){ - robocopy "$DriversFolder\$Make" "$DeployPartitionDriveLetter\Drivers" /E /R:5 /W:5 /J - }else{ - robocopy "$DriversFolder" "$DeployPartitionDriveLetter\Drivers" /E /R:5 /W:5 /J - } - - } - #Copy Unattend file to the USB drive. - if ($CopyUnattend) { - # WriteLog "Copying Unattend folder to $DeployPartitionDriveLetter" - # Copy-Item -Path "$FFUDevelopmentPath\Unattend" -Destination $DeployPartitionDriveLetter -Recurse -Force - $DeployUnattendPath = "$DeployPartitionDriveLetter\unattend" - WriteLog "Copying unattend file to $DeployUnattendPath" - New-Item -Path $DeployUnattendPath -ItemType Directory | Out-Null - if ($WindowsArch -eq 'x64') { - Copy-Item -Path "$FFUDevelopmentPath\unattend\unattend_x64.xml" -Destination "$DeployUnattendPath\Unattend.xml" -Force | Out-Null - } - else { - Copy-Item -Path "$FFUDevelopmentPath\unattend\unattend_arm64.xml" -Destination "$DeployUnattendPath\Unattend.xml" -Force | Out-Null - } - WriteLog 'Copy completed' - } - #Copy PPKG folder in the FFU folder to the USB drive. Can use copy-item as it's a small folder - if ($CopyPPKG) { - WriteLog "Copying PPKG folder to $DeployPartitionDriveLetter" - Copy-Item -Path "$FFUDevelopmentPath\PPKG" -Destination $DeployPartitionDriveLetter -Recurse -Force - } - #Copy Autopilot folder in the FFU folder to the USB drive. Can use copy-item as it's a small folder - if ($CopyAutopilot) { - WriteLog "Copying Autopilot folder to $DeployPartitionDriveLetter" - Copy-Item -Path "$FFUDevelopmentPath\Autopilot" -Destination $DeployPartitionDriveLetter -Recurse -Force - } - } - else { - WriteLog "No FFU file selected. Skipping copy." - } - } - - Set-Volume -FileSystemLabel "TempBoot" -NewFileSystemLabel "Boot" - Set-Volume -FileSystemLabel "TempDeploy" -NewFileSystemLabel "Deploy" - - if ($USBDrivesCount -gt 1) { - & mountvol $BootPartitionDriveLetter /D - & mountvol $DeployPartitionDriveLetter /D - } - - WriteLog "Drive $counter completed" - } - - WriteLog "USB Drives completed" -} - - -function Get-FFUEnvironment { - WriteLog 'Dirty.txt file detected. Last run did not complete succesfully. Will clean environment' - # Check for running VMs that start with '_FFU-' and are in the 'Off' state - $vms = Get-VM - - # Loop through each VM - foreach ($vm in $vms) { - if ($vm.Name.StartsWith("_FFU-")) { - if ($vm.State -eq 'Running') { - Stop-VM -Name $vm.Name -TurnOff -Force - } - # If conditions are met, delete the VM - Remove-FFUVM -VMName $vm.Name - } - } - # Check for MSFT Virtual disks where location contains FFUDevelopment in the path - $disks = Get-Disk -FriendlyName *virtual* - foreach ($disk in $disks) { - $diskNumber = $disk.Number - $vhdLocation = $disk.Location - if ($vhdLocation -like "*FFUDevelopment*") { - WriteLog "Dismounting Virtual Disk $diskNumber with Location $vhdLocation" - Dismount-ScratchVhdx -VhdxPath $vhdLocation - $parentFolder = Split-Path -Parent $vhdLocation - WriteLog "Removing folder $parentFolder" - Remove-Item -Path $parentFolder -Recurse -Force - } - } - - # Check for mounted DiskImages - $volumes = Get-Volume | Where-Object { $_.DriveType -eq 'CD-ROM' } - foreach ($volume in $volumes) { - $letter = $volume.DriveLetter - WriteLog "Dismounting DiskImage for volume $letter" - Get-Volume $letter | Get-DiskImage | Dismount-DiskImage | Out-Null - WriteLog "Dismounting complete" - } - - # Remove unused mountpoints - WriteLog 'Remove unused mountpoints' - Invoke-Process cmd "/c mountvol /r" - WriteLog 'Removal complete' - - # Check for content in the VM folder and delete any folders that start with _FFU- - $folders = Get-ChildItem -Path $VMLocation -Directory - foreach ($folder in $folders) { - if ($folder.Name -like '_FFU-*') { - WriteLog "Removing folder $($folder.FullName)" - Remove-Item -Path $folder.FullName -Recurse -Force - } - } - - # Remove orphaned mounted images - $mountedImages = Get-WindowsImage -Mounted - if ($mountedImages) { - foreach ($image in $mountedImages) { - $mountPath = $image.Path - WriteLog "Dismounting image at $mountPath" - Dismount-WindowsImage -Path $mountPath -discard | Out-null - WriteLog "Successfully dismounted image at $mountPath" - } - } - - # Remove Mount folder if it exists - if (Test-Path -Path "$FFUDevelopmentPath\Mount") { - WriteLog "Remove $FFUDevelopmentPath\Mount folder" - Remove-Item -Path "$FFUDevelopmentPath\Mount" -Recurse -Force - WriteLog 'Folder removed' - } - - #Clear any corrupt Windows mount points - WriteLog 'Clearing any corrupt Windows mount points' - Clear-WindowsCorruptMountPoint | Out-null - WriteLog 'Complete' - - #Clean up registry - if (Test-Path -Path 'HKLM:\FFU') { - Writelog 'Found HKLM:\FFU, removing it' - Invoke-Process reg "unload HKLM\FFU" - } - - #Remove FFU User and Share - $UserExists = Get-LocalUser -Name $UserName -ErrorAction SilentlyContinue - if ($UserExists) { - WriteLog "Removing FFU User and Share" - Remove-FFUUserShare - WriteLog 'Removal complete' - } - #Clean up $KBPath - If (Test-Path -Path $KBPath) { - WriteLog "Removing $KBPath" - Remove-Item -Path $KBPath -Recurse -Force -ErrorAction SilentlyContinue - WriteLog 'Removal complete' - } - #Clean up $DefenderPath - If (Test-Path -Path $DefenderPath) { - WriteLog "Removing $DefenderPath" - Remove-Item -Path $DefenderPath -Recurse -Force -ErrorAction SilentlyContinue - WriteLog 'Removal complete' - } - #Clean up $OneDrivePath - If (Test-Path -Path $OneDrivePath) { - WriteLog "Removing $OneDrivePath" - Remove-Item -Path $OneDrivePath -Recurse -Force -ErrorAction SilentlyContinue - WriteLog 'Removal complete' - } - #Clean up $EdgePath - If (Test-Path -Path $EdgePath) { - WriteLog "Removing $EdgePath" - Remove-Item -Path $EdgePath -Recurse -Force -ErrorAction SilentlyContinue - WriteLog 'Removal complete' - } - if (Test-Path -Path "$AppsPath\Win32" -PathType Container) { - WriteLog "Cleaning up Win32 folder" - Remove-Item -Path "$AppsPath\Win32" -Recurse -Force -ErrorAction SilentlyContinue - } - if (Test-Path -Path "$AppsPath\MSStore" -PathType Container) { - WriteLog "Cleaning up MSStore folder" - Remove-Item -Path "$AppsPath\MSStore" -Recurse -Force -ErrorAction SilentlyContinue - } - Clear-InstallAppsandSysprep - Writelog 'Removing dirty.txt file' - Remove-Item -Path "$FFUDevelopmentPath\dirty.txt" -Force - WriteLog "Cleanup complete" -} -function Remove-FFU { - #Remove all FFU files in the FFUCaptureLocation - WriteLog "Removing all FFU files in $FFUCaptureLocation" - Remove-Item -Path $FFUCaptureLocation\*.ffu -Force - WriteLog "Removal complete" -} -function Clear-InstallAppsandSysprep { - $cmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to remove win32 app install commands" - $cmdContent -notmatch "REM Win32*" | Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - $cmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - $cmdContent -notmatch "D:\\win32*" | Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - $cmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - WriteLog "Setting MSStore installation condition to false" - $cmdContent -replace 'set "INSTALL_STOREAPPS=true"', 'set "INSTALL_STOREAPPS=false"' | Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - if ($UpdateLatestDefender) { - WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to remove Defender Platform Update" - $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - $CmdContent -notmatch 'd:\\Defender*' | Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - # #Remove $DefenderPath - # WriteLog "Removing $DefenderPath" - # Remove-Item -Path $DefenderPath -Recurse -Force - # WriteLog "Removal complete" - - } - if ($UpdateOneDrive) { - WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to remove OneDrive install" - $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - $CmdContent -notmatch 'd:\\OneDrive*' | Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - # #Remove $OneDrivePath - # WriteLog "Removing $OneDrivePath" - # Remove-Item -Path $OneDrivePath -Recurse -Force - # WriteLog "Removal complete" - } - if ($UpdateEdge) { - WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to remove Edge install" - $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - $CmdContent -notmatch 'd:\\Edge*' | Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - # #Remove $EdgePath - # WriteLog "Removing $EdgePath" - # Remove-Item -Path $EdgePath -Recurse -Force - # WriteLog "Removal complete" - } -} - -###END FUNCTIONS - -#Remove old log file if found -if (Test-Path -Path $Logfile) { - Remove-item -Path $LogFile -Force -} -$startTime = Get-Date -Write-Host "FFU build process started at" $startTime -Write-Host "This process can take 20 minutes or more. Please do not close this window or any additional windows that pop up" -Write-Host "To track progress, please open the log file $Logfile or use the -Verbose parameter next time" - -WriteLog 'Begin Logging' - -#Override $InstallApps value if using ESD to build FFU. This is due to a strange issue where building the FFU -#from vhdx doesn't work (you get an older style OOBE screen and get stuck in an OOBE reboot loop when hitting next). -#This behavior doesn't happen with WIM files. -If (-not ($ISOPath) -and (-not ($InstallApps))) { - $InstallApps = $true - WriteLog "Script will download Windows media. Setting `$InstallApps to `$true to build VM to capture FFU. Must do this when using MCT ESD." -} - -if (($InstallOffice -eq $true) -and ($InstallApps -eq $false)) { - throw "If variable InstallOffice is set to `$true, InstallApps must also be set to `$true." -} -if (($InstallApps -and ($VMSwitchName -eq ''))) { - throw "If variable InstallApps is set to `$true, VMSwitchName must also be set to capture the FFU. Please set -VMSwitchName and try again." -} - -if (($InstallApps -and ($VMHostIPAddress -eq ''))) { - throw "If variable InstallApps is set to `$true, VMHostIPAddress must also be set to capture the FFU. Please set -VMHostIPAddress and try again." -} - -if (-not ($ISOPath) -and ($OptionalFeatures -like '*netfx3*')) { - throw "netfx3 specified as an optional feature, however Windows ISO isn't defined. Unable to get netfx3 source files from downloaded ESD media. Please specify a Windows ISO in the ISOPath parameter." -} -if (($LogicalSectorSizeBytes -eq 4096) -and ($installdrivers -eq $true)) { - $installdrivers = $false - $CopyDrivers = $true - WriteLog 'LogicalSectorSizeBytes is set to 4096, which is not supported for driver injection. Setting $installdrivers to $false' - WriteLog 'As a workaround, setting -copydrivers $true to copy drivers to the deploy partition drivers folder' - WriteLog 'We are investigating this issue and will update the script if/when we have a fix' -} -if ($BuildUSBDrive -eq $true) { - $USBDrives, $USBDrivesCount = Get-USBDrive -} -if (($InstallApps -eq $false) -and (($UpdateLatestDefender -eq $true) -or ($UpdateOneDrive -eq $true) -or ($UpdateEdge -eq $true))) { - WriteLog 'You have selected to update Defender, OneDrive, or Edge, however you are setting InstallApps to false. These updates require the InstallApps variable to be set to true. Please set InstallApps to true and try again.' - throw "InstallApps variable must be set to `$true to update Defender, OneDrive, or Edge" -} -if (($WindowsArch -eq 'ARM64') -and ($InstallOffice -eq $true)) { - $InstallOffice = $false - WriteLog 'M365 Apps/Office currently fails to install on ARM64 VMs without an internet connection. Setting InstallOffice to false' -} - -if (($WindowsArch -eq 'ARM64') -and ($UpdateOneDrive -eq $true)) { - $UpdateOneDrive = $false - WriteLog 'OneDrive currently fails to install on ARM64 VMs (even with the OneDrive ARM setup files). Setting UpdateOneDrive to false' -} -# if(($WindowsArch -eq 'ARM64') -and ($UpdateLatestDefender -eq $true)){ -# $UpdateLatestDefender = $false -# WriteLog 'Defender ARM and x64 updates currently fail to install on ARM64 VMs. Setting UpdateLatestDefender to false' -# } - -#Get script variable values -LogVariableValues - -#Check if environment is dirty -If (Test-Path -Path "$FFUDevelopmentPath\dirty.txt") { - Get-FFUEnvironment -} -WriteLog 'Creating dirty.txt file' -New-Item -Path .\ -Name "dirty.txt" -ItemType "file" | Out-Null - -#Get drivers first since user could be prompted for additional info -if (($make -and $model) -and ($installdrivers -or $copydrivers)) { - try { - if ($Make -eq 'HP'){ - WriteLog 'Getting HP drivers' - Get-HPDrivers -Make $Make -Model $Model -WindowsArch $WindowsArch -WindowsRelease $WindowsRelease -WindowsVersion $WindowsVersion - WriteLog 'Getting HP drivers completed successfully' - } - if ($make -eq 'Microsoft'){ - WriteLog 'Getting Microsoft drivers' - Get-MicrosoftDrivers -Make $Make -Model $Model -WindowsArch $WindowsArch -WindowsRelease $WindowsRelease - WriteLog 'Getting Microsoft drivers completed successfully' - } - if ($make -eq 'Lenovo'){ - WriteLog 'Getting Lenovo drivers' - Get-LenovoDrivers -Model $Model -WindowsArch $WindowsArch -WindowsRelease $WindowsRelease - WriteLog 'Getting Lenovo drivers completed successfully' - } - if ($make -eq 'Dell'){ - WriteLog 'Getting Dell drivers' - #Dell mixes Win10 and 11 drivers, hence no WindowsRelease parameter - Get-DellDrivers -Model $Model -WindowsArch $WindowsArch - WriteLog 'Getting Dell drivers completed successfully' - } - } - catch { - Writelog "Getting drivers failed with error $_" - throw $_ - } - -} - -#Get Windows ADK -try { - $adkPath = Get-ADK - #Need to use the Deployment and Imaging tools environment to use dism from the Sept 2023 ADK to optimize FFU - $DandIEnv = "$adkPath`Assessment and Deployment Kit\Deployment Tools\DandISetEnv.bat" -} -catch { - WriteLog 'ADK not found' - throw $_ -} - -#Create apps ISO for Office and/or 3rd party apps -if ($InstallApps) { - try { - #Make sure InstallAppsandSysprep.cmd file exists - WriteLog "InstallApps variable set to true, verifying $AppsPath\InstallAppsandSysprep.cmd exists" - if (-not (Test-Path -Path "$AppsPath\InstallAppsandSysprep.cmd")) { - Write-Host "$AppsPath\InstallAppsandSysprep.cmd is missing, exiting script" - WriteLog "$AppsPath\InstallAppsandSysprep.cmd is missing, exiting script" - exit - } - WriteLog "$AppsPath\InstallAppsandSysprep.cmd found" - If (Test-Path -Path "$AppsPath\AppList.json"){ - WriteLog "$AppsPath\AppList.json found, checking for winget apps to install" - Get-Apps -AppList "$AppsPath\AppList.json" - } - - if (-not $InstallOffice) { - #Modify InstallAppsandSysprep.cmd to REM out the office install command - $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - $UpdatedcmdContent = $CmdContent -replace '^(d:\\Office\\setup.exe /configure d:\\office\\DeployFFU.xml)', ("REM d:\Office\setup.exe /configure d:\office\DeployFFU.xml") - Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" -Value $UpdatedcmdContent - } - - if ($InstallOffice) { - WriteLog 'Downloading M365 Apps/Office' - Get-Office - WriteLog 'Downloading M365 Apps/Office completed successfully' - } - - #Update Latest Defender Platform and Definitions - these can't be serviced into the VHDX, will be saved to AppsPath - if ($UpdateLatestDefender) { - WriteLog "`$UpdateLatestDefender is set to true, checking for latest Defender Platform and Definitions" - $Name = "Update for Microsoft Defender Antivirus antimalware platform" - #Check if $DefenderPath exists, if not, create it - If (-not (Test-Path -Path $DefenderPath)) { - WriteLog "Creating $DefenderPath" - New-Item -Path $DefenderPath -ItemType Directory -Force | Out-Null - } - WriteLog "Searching for $Name from Microsoft Update Catalog and saving to $DefenderPath" - $KBFilePath = Save-KB -Name $Name -Path $DefenderPath - WriteLog "Latest Defender Platform and Definitions saved to $DefenderPath\$KBFilePath" - - #Modify InstallAppsandSysprep.cmd to add in $KBFilePath on the line after REM Install Defender Update Platform - WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to include Defender Platform Update" - $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - $UpdatedcmdContent = $CmdContent -replace '^(REM Install Defender Platform Update)', ("REM Install Defender Platform Update`r`nd:\Defender\$KBFilePath") - Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" -Value $UpdatedcmdContent - WriteLog "Update complete" - - #Get Windows Security platform update - $Name = "Windows Security platform definition updates" - WriteLog "Searching for $Name from Microsoft Update Catalog and saving to $DefenderPath" - $KBFilePath = Save-KB -Name $Name -Path $DefenderPath - WriteLog "Latest Security Platform Update saved to $DefenderPath\$KBFilePath" - #Modify InstallAppsandSysprep.cmd to add in $KBFilePath on the line after REM Install Windows Security Platform Update - WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to include Windows Security Platform Update" - $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - $UpdatedcmdContent = $CmdContent -replace '^(REM Install Windows Security Platform Update)', ("REM Install Windows Security Platform Update`r`nd:\Defender\$KBFilePath") - Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" -Value $UpdatedcmdContent - WriteLog "Update complete" - - #Download latest Defender Definitions - WriteLog "Downloading latest Defender Definitions" - # Defender def updates can be found https://www.microsoft.com/en-us/wdsi/defenderupdates - if ($WindowsArch -eq 'x64') { - $DefenderDefURL = 'https://go.microsoft.com/fwlink/?LinkID=121721&arch=x64' - } - if ($WindowsArch -eq 'ARM64') { - $DefenderDefURL = 'https://go.microsoft.com/fwlink/?LinkID=121721&arch=arm64' - } - try { - WriteLog "Defender definitions URL is $DefenderDefURL" - Start-BitsTransferWithRetry -Source $DefenderDefURL -Destination "$DefenderPath\mpam-fe.exe" - WriteLog "Defender Definitions downloaded to $DefenderPath\mpam-fe.exe" - } - catch { - Write-Host "Downloading Defender Definitions Failed" - WriteLog "Downloading Defender Definitions Failed with error $_" - throw $_ - } - - #Modify InstallAppsandSysprep.cmd to add in $DefenderPath on the line after REM Install Defender Definitions - WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to include Defender Definitions" - $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - $UpdatedcmdContent = $CmdContent -replace '^(REM Install Defender Definitions)', ("REM Install Defender Definitions`r`nd:\Defender\mpam-fe.exe") - Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" -Value $UpdatedcmdContent - WriteLog "Update complete" - } - #Download and Install OneDrive Per Machine - if ($UpdateOneDrive) { - WriteLog "`$UpdateOneDrive is set to true, checking for latest OneDrive client" - #Check if $OneDrivePath exists, if not, create it - If (-not (Test-Path -Path $OneDrivePath)) { - WriteLog "Creating $OneDrivePath" - New-Item -Path $OneDrivePath -ItemType Directory -Force | Out-Null - } - WriteLog "Downloading latest OneDrive client" - if($WindowsArch -eq 'x64') - { - $OneDriveURL = 'https://go.microsoft.com/fwlink/?linkid=844652' - } - elseif($WindowsArch -eq 'ARM64') - { - $OneDriveURL = 'https://go.microsoft.com/fwlink/?linkid=2271260' - } - try { - Start-BitsTransferWithRetry -Source $OneDriveURL -Destination "$OneDrivePath\OneDriveSetup.exe" - WriteLog "OneDrive client downloaded to $OneDrivePath\OneDriveSetup.exe" - } - catch { - Write-Host "Downloading OneDrive client Failed" - WriteLog "Downloading OneDrive client Failed with error $_" - throw $_ - } - - #Modify InstallAppsandSysprep.cmd to add in $OneDrivePath on the line after REM Install Defender Definitions - WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to include OneDrive client" - $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - $UpdatedcmdContent = $CmdContent -replace '^(REM Install OneDrive Per Machine)', ("REM Install OneDrive Per Machine`r`nd:\OneDrive\OneDriveSetup.exe /allusers") - Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" -Value $UpdatedcmdContent - WriteLog "Update complete" - } - - #Download and Install Edge Stable - if ($UpdateEdge) { - WriteLog "`$UpdateEdge is set to true, checking for latest Edge Stable $WindowsArch release" - $Name = "microsoft edge stable -extended $WindowsArch" - #Check if $EdgePath exists, if not, create it - If (-not (Test-Path -Path $EdgePath)) { - WriteLog "Creating $EdgePath" - New-Item -Path $EdgePath -ItemType Directory -Force | Out-Null - } - WriteLog "Searching for $Name from Microsoft Update Catalog and saving to $EdgePath" - $KBFilePath = Save-KB -Name $Name -Path $EdgePath - $EdgeCABFilePath = "$EdgePath\$KBFilePath" - WriteLog "Latest Edge Stable $WindowsArch release saved to $EdgeCABFilePath" - - #Extract Edge cab file to same folder as $EdgeFilePath - $EdgeMSIFileName = "MicrosoftEdgeEnterprise$WindowsArch.msi" - $EdgeFullFilePath = "$EdgePath\$EdgeMSIFileName" - WriteLog "Expanding $EdgeCABFilePath" - Invoke-Process Expand "$EdgeCABFilePath -F:*.msi $EdgeFullFilePath" - WriteLog "Expansion complete" - - #Remove Edge CAB file - WriteLog "Removing $EdgeCABFilePath" - Remove-Item -Path $EdgeCABFilePath -Force - WriteLog "Removal complete" - - #Modify InstallAppsandSysprep.cmd to add in $KBFilePath on the line after REM Install Edge Stable - WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to include Edge Stable $WindowsArch release" - $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - $UpdatedcmdContent = $CmdContent -replace '^(REM Install Edge Stable)', ("REM Install Edge Stable`r`nd:\Edge\$EdgeMSIFileName /quiet /norestart") - Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" -Value $UpdatedcmdContent - WriteLog "Update complete" - } - #Create Apps ISO - WriteLog "Creating $AppsISO file" - New-AppsISO - WriteLog "$AppsISO created successfully" - } - catch { - Write-Host "Creating Apps ISO Failed" - WriteLog "Creating Apps ISO Failed with error $_" - throw $_ - } -} - -#Create VHDX -try { - - if ($ISOPath) { - $wimPath = Get-WimFromISO - } - else { - $wimPath = Get-WindowsESD -WindowsRelease $WindowsRelease -WindowsArch $WindowsArch -WindowsLang $WindowsLang -MediaType $mediaType - } - #If index not specified by user, try and find based on WindowsSKU - if (-not($index) -and ($WindowsSKU)) { - $index = Get-Index -WindowsImagePath $wimPath -WindowsSKU $WindowsSKU - } - - $vhdxDisk = New-ScratchVhdx -VhdxPath $VHDXPath -SizeBytes $disksize -LogicalSectorSizeBytes $LogicalSectorSizeBytes - - $systemPartitionDriveLetter = New-SystemPartition -VhdxDisk $vhdxDisk - - New-MSRPartition -VhdxDisk $vhdxDisk - - $osPartition = New-OSPartition -VhdxDisk $vhdxDisk -OSPartitionSize $OSPartitionSize -WimPath $WimPath -WimIndex $index - $osPartitionDriveLetter = $osPartition[1].DriveLetter - $WindowsPartition = $osPartitionDriveLetter + ":\" - - #$recoveryPartition = New-RecoveryPartition -VhdxDisk $vhdxDisk -OsPartition $osPartition[1] -RecoveryPartitionSize $RecoveryPartitionSize -DataPartition $dataPartition - $recoveryPartition = New-RecoveryPartition -VhdxDisk $vhdxDisk -OsPartition $osPartition[1] -RecoveryPartitionSize $RecoveryPartitionSize -DataPartition $dataPartition - - WriteLog "All necessary partitions created." - - Add-BootFiles -OsPartitionDriveLetter $osPartitionDriveLetter -SystemPartitionDriveLetter $systemPartitionDriveLetter[1] - - #Update latest Cumulative Update - #Changed to use MU Catalog instead of using Get-LatestWindowsKB - #The Windows release info page is updated later than the MU Catalog - if ($UpdateLatestCU) { - Writelog "`$UpdateLatestCU is set to true, checking for latest CU" - $Name = """Cumulative update for Windows $WindowsRelease Version $WindowsVersion for $WindowsArch""" - #Check if $KBPath exists, if not, create it - If (-not (Test-Path -Path $KBPath)) { - WriteLog "Creating $KBPath" - New-Item -Path $KBPath -ItemType Directory -Force | Out-Null - } - WriteLog "Searching for $name from Microsoft Update Catalog and saving to $KBPath" - $KBFilePath = Save-KB -Name $Name -Path $KBPath - WriteLog "Latest CU saved to $KBPath\$KBFilePath" - } - - - #Update Latest .NET Framework - if ($UpdateLatestNet) { - Writelog "`$UpdateLatestNet is set to true, checking for latest .NET Framework" - $Name = "Cumulative update for .net framework windows $WindowsRelease $WindowsVersion $WindowsArch -preview" - #Check if $KBPath exists, if not, create it - If (-not (Test-Path -Path $KBPath)) { - WriteLog "Creating $KBPath" - New-Item -Path $KBPath -ItemType Directory -Force | Out-Null - } - WriteLog "Searching for $name from Microsoft Update Catalog and saving to $KBPath" - $KBFilePath = Save-KB -Name $Name -Path $KBPath - WriteLog "Latest .NET saved to $KBPath\$KBFilePath" - } - #Update Latest Security Platform Update - if ($UpdateSecurityPlatform) { - WriteLog "`$UpdateSecurityPlatform is set to true, checking for latest Security Platform Update" - $Name = "Windows Security platform definition updates" - #Check if $KBPath exists, if not, create it - If (-not (Test-Path -Path $KBPath)) { - WriteLog "Creating $KBPath" - New-Item -Path $KBPath -ItemType Directory -Force | Out-Null - } - WriteLog "Searching for $Name from Microsoft Update Catalog and saving to $KBPath" - $KBFilePath = Save-KB -Name $Name -Path $KBPath - WriteLog "Latest Security Platform Update saved to $KBPath\$KBFilePath" - } - - - #Add Windows packages - if ($UpdateLatestCU -or $UpdateLatestNet) { - try { - WriteLog "Adding KBs to $WindowsPartition" - WriteLog 'This can take 10+ minutes depending on how old the media is and the size of the KB. Please be patient' - Add-WindowsPackage -Path $WindowsPartition -PackagePath $KBPath -PreventPending | Out-Null - WriteLog "KBs added to $WindowsPartition" - WriteLog "Removing $KBPath" - Remove-Item -Path $KBPath -Recurse -Force | Out-Null - WriteLog "Clean Up the WinSxS Folder" - Dism /Image:$WindowsPartition /Cleanup-Image /StartComponentCleanup /ResetBase | Out-Null - WriteLog "Clean Up the WinSxS Folder completed" - } - catch { - Write-Host "Adding KB to VHDX failed with error $_" - WriteLog "Adding KB to VHDX failed with error $_" - throw $_ - } - } - - - #Enable Windows Optional Features (e.g. .Net3, etc) - If ($OptionalFeatures) { - $Source = Join-Path (Split-Path $wimpath) "sxs" - Enable-WindowsFeaturesByName -FeatureNames $OptionalFeatures -Source $Source - } - - #Set Product key - If ($ProductKey) { - WriteLog "Setting Windows Product Key" - Set-WindowsProductKey -Path $WindowsPartition -ProductKey $ProductKey - } - If ($ISOPath) { - WriteLog 'Dismounting Windows ISO' - Dismount-DiskImage -ImagePath $ISOPath | Out-null - WriteLog 'Done' - } - else { - #Remove ESD file - Remove-Item -Path $wimPath -Force - } - - - If ($InstallApps) { - #Copy Unattend file so VM Boots into Audit Mode - WriteLog 'Copying unattend file to boot to audit mode' - New-Item -Path "$($osPartitionDriveLetter):\Windows\Panther\unattend" -ItemType Directory | Out-Null - if($WindowsArch -eq 'x64'){ - Copy-Item -Path "$FFUDevelopmentPath\BuildFFUUnattend\unattend_x64.xml" -Destination "$($osPartitionDriveLetter):\Windows\Panther\Unattend\Unattend.xml" -Force | Out-Null - } - else { - Copy-Item -Path "$FFUDevelopmentPath\BuildFFUUnattend\unattend_arm64.xml" -Destination "$($osPartitionDriveLetter):\Windows\Panther\Unattend\Unattend.xml" -Force | Out-Null - } - WriteLog 'Copy completed' - Dismount-ScratchVhdx -VhdxPath $VHDXPath - } -} -catch { - Write-Host 'Creating VHDX Failed' - WriteLog "Creating VHDX Failed with error $_" - WriteLog "Dismounting $VHDXPath" - Dismount-ScratchVhdx -VhdxPath $VHDXPath - WriteLog "Removing $VMPath" - Remove-Item -Path $VMPath -Force -Recurse | Out-Null - WriteLog 'Removal complete' - If ($ISOPath) { - WriteLog 'Dismounting Windows ISO' - Dismount-DiskImage -ImagePath $ISOPath | Out-null - WriteLog 'Done' - } - else { - #Remove ESD file - WriteLog "Deleting ESD file" - Remove-Item -Path $wimPath -Force - WriteLog "ESD File deleted" - } - throw $_ - -} - -#If installing apps (Office or 3rd party), we need to build a VM and capture that FFU, if not, just cut the FFU from the VHDX file -if ($InstallApps) { - #Create VM and attach VHDX - try { - WriteLog 'Creating new FFU VM' - $FFUVM = New-FFUVM - WriteLog 'FFU VM Created' - } - catch { - Write-Host 'VM creation failed' - Writelog "VM creation failed with error $_" - Remove-FFUVM -VMName $VMName - throw $_ - - } - #Create ffu user and share to capture FFU to - try { - Set-CaptureFFU - } - catch { - Write-Host 'Set-CaptureFFU function failed' - WriteLog "Set-CaptureFFU function failed with error $_" - Remove-FFUVM -VMName $VMName - throw $_ - - } - If ($CreateCaptureMedia) { - #Create Capture Media - try { - #This should happen while the FFUVM is building - New-PEMedia -Capture $true - } - catch { - Write-Host 'Creating capture media failed' - WriteLog "Creating capture media failed with error $_" - Remove-FFUVM -VMName $VMName - throw $_ - - } - } -} -#Capture FFU file -try { - #Check for FFU Folder and create it if it's missing - 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" - } - #Check if VM is done provisioning - If ($InstallApps) { - do { - $FFUVM = Get-VM -Name $FFUVM.Name - Start-Sleep -Seconds 10 - WriteLog 'Waiting for VM to shutdown' - } while ($FFUVM.State -ne 'Off') - WriteLog 'VM Shutdown' - Optimize-FFUCaptureDrive -VhdxPath $VHDXPath - #Capture FFU file - New-FFU $FFUVM.Name - } - else { - New-FFU - } -} -Catch { - Write-Host 'Capturing FFU file failed' - Writelog "Capturing FFU file failed with error $_" - If ($InstallApps) { - Remove-FFUVM -VMName $VMName - } - else { - Remove-FFUVM - } - - throw $_ - -} -#Clean up ffu_user and Share and clean up apps -If ($InstallApps) { - try { - Remove-FFUUserShare - } - catch { - Write-Host 'Cleaning up FFU User and/or share failed' - WriteLog "Cleaning up FFU User and/or share failed with error $_" - Remove-FFUVM -VMName $VMName - throw $_ - } - #Clean up InstallAppsandSysprep.cmd - try { - WriteLog "Cleaning up $AppsPath\InstallAppsandSysprep.cmd" - Clear-InstallAppsandSysprep - } - catch { - Write-Host 'Cleaning up InstallAppsandSysprep.cmd failed' - Writelog "Cleaning up InstallAppsandSysprep.cmd failed with error $_" - throw $_ - } - try { - if (Test-Path -Path "$AppsPath\Win32" -PathType Container) { - WriteLog "Cleaning up Win32 folder" - Remove-Item -Path "$AppsPath\Win32" -Recurse -Force - } - if (Test-Path -Path "$AppsPath\MSStore" -PathType Container) { - WriteLog "Cleaning up MSStore folder" - Remove-Item -Path "$AppsPath\MSStore" -Recurse -Force - } - } - catch { - WriteLog "$_" - throw $_ - } -} -#Clean up VM or VHDX -try { - Remove-FFUVM - WriteLog 'FFU build complete!' -} -catch { - Write-Host 'VM or vhdx cleanup failed' - Writelog "VM or vhdx cleanup failed with error $_" - throw $_ -} - -#Clean up InstallAppsandSysprep.cmd -try { - WriteLog "Cleaning up $AppsPath\InstallAppsandSysprep.cmd" - Clear-InstallAppsandSysprep -} -catch { - Write-Host 'Cleaning up InstallAppsandSysprep.cmd failed' - Writelog "Cleaning up InstallAppsandSysprep.cmd failed with error $_" - throw $_ -} -try { - if (Test-Path -Path "$AppsPath\Win32" -PathType Container) { - WriteLog "Cleaning up Win32 folder" - Remove-Item -Path "$AppsPath\Win32" -Recurse -Force - } - if (Test-Path -Path "$AppsPath\MSStore" -PathType Container) { - WriteLog "Cleaning up MSStore folder" - Remove-Item -Path "$AppsPath\MSStore" -Recurse -Force - } -} -catch { - WriteLog "$_" - throw $_ -} -#Create Deployment Media -If ($CreateDeploymentMedia) { - try { - New-PEMedia -Deploy $true - } - catch { - Write-Host 'Creating deployment media failed' - WriteLog "Creating deployment media failed with error $_" - throw $_ - - } -} -If ($BuildUSBDrive) { - try { - If (Test-Path -Path $DeployISO) { - New-DeploymentUSB -CopyFFU - } - else { - WriteLog "$BuildUSBDrive set to true, however unable to find $DeployISO. USB drive not built." - } - - } - catch { - Write-Host 'Building USB deployment drive failed' - Writelog "Building USB deployment drive failed with error $_" - throw $_ - } -} -If ($RemoveFFU) { - try { - Remove-FFU - } - catch { - Write-Host 'Removing FFU files failed' - Writelog "Removing FFU files failed with error $_" - throw $_ - } - -} -If ($CleanupCaptureISO) { - try { - If (Test-Path -Path $CaptureISO) { - WriteLog "Removing $CaptureISO" - Remove-Item -Path $CaptureISO -Force - WriteLog "Removal complete" - } - } - catch { - Writelog "Removing $CaptureISO failed with error $_" - throw $_ - } -} -If ($CleanupDeployISO) { - try { - If (Test-Path -Path $DeployISO) { - WriteLog "Removing $DeployISO" - Remove-Item -Path $DeployISO -Force - WriteLog "Removal complete" - } - } - catch { - Writelog "Removing $DeployISO failed with error $_" - throw $_ - } -} -If ($CleanupAppsISO) { - try { - If (Test-Path -Path $AppsISO) { - WriteLog "Removing $AppsISO" - Remove-Item -Path $AppsISO -Force - WriteLog "Removal complete" - } - } - catch { - Writelog "Removing $AppsISO failed with error $_" - throw $_ - } -If ($CleanupDrivers){ - try { - If (Test-Path -Path $Driversfolder\$Make) { - WriteLog "Removing $Driversfolder\$Make" - Remove-Item -Path $Driversfolder\$Make -Force -Recurse - WriteLog "Removal complete" - } - } - catch { - Writelog "Removing $Driversfolder\$Make failed with error $_" - throw $_ - } - -} -} -#Clean up dirty.txt file -Remove-Item -Path .\dirty.txt -Force | out-null -if ($VerbosePreference -ne 'Continue'){ - Write-Host 'Script complete' -} -# Record the end time -$endTime = Get-Date -Write-Host "FFU build process completed at" $endTime - -# Calculate the total run time -$runTime = $endTime - $startTime - -# Format the runtime as minutes and seconds -$runTimeFormatted = 'Duration: {0:mm} min {0:ss} sec' -f $runTime - -if ($VerbosePreference -ne 'Continue'){ - Write-Host $runTimeFormatted -} -WriteLog 'Script complete' -WriteLog $runTimeFormatted From dad51fdf80b22096c6a80e26cf203fbecb2dca21 Mon Sep 17 00:00:00 2001 From: Doctair Date: Mon, 12 Aug 2024 13:15:16 -0400 Subject: [PATCH 04/14] updated Docs to relect new $UpdatePreviewCU Parameter --- FFUDevelopment/Docs/BuildDeployFFU.docx | Bin 2718976 -> 2716564 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/FFUDevelopment/Docs/BuildDeployFFU.docx b/FFUDevelopment/Docs/BuildDeployFFU.docx index 19d200efebde977a704c55928af4f301c39da4fa..52d64a84f9d5013e99efb5404950a02cf834e0cb 100644 GIT binary patch delta 53791 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

    }VNnH!{RfXuGmYw)vMx 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 From e250e2a1307ed70753b2720ac752b79009f1fbb5 Mon Sep 17 00:00:00 2001 From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com> Date: Tue, 13 Aug 2024 16:11:36 -0700 Subject: [PATCH 05/14] Changed some logging when winget apps can't be found --- FFUDevelopment/BuildFFUVM.ps1 | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1 index 96d5779..fab50ad 100644 --- a/FFUDevelopment/BuildFFUVM.ps1 +++ b/FFUDevelopment/BuildFFUVM.ps1 @@ -1764,7 +1764,15 @@ function Get-WinGetApp { ) $wingetSearchResult = & winget.exe search --id "$WinGetAppId" --exact --accept-source-agreements --source winget if ($wingetSearchResult -contains "No package found matching input criteria.") { - WriteLog "$WinGetAppName not found in WinGet repository. Skipping download." + if ($VerbosePreference -ne 'Continue'){ + Write-Error "$WinGetAppName not found in WinGet repository. Skipping download." + Write-Error "Check the AppList.json file and make sure the AppID is correct." + Write-Error "If OS language is not English, winget download may fail. We hope to have this addressed in a future release." + } + WriteLog "$WinGetAppName not found in WinGet repository. Exiting." + WriteLog "Check the AppList.json file and make sure the AppID is correct." + WriteLog "If OS language is not English, winget download may fail. We hope to have this addressed in a future release." + Exit 1 } $appFolderPath = Join-Path -Path "$AppsPath\Win32" -ChildPath $WinGetAppName WriteLog "Creating $appFolderPath" From dc4438dcf960f3a29b7f61d6713f2004acaff944 Mon Sep 17 00:00:00 2001 From: w0 <33212583+w0@users.noreply.github.com> Date: Thu, 15 Aug 2024 20:11:41 -0500 Subject: [PATCH 06/14] use ValidateSet for WindowsSKU --- FFUDevelopment/BuildFFUVM.ps1 | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1 index da4434e..27cb418 100644 --- a/FFUDevelopment/BuildFFUVM.ps1 +++ b/FFUDevelopment/BuildFFUVM.ps1 @@ -198,11 +198,7 @@ param( [Parameter(Mandatory = $false, Position = 0)] [ValidateScript({ Test-Path $_ })] [string]$ISOPath, - [ValidateScript({ - $allowedSKUs = @('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') - if ($allowedSKUs -contains $_) { $true } else { throw "Invalid WindowsSKU value. Allowed values: $($allowedSKUs -join ', ')" } - return $true - })] + [ValidateSet('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')] [string]$WindowsSKU = 'Pro', [ValidateScript({ Test-Path $_ })] [string]$FFUDevelopmentPath = $PSScriptRoot, From 7d74feec0c94f9d09e224107028d52f038b7a2de Mon Sep 17 00:00:00 2001 From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com> Date: Wed, 4 Sep 2024 13:20:35 -0700 Subject: [PATCH 07/14] - Fix an issue with removal of Defender/OneDrive/Edge after FFU is complete - Migrate Winget downloads to use Export-WingetPackage cmdlet as per issue Known Issue: Winget downloads fail on Non-English OS #50 - Add better logging when unable to find HDD when applying FFU --- FFUDevelopment/BuildFFUVM.ps1 | 471 +++++++++++------- .../WinPEDeployFFUFiles/ApplyFFU.ps1 | 7 +- 2 files changed, 308 insertions(+), 170 deletions(-) diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1 index f0703b0..e6da207 100644 --- a/FFUDevelopment/BuildFFUVM.ps1 +++ b/FFUDevelopment/BuildFFUVM.ps1 @@ -332,7 +332,7 @@ param( [bool]$AllowExternalHardDiskMedia, [bool]$PromptExternalHardDiskMedia = $true ) -$version = '2408.2' +$version = '2409.1' #Check if Hyper-V feature is installed (requires only checks the module) $osInfo = Get-WmiObject -Class Win32_OperatingSystem @@ -1679,33 +1679,82 @@ function Install-WinGet { WriteLog "Downloading $($package.Name) from $($package.Url) to $destination" Start-BitsTransferWithRetry -Source $package.Url -Destination $destination WriteLog "Installing $($package.Name)..." + # Don't show progress bar for Add-AppxPackage - there's a weird issue where the progress stays on the screen after the apps are installed + $ProgressPreference = 'SilentlyContinue' Add-AppxPackage -Path $destination -ErrorAction SilentlyContinue + # Set progress preference back to default + $ProgressPreference = 'Continue' WriteLog "Removing $($package.Name)..." Remove-Item -Path $destination -Force -ErrorAction SilentlyContinue } WriteLog "WinGet installation complete." } +# function Confirm-WinGetInstallation { +# WriteLog 'Checking if WinGet is installed...' +# $wingetPath = "$env:LOCALAPPDATA\Microsoft\WindowsApps\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\winget.exe" +# $minVersion = [version]"1.8.1911" +# if (-not (Test-Path -Path $wingetPath -PathType Leaf)) { +# WriteLog "WinGet is not installed. Downloading WinGet..." +# Install-WinGet -Architecture $WindowsArch +# } +# if (-not (Get-Command -Name winget -ErrorAction SilentlyContinue)) { +# WriteLog "WinGet not found. Downloading WinGet..." +# Install-WinGet -Architecture $WindowsArch +# } +# $wingetVersion = & winget.exe --version +# WriteLog "Installed version of WinGet: $wingetVersion" +# if ($wingetVersion -match 'v?(\d+\.\d+\.\d+)' -and [version]$matches[1] -lt $minVersion) { +# WriteLog "The installed version of WinGet $($matches[1]) does not support downloading MSStore apps. Downloading the latest version of WinGet..." +# Install-WinGet -Architecture $WindowsArch +# } + +# # Check if Winget PowerShell module version 1.8.1911 or later is installed +# $wingetModule = Get-InstalledModule -Name Microsoft.Winget.Client -ErrorAction SilentlyContinue +# if ($wingetModule.Version -lt $minVersion -or -not $wingetModule) { +# WriteLog 'Microsoft.Winget.Client module is not installed or is an older version. Installing the latest version...' +# #Check if PSGallery is a trusted repository +# $PSGalleryTrust = (Get-PSRepository -Name 'PSGallery').InstallationPolicy +# if($PSGalleryTrust -eq 'Untrusted'){ +# WriteLog 'Temporarily setting PSGallery as a trusted repository...' +# Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted +# } +# Install-Module -Name Microsoft.Winget.Client -Force -Repository 'PSGallery' +# if($PSGalleryTrust -eq 'Untrusted'){ +# WriteLog 'Setting PSGallery back to untrusted repository...' +# Set-PSRepository -Name 'PSGallery' -InstallationPolicy Untrusted +# WriteLog 'Done' +# } +# } +# } function Confirm-WinGetInstallation { WriteLog 'Checking if WinGet is installed...' - $wingetPath = "$env:LOCALAPPDATA\Microsoft\WindowsApps\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\winget.exe" $minVersion = [version]"1.8.1911" - if (-not (Test-Path -Path $wingetPath -PathType Leaf)) { - WriteLog "WinGet is not installed. Downloading WinGet..." - Install-WinGet -Architecture $WindowsArch - return - } - if (-not (Get-Command -Name winget -ErrorAction SilentlyContinue)) { - WriteLog "WinGet not found. Downloading WinGet..." - Install-WinGet -Architecture $WindowsArch - return + # Check if Winget PowerShell module version 1.8.1911 or later is installed + $wingetModule = Get-InstalledModule -Name Microsoft.Winget.Client -ErrorAction SilentlyContinue + if ($wingetModule.Version -lt $minVersion -or -not $wingetModule) { + WriteLog 'Microsoft.Winget.Client module is not installed or is an older version. Installing the latest version...' + #Check if PSGallery is a trusted repository + $PSGalleryTrust = (Get-PSRepository -Name 'PSGallery').InstallationPolicy + if($PSGalleryTrust -eq 'Untrusted'){ + WriteLog 'Temporarily setting PSGallery as a trusted repository...' + Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted + } + Install-Module -Name Microsoft.Winget.Client -Force -Repository 'PSGallery' + if($PSGalleryTrust -eq 'Untrusted'){ + WriteLog 'Setting PSGallery back to untrusted repository...' + Set-PSRepository -Name 'PSGallery' -InstallationPolicy Untrusted + WriteLog 'Done' + } } - $wingetVersion = & winget.exe --version - WriteLog "Installed version of WinGet: $wingetVersion" - if ($wingetVersion -match 'v?(\d+\.\d+\.\d+)' -and [version]$matches[1] -lt $minVersion) { - WriteLog "The installed version of WinGet $($matches[1]) does not support downloading MSStore apps. Downloading the latest version of WinGet..." + $wingetVersion = Get-WinGetVersion + if (-not $wingetVersion) { + WriteLog "WinGet is not installed. Installing WinGet..." + Install-WinGet -Architecture $WindowsArch + } + if (($wingetVersion -match 'v?(\d+\.\d+\.\d+)' -and [version]$matches[1] -lt $minVersion)) { + WriteLog "The installed version of WinGet $($matches[1]) does not support downloading MSStore apps. Installing the latest version of WinGet..." Install-WinGet -Architecture $WindowsArch - return } } @@ -1753,51 +1802,106 @@ function Set-InstallStoreAppsFlag { } } +# function Get-WinGetApp { +# param ( +# [string]$WinGetAppName, +# [string]$WinGetAppId +# ) +# $wingetSearchResult = & winget.exe search --id "$WinGetAppId" --exact --accept-source-agreements --source winget +# if ($wingetSearchResult -contains "No package found matching input criteria.") { +# if ($VerbosePreference -ne 'Continue'){ +# Write-Error "$WinGetAppName not found in WinGet repository. Skipping download." +# Write-Error "Check the AppList.json file and make sure the AppID is correct." +# Write-Error "If OS language is not English, winget download may fail. We hope to have this addressed in a future release." +# } +# WriteLog "$WinGetAppName not found in WinGet repository. Exiting." +# WriteLog "Check the AppList.json file and make sure the AppID is correct." +# WriteLog "If OS language is not English, winget download may fail. We hope to have this addressed in a future release." +# Exit 1 +# } +# $appFolderPath = Join-Path -Path "$AppsPath\Win32" -ChildPath $WinGetAppName +# WriteLog "Creating $appFolderPath" +# New-Item -Path $appFolderPath -ItemType Directory -Force | Out-Null +# WriteLog "Downloading $WinGetAppName to $appFolderPath" +# $downloadParams = @( +# "download", +# "--id", "$WinGetAppId", +# "--exact", +# "--download-directory", "$appFolderPath", +# "--accept-package-agreements", +# "--accept-source-agreements", +# "--source", "winget", +# "--scope", "machine", +# "--architecture", "$WindowsArch" +# ) +# WriteLog "winget command: winget.exe $downloadParams" +# $wingetDownloadResult = & winget.exe @downloadParams | Out-String +# if ($wingetDownloadResult -match "No applicable installer found") { +# WriteLog "No installer found for $WindowsArch architecture. Attempting to download without specifying architecture..." +# $downloadParams = $downloadParams | Where-Object { $_ -notmatch "--architecture" -and $_ -notmatch "$WindowsArch" } +# $wingetDownloadResult = & winget.exe @downloadParams | Out-String +# if ($wingetDownloadResult -match "Installer downloaded") { +# WriteLog "Downloaded $WinGetAppName without specifying architecture." +# } +# } +# if ($wingetDownloadResult -notmatch "Installer downloaded") { +# WriteLog "No installer found for $WinGetAppName. Skipping download." +# Remove-Item -Path $appFolderPath -Recurse -Force +# } +# WriteLog "$WinGetAppName downloaded to $appFolderPath" +# $installerPath = Get-ChildItem -Path "$appFolderPath\*" -Exclude "*.yaml", "*.xml" -File -ErrorAction Stop +# $uwpExtensions = @(".appx", ".appxbundle", ".msix", ".msixbundle") +# if ($uwpExtensions -contains $installerPath.Extension) { +# $NewAppPath = "$AppsPath\MSStore\$WinGetAppName" +# Writelog "$WinGetAppName is a UWP app. Moving to $NewAppPath" +# WriteLog "Creating $NewAppPath" +# New-Item -Path "$AppsPath\MSStore\$WinGetAppName" -ItemType Directory -Force | Out-Null +# WriteLog "Moving $WinGetAppName to $NewAppPath" +# Move-Item -Path "$appFolderPath\*" -Destination "$AppsPath\MSStore\$WinGetAppName" -Force +# WriteLog "Removing $appFolderPath" +# Remove-Item -Path $appFolderPath -Force +# WriteLog "$WinGetAppName moved to $NewAppPath" +# Set-InstallStoreAppsFlag +# } +# else { +# Add-Win32SilentInstallCommand -AppFolder $WinGetAppName -AppFolderPath $appFolderPath +# } +# } function Get-WinGetApp { param ( [string]$WinGetAppName, [string]$WinGetAppId ) - $wingetSearchResult = & winget.exe search --id "$WinGetAppId" --exact --accept-source-agreements --source winget - if ($wingetSearchResult -contains "No package found matching input criteria.") { + $Source = 'winget' + $wingetSearchResult = Find-WinGetPackage -id $WinGetAppId -MatchOption Equals -Source $Source + if (-not $wingetSearchResult) { if ($VerbosePreference -ne 'Continue'){ - Write-Error "$WinGetAppName not found in WinGet repository. Skipping download." + Write-Error "$WinGetAppName not found in WinGet repository. Exiting." Write-Error "Check the AppList.json file and make sure the AppID is correct." - Write-Error "If OS language is not English, winget download may fail. We hope to have this addressed in a future release." } WriteLog "$WinGetAppName not found in WinGet repository. Exiting." WriteLog "Check the AppList.json file and make sure the AppID is correct." - WriteLog "If OS language is not English, winget download may fail. We hope to have this addressed in a future release." Exit 1 } $appFolderPath = Join-Path -Path "$AppsPath\Win32" -ChildPath $WinGetAppName WriteLog "Creating $appFolderPath" New-Item -Path $appFolderPath -ItemType Directory -Force | Out-Null WriteLog "Downloading $WinGetAppName to $appFolderPath" - $downloadParams = @( - "download", - "--id", "$WinGetAppId", - "--exact", - "--download-directory", "$appFolderPath", - "--accept-package-agreements", - "--accept-source-agreements", - "--source", "winget", - "--scope", "machine", - "--architecture", "$WindowsArch" - ) - WriteLog "winget command: winget.exe $downloadParams" - $wingetDownloadResult = & winget.exe @downloadParams | Out-String - if ($wingetDownloadResult -match "No applicable installer found") { + + WriteLog "WinGet command: Export-WinGetPackage -id $WinGetAppId -DownloadDirectory $appFolderPath -Architecture $WindowsArch -Source $Source" + $wingetDownloadResult = Export-WinGetPackage -id $WinGetAppId -DownloadDirectory $appFolderPath -Architecture $WindowsArch -Source $Source + if ($wingetDownloadResult.status -eq 'NoApplicableInstallers') { + # If no applicable installer is found, try downloading without specifying architecture WriteLog "No installer found for $WindowsArch architecture. Attempting to download without specifying architecture..." - $downloadParams = $downloadParams | Where-Object { $_ -notmatch "--architecture" -and $_ -notmatch "$WindowsArch" } - $wingetDownloadResult = & winget.exe @downloadParams | Out-String - if ($wingetDownloadResult -match "Installer downloaded") { + $wingetDownloadResult = Export-WinGetPackage -id $WinGetAppId -DownloadDirectory $appFolderPath -Source $Source + if ($wingetDownloadResult.status -eq 'Ok') { WriteLog "Downloaded $WinGetAppName without specifying architecture." } - } - if ($wingetDownloadResult -notmatch "Installer downloaded") { - WriteLog "No installer found for $WinGetAppName. Skipping download." - Remove-Item -Path $appFolderPath -Recurse -Force + else{ + WriteLog "No installer found for $WinGetAppName. Exiting." + Remove-Item -Path $appFolderPath -Recurse -Force + Exit 1 + } } WriteLog "$WinGetAppName downloaded to $appFolderPath" $installerPath = Get-ChildItem -Path "$appFolderPath\*" -Exclude "*.yaml", "*.xml" -File -ErrorAction Stop @@ -1819,15 +1923,105 @@ function Get-WinGetApp { } } +# function Get-StoreApp { +# param ( +# [string]$StoreAppName, +# [string]$StoreAppId +# ) +# $wingetSearchResult = & winget.exe search "$StoreAppId" --accept-source-agreements --source msstore +# if ($wingetSearchResult -contains "No package found matching input criteria.") { +# WriteLog "$StoreAppName not found in WinGet repository. Skipping download." +# return +# } +# WriteLog "Checking if $StoreAppName is a win32 app..." +# $appIsWin32 = $StoreAppId.StartsWith("XP") +# if ($appIsWin32) { +# WriteLog "$StoreAppName is a win32 app. Adding to $AppsPath\win32 folder" +# $appFolderPath = Join-Path -Path "$AppsPath\win32" -ChildPath $StoreAppName +# } +# else { +# WriteLog "$StoreAppName is not a win32 app." +# $appFolderPath = Join-Path -Path "$AppsPath\MSStore" -ChildPath $StoreAppName +# } +# New-Item -Path $appFolderPath -ItemType Directory -Force | Out-Null +# WriteLog "Downloading $StoreAppName for $WindowsArch architecture..." +# $downloadParams = @( +# "download", "$StoreAppId", +# "--download-directory", "$appFolderPath", +# "--accept-package-agreements", +# "--accept-source-agreements", +# "--source", "msstore", +# "--scope", "machine", +# "--architecture", "$WindowsArch" +# ) +# WriteLog 'MSStore app downloads require authentication with an Entra ID account. You may be prompted twice for credentials, once for the app and another for the license file.' +# WriteLog "Attempting to download $StoreAppName and dependencies for $WindowsArch architecture..." +# $wingetDownloadResult = & winget.exe @downloadParams | Out-String +# # For some apps, specifying the architecture leads to no results found for the app. In those cases, the command will be run without the architecture parameter. +# if ($wingetDownloadResult -match "No applicable installer found") { +# WriteLog "No installer found for $WindowsArch architecture. Attempting to download without specifying architecture..." +# $downloadParams = $downloadParams | Where-Object { $_ -notmatch "--architecture" -and $_ -notmatch "$WindowsArch" } +# $wingetDownloadResult = & winget.exe @downloadParams | Out-String +# if ($wingetDownloadResult -match "Microsoft Store package download completed") { +# WriteLog "Downloaded $StoreAppName without specifying architecture." +# } +# } +# if ($wingetDownloadResult -notmatch "Installer downloaded|Microsoft Store package download completed") { +# WriteLog "Download not supported for $StoreAppName. Skipping download." +# Remove-Item -Path $appFolderPath -Recurse -Force +# return +# } +# if ($appIsWin32) { +# Add-Win32SilentInstallCommand -AppFolder $StoreAppName -AppFolderPath $appFolderPath +# } +# Set-InstallStoreAppsFlag +# # If $WindowsArch -eq 'ARM64', remove all dependency files that are not ARM64 +# if ($WindowsArch -eq 'ARM64') { +# WriteLog 'Windows architecture is ARM64. Removing dependencies that are not ARM64.' +# $dependencies = Get-ChildItem -Path "$appFolderPath\Dependencies" -ErrorAction SilentlyContinue +# if ($dependencies) { +# foreach ($dependency in $dependencies) { +# if ($dependency.Name -notmatch 'ARM64') { +# WriteLog "Removing dependency file $($dependency.FullName)" +# Remove-Item -Path $dependency.FullName -Recurse -Force +# } +# } +# } +# } +# WriteLog "$StoreAppName has completed downloading. Identifying the latest version of $StoreAppName." +# $packages = Get-ChildItem -Path "$appFolderPath\*" -Exclude "Dependencies\*", "*.xml", "*.yaml" -File -ErrorAction Stop +# # WinGet downloads multiple versions of certain store apps. The latest version of the package will be determined based on the date of the file signature. +# $latestPackage = $packages | Sort-Object { (Get-AuthenticodeSignature $_.FullName).SignerCertificate.NotBefore } -Descending | Select-Object -First 1 +# # Removing all packages that are not the latest version +# WriteLog "Latest version of $StoreAppName has been identified as $latestPackage. Removing old versions of $StoreAppName that may have downloaded." +# foreach ($package in $packages) { +# if ($package.FullName -ne $latestPackage) { +# try { +# WriteLog "Removing $($package.FullName)" +# Remove-Item -Path $package.FullName -Force +# } +# catch { +# WriteLog "Failed to delete: $($package.FullName) - $_" +# throw $_ +# } +# } +# } +# } function Get-StoreApp { param ( [string]$StoreAppName, [string]$StoreAppId ) - $wingetSearchResult = & winget.exe search "$StoreAppId" --accept-source-agreements --source msstore - if ($wingetSearchResult -contains "No package found matching input criteria.") { - WriteLog "$StoreAppName not found in WinGet repository. Skipping download." - return + $Source = 'msstore' + $wingetSearchResult = Find-WinGetPackage -id $StoreAppId -MatchOption Equals -Source $Source + if (-not $wingetSearchResult) { + if ($VerbosePreference -ne 'Continue'){ + Write-Error "$WinGetAppName not found in WinGet repository. Exiting." + Write-Error "Check the AppList.json file and make sure the AppID is correct." + } + WriteLog "$WinGetAppName not found in WinGet repository. Exiting." + WriteLog "Check the AppList.json file and make sure the AppID is correct." + Exit 1 } WriteLog "Checking if $StoreAppName is a win32 app..." $appIsWin32 = $StoreAppId.StartsWith("XP") @@ -1841,31 +2035,22 @@ function Get-StoreApp { } New-Item -Path $appFolderPath -ItemType Directory -Force | Out-Null WriteLog "Downloading $StoreAppName for $WindowsArch architecture..." - $downloadParams = @( - "download", "$StoreAppId", - "--download-directory", "$appFolderPath", - "--accept-package-agreements", - "--accept-source-agreements", - "--source", "msstore", - "--scope", "machine", - "--architecture", "$WindowsArch" - ) WriteLog 'MSStore app downloads require authentication with an Entra ID account. You may be prompted twice for credentials, once for the app and another for the license file.' WriteLog "Attempting to download $StoreAppName and dependencies for $WindowsArch architecture..." - $wingetDownloadResult = & winget.exe @downloadParams | Out-String - # For some apps, specifying the architecture leads to no results found for the app. In those cases, the command will be run without the architecture parameter. - if ($wingetDownloadResult -match "No applicable installer found") { + WriteLog "WinGet command: Export-WinGetPackage -id $StoreAppId -DownloadDirectory $appFolderPath -Architecture $WindowsArch -Source $Source" + $wingetDownloadResult = Export-WinGetPackage -id $StoreAppId -DownloadDirectory $appFolderPath -Architecture $WindowsArch -Source $Source + if ($wingetDownloadResult.status -eq 'NoApplicableInstallerFound') { + # If no applicable installer is found, try downloading without specifying architecture WriteLog "No installer found for $WindowsArch architecture. Attempting to download without specifying architecture..." - $downloadParams = $downloadParams | Where-Object { $_ -notmatch "--architecture" -and $_ -notmatch "$WindowsArch" } - $wingetDownloadResult = & winget.exe @downloadParams | Out-String - if ($wingetDownloadResult -match "Microsoft Store package download completed") { - WriteLog "Downloaded $StoreAppName without specifying architecture." + $wingetDownloadResult = Export-WinGetPackage -id $StoreAppId -DownloadDirectory $appFolderPath -Source $Source + if ($wingetDownloadResult.status -eq 'Ok') { + WriteLog "Downloaded $WinGetAppName without specifying architecture." + } + else{ + WriteLog "No installer found for $WinGetAppName. Exiting" + Remove-Item -Path $appFolderPath -Recurse -Force + Exit 1 } - } - if ($wingetDownloadResult -notmatch "Installer downloaded|Microsoft Store package download completed") { - WriteLog "Download not supported for $StoreAppName. Skipping download." - Remove-Item -Path $appFolderPath -Recurse -Force - return } if ($appIsWin32) { Add-Win32SilentInstallCommand -AppFolder $StoreAppName -AppFolderPath $appFolderPath @@ -2054,68 +2239,6 @@ function Save-KB { if ($WindowsArch -eq 'x64') { [array]$WindowsArch = @("x64", "amd64") } - #Keep for now, will remove in future - # foreach ($kb in $name) { - # $links = Get-KBLink -Name $kb - # foreach ($link in $links) { - # #Check if $WindowsArch is an array - # if ($WindowsArch -is [array]) { - # #Some file names include either x64 or amd64 - # if ($link -match $WindowsArch[0] -or $link -match $WindowsArch[1]) { - # Start-BitsTransferWithRetry -Source $link -Destination $Path - # $fileName = ($link -split '/')[-1] - # break - # } - # # elseif (!($link -match 'x64' -or $link -match 'amd64' -or $link -match 'x86' -or $link -match 'arm64')) { - # # Write-Host "No architecture found in $link, assume it's for all architectures" - # # Start-BitsTransfer -Source $link -Destination $Path - # # $fileName = ($link -split '/')[-1] - # # break - # # } - # elseif (!($link -match 'x64' -or $link -match 'amd64' -or $link -match 'x86' -or $link -match 'arm64')) { - # WriteLog "No architecture found in $link, assume this is for all architectures" - # #FIX: 3/22/2024 - the SecurityHealthSetup fix was updated and now includes two files (one is x64 and the other is arm64) - # #Unfortunately there is no easy way to determine the architecture from the file name - # #There is a support doc that include links to download, but it's out of date (n-1) - # #https://support.microsoft.com/en-us/topic/windows-security-update-a6ac7d2e-b1bf-44c0-a028-41720a242da3 - # #These files don't change that often, so will check the link above to see when it updates and may use that - # #For now this is hard-coded for these specific file names - # if ($link -match 'security'){ - # #Make sure we're getting the correct architecture for the Security Health Setup update - # WriteLog "Link: $link matches security" - # if ($WindowsArch -eq 'x64'){ - # if ($link -match 'securityhealthsetup_e1'){ - # Writelog "Downloading $Link for $WindowsArch to $Path" - # Start-BitsTransferWithRetry -Source $link -Destination $Path - # $fileName = ($link -split '/')[-1] - # Writelog "Returning $fileName" - # break - # } - # } - # elseif ($WindowsArch -eq 'arm64'){ - # if ($link -match 'securityhealthsetup_25'){ - # Writelog "Downloading $Link for $WindowsArch to $Path" - # Start-BitsTransferWithRetry -Source $link -Destination $Path - # $fileName = ($link -split '/')[-1] - # Writelog "Returning $fileName" - # break - # } - # } - # continue - # } - # Start-BitsTransferWithRetry -Source $link -Destination $Path - # $fileName = ($link -split '/')[-1] - # } - # } - # else { - # if ($link -match $WindowsArch) { - # Start-BitsTransferWithRetry -Source $link -Destination $Path - # $fileName = ($link -split '/')[-1] - # break - # } - # } - # } - # } foreach ($kb in $name) { $links = Get-KBLink -Name $kb foreach ($link in $links) { @@ -3326,8 +3449,13 @@ function Get-FFUEnvironment { foreach ($image in $mountedImages) { $mountPath = $image.Path WriteLog "Dismounting image at $mountPath" - Dismount-WindowsImage -Path $mountPath -discard | Out-null - WriteLog "Successfully dismounted image at $mountPath" + try { + Dismount-WindowsImage -Path $mountPath -discard | Out-null + WriteLog "Successfully dismounted image at $mountPath" + } + catch { + WriteLog "Failed to dismount image at $mountPath with error: $_" + } } } @@ -3356,6 +3484,7 @@ function Get-FFUEnvironment { Remove-FFUUserShare WriteLog 'Removal complete' } + Clear-InstallAppsandSysprep #Clean up $KBPath If (Test-Path -Path $KBPath) { WriteLog "Removing $KBPath" @@ -3388,7 +3517,6 @@ function Get-FFUEnvironment { WriteLog "Cleaning up MSStore folder" Remove-Item -Path "$AppsPath\MSStore" -Recurse -Force -ErrorAction SilentlyContinue } - Clear-InstallAppsandSysprep Writelog 'Removing dirty.txt file' Remove-Item -Path "$FFUDevelopmentPath\dirty.txt" -Force WriteLog "Cleanup complete" @@ -3412,29 +3540,34 @@ function Clear-InstallAppsandSysprep { WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to remove Defender Platform Update" $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" $CmdContent -notmatch 'd:\\Defender*' | Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - # #Remove $DefenderPath - # WriteLog "Removing $DefenderPath" - # Remove-Item -Path $DefenderPath -Recurse -Force - # WriteLog "Removal complete" - + #Clean up $DefenderPath + If (Test-Path -Path $DefenderPath) { + WriteLog "Removing $DefenderPath" + Remove-Item -Path $DefenderPath -Recurse -Force -ErrorAction SilentlyContinue + WriteLog 'Removal complete' + } } if ($UpdateOneDrive) { WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to remove OneDrive install" $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" $CmdContent -notmatch 'd:\\OneDrive*' | Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - # #Remove $OneDrivePath - # WriteLog "Removing $OneDrivePath" - # Remove-Item -Path $OneDrivePath -Recurse -Force - # WriteLog "Removal complete" + #Clean up $OneDrivePath + If (Test-Path -Path $OneDrivePath) { + WriteLog "Removing $OneDrivePath" + Remove-Item -Path $OneDrivePath -Recurse -Force -ErrorAction SilentlyContinue + WriteLog 'Removal complete' + } } if ($UpdateEdge) { WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to remove Edge install" $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" $CmdContent -notmatch 'd:\\Edge*' | Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - # #Remove $EdgePath - # WriteLog "Removing $EdgePath" - # Remove-Item -Path $EdgePath -Recurse -Force - # WriteLog "Removal complete" + #Clean up $EdgePath + If (Test-Path -Path $EdgePath) { + WriteLog "Removing $EdgePath" + Remove-Item -Path $EdgePath -Recurse -Force -ErrorAction SilentlyContinue + WriteLog 'Removal complete' + } } } @@ -4003,30 +4136,30 @@ catch { throw $_ } -#Clean up InstallAppsandSysprep.cmd -try { - WriteLog "Cleaning up $AppsPath\InstallAppsandSysprep.cmd" - Clear-InstallAppsandSysprep -} -catch { - Write-Host 'Cleaning up InstallAppsandSysprep.cmd failed' - Writelog "Cleaning up InstallAppsandSysprep.cmd failed with error $_" - throw $_ -} -try { - if (Test-Path -Path "$AppsPath\Win32" -PathType Container) { - WriteLog "Cleaning up Win32 folder" - Remove-Item -Path "$AppsPath\Win32" -Recurse -Force - } - if (Test-Path -Path "$AppsPath\MSStore" -PathType Container) { - WriteLog "Cleaning up MSStore folder" - Remove-Item -Path "$AppsPath\MSStore" -Recurse -Force - } -} -catch { - WriteLog "$_" - throw $_ -} +# #Clean up InstallAppsandSysprep.cmd +# try { +# WriteLog "Cleaning up $AppsPath\InstallAppsandSysprep.cmd" +# Clear-InstallAppsandSysprep +# } +# catch { +# Write-Host 'Cleaning up InstallAppsandSysprep.cmd failed' +# Writelog "Cleaning up InstallAppsandSysprep.cmd failed with error $_" +# throw $_ +# } +# try { +# if (Test-Path -Path "$AppsPath\Win32" -PathType Container) { +# WriteLog "Cleaning up Win32 folder" +# Remove-Item -Path "$AppsPath\Win32" -Recurse -Force +# } +# if (Test-Path -Path "$AppsPath\MSStore" -PathType Container) { +# WriteLog "Cleaning up MSStore folder" +# Remove-Item -Path "$AppsPath\MSStore" -Recurse -Force +# } +# } +# catch { +# WriteLog "$_" +# throw $_ +# } #Create Deployment Media If ($CreateDeploymentMedia) { try { diff --git a/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 b/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 index 7c9f19f..34f1f9d 100644 --- a/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 +++ b/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 @@ -128,13 +128,18 @@ $LogFileName = 'ScriptLog.txt' $USBDrive = Get-USBDrive New-item -Path $USBDrive -Name $LogFileName -ItemType "file" -Force | Out-Null $LogFile = $USBDrive + $LogFilename -$version = '2408.2' +$version = '2409.1' WriteLog 'Begin Logging' WriteLog "Script version: $version" #Find PhysicalDrive # $PhysicalDeviceID = Get-HardDrive $hardDrive = Get-HardDrive +if($hardDrive -eq $null){ + WriteLog 'No hard drive found. Exiting' + WriteLog 'Try adding storage drivers to the PE boot image (you can re-create your FFU and USB drive and add the PE drivers to the PEDrivers folder and add -CopyPEDrivers $true to the command line, or manually add them via DISM)' + Exit +} $PhysicalDeviceID = $hardDrive.DeviceID $BytesPerSector = $hardDrive.BytesPerSector WriteLog "Physical BytesPerSector is $BytesPerSector" From e62d481405f506ca7c98e3a8dcff0cc46e73719e Mon Sep 17 00:00:00 2001 From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com> Date: Wed, 4 Sep 2024 17:05:06 -0700 Subject: [PATCH 08/14] - Remove ValidateScript on InstallDrivers and break it out in a validation block so -Make and -Model can be specified anywhere in the command line - Check for Prefixes.txt file and copy to the USB drive if it exists - Perform better validation for PPKG, Unattend, Autopilot json, and drivers - Comment out the Windows Security Platform update as the file has been removed from the MU Catalog. --- FFUDevelopment/BuildFFUVM.ps1 | 139 +++++++++++++++++++++++++--------- 1 file changed, 103 insertions(+), 36 deletions(-) diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1 index 30f30a3..49d0a5b 100644 --- a/FFUDevelopment/BuildFFUVM.ps1 +++ b/FFUDevelopment/BuildFFUVM.ps1 @@ -210,16 +210,16 @@ param( [ValidateSet('Microsoft', 'Dell', 'HP', 'Lenovo')] [string]$Make, [string]$Model, - [Parameter(Mandatory = $false)] - [ValidateScript({ - if ($Make) { - return $true - } - if ($_ -and (!(Test-Path -Path '.\Drivers') -or ((Get-ChildItem -Path '.\Drivers' -Recurse | Measure-Object -Property Length -Sum).Sum -lt 1MB))) { - throw 'InstallDrivers is set to $true, but either the Drivers folder is missing or empty' - } - return $true - })] + # [Parameter(Mandatory = $false)] + # [ValidateScript({ + # if ($Make) { + # return $true + # } + # if ($_ -and (!(Test-Path -Path '.\Drivers') -or ((Get-ChildItem -Path '.\Drivers' -Recurse | Measure-Object -Property Length -Sum).Sum -lt 1MB))) { + # throw 'InstallDrivers is set to $true, but either the Drivers folder is missing or empty' + # } + # return $true + # })] [bool]$InstallDrivers, [uint64]$Memory = 4GB, [uint64]$Disksize = 30GB, @@ -3359,9 +3359,14 @@ Function New-DeploymentUSB { if ($WindowsArch -eq 'x64') { Copy-Item -Path "$FFUDevelopmentPath\unattend\unattend_x64.xml" -Destination "$DeployUnattendPath\Unattend.xml" -Force | Out-Null } - else { + if ($WindowsArch -eq 'arm64') { Copy-Item -Path "$FFUDevelopmentPath\unattend\unattend_arm64.xml" -Destination "$DeployUnattendPath\Unattend.xml" -Force | Out-Null } + #Check for prefixes.txt file and copy it to the USB drive + if (Test-Path "$FFUDevelopmentPath\unattend\prefixes.txt") { + WriteLog "Copying prefixes.txt file to $DeployUnattendPath" + Copy-Item -Path "$FFUDevelopmentPath\unattend\prefixes.txt" -Destination "$DeployUnattendPath\prefixes.txt" -Force | Out-Null + } WriteLog 'Copy completed' } #Copy PPKG folder in the FFU folder to the USB drive. Can use copy-item as it's a small folder @@ -3588,13 +3593,72 @@ Write-Host "To track progress, please open the log file $Logfile or use the -Ver WriteLog 'Begin Logging' +#Validate drivers folder +if ($InstallDrivers -or $CopyDrivers) { + WriteLog 'Doing driver validation' + if ($Make -and $Model){ + WriteLog "Make and Model are set to $Make and $Model, will attempt to download drivers" + } else { + if (!(Test-Path -Path $DriversFolder)) { + WriteLog "-InstallDrivers or -CopyDrivers is set to `$true, but the $DriversFolder folder is missing" + throw "-InstallDrivers or -CopyDrivers is set to `$true, but the $DriversFolder folder is missing" + } + if ((Get-ChildItem -Path $DriversFolder -Recurse | Measure-Object -Property Length -Sum).Sum -lt 1MB) { + WriteLog "-InstallDrivers or -CopyDrivers is set to `$true, but the $DriversFolder folder is empty" + throw "-InstallDrivers or -CopyDrivers is set to `$true, but the $DriversFolder folder is empty" + } + } +} + +#Validate PPKG folder +if ($CopyPPKG) { + WriteLog 'Doing PPKG validation' + if (!(Test-Path -Path $PPKGFolder)) { + WriteLog "-CopyPPKG is set to `$true, but the $PPKGFolder folder is missing" + throw "-CopyPPKG is set to `$true, but the $PPKGFolder folder is missing" + } + #Check for at least one .PPKG file + if (!(Get-ChildItem -Path $PPKGFolder -Filter *.ppkg)) { + WriteLog "-CopyPPKG is set to `$true, but the $PPKGFolder folder is missing a .PPKG file" + throw "-CopyPPKG is set to `$true, but the $PPKGFolder folder is missing a .PPKG file" + } +} + +#Validate Autopilot folder +if ($CopyAutopilot) { + WriteLog 'Doing Autopilot validation' + if (!(Test-Path -Path $AutopilotFolder)) { + WriteLog "-CopyAutopilot is set to `$true, but the $AutopilotFolder folder is missing" + throw "-CopyAutopilot is set to `$true, but the $AutopilotFolder folder is missing" + } + #Check for .JSON file + if (!(Get-ChildItem -Path $AutopilotFolder -Filter *.json)) { + WriteLog "-CopyAutopilot is set to `$true, but the $AutopilotFolder folder is missing a .JSON file" + throw "-CopyAutopilot is set to `$true, but the $AutopilotFolder folder is missing a .JSON file" + } +} + +#Validate Unattend folder +if ($CopyUnattend) { + WriteLog 'Doing Unattend validation' + if (!(Test-Path -Path $UnattendFolder)) { + WriteLog "-CopyUnattend is set to `$true, but the $UnattendFolder folder is missing" + throw "-CopyUnattend is set to `$true, but the $UnattendFolder folder is missing" + } + #Check for .XML file + if (!(Get-ChildItem -Path $UnattendFolder -Filter unattend_*.xml)) { + WriteLog "-CopyUnattend is set to `$true, but the $UnattendFolder folder is missing a .XML file" + throw "-CopyUnattend is set to `$true, but the $UnattendFolder folder is missing a .XML file" + } +} + #Override $InstallApps value if using ESD to build FFU. This is due to a strange issue where building the FFU #from vhdx doesn't work (you get an older style OOBE screen and get stuck in an OOBE reboot loop when hitting next). #This behavior doesn't happen with WIM files. If (-not ($ISOPath) -and (-not ($InstallApps))) { $InstallApps = $true WriteLog "Script will download Windows media. Setting `$InstallApps to `$true to build VM to capture FFU. Must do this when using MCT ESD." -} +} if (($InstallOffice -eq $true) -and ($InstallApps -eq $false)) { throw "If variable InstallOffice is set to `$true, InstallApps must also be set to `$true." @@ -3740,17 +3804,20 @@ if ($InstallApps) { Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" -Value $UpdatedcmdContent WriteLog "Update complete" - #Get Windows Security platform update - $Name = "Windows Security platform definition updates" - WriteLog "Searching for $Name from Microsoft Update Catalog and saving to $DefenderPath" - $KBFilePath = Save-KB -Name $Name -Path $DefenderPath - WriteLog "Latest Security Platform Update saved to $DefenderPath\$KBFilePath" - #Modify InstallAppsandSysprep.cmd to add in $KBFilePath on the line after REM Install Windows Security Platform Update - WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to include Windows Security Platform Update" - $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - $UpdatedcmdContent = $CmdContent -replace '^(REM Install Windows Security Platform Update)', ("REM Install Windows Security Platform Update`r`nd:\Defender\$KBFilePath") - Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" -Value $UpdatedcmdContent - WriteLog "Update complete" + ###### 9/4/2024 - Windows Security Platform update is no longer available from Update Catalog. Will change to using + ###### https://support.microsoft.com/en-us/topic/windows-security-update-a6ac7d2e-b1bf-44c0-a028-41720a242da3 + + # #Get Windows Security platform update + # $Name = "Windows Security platform definition updates" + # WriteLog "Searching for $Name from Microsoft Update Catalog and saving to $DefenderPath" + # $KBFilePath = Save-KB -Name $Name -Path $DefenderPath + # WriteLog "Latest Security Platform Update saved to $DefenderPath\$KBFilePath" + # #Modify InstallAppsandSysprep.cmd to add in $KBFilePath on the line after REM Install Windows Security Platform Update + # WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to include Windows Security Platform Update" + # $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" + # $UpdatedcmdContent = $CmdContent -replace '^(REM Install Windows Security Platform Update)', ("REM Install Windows Security Platform Update`r`nd:\Defender\$KBFilePath") + # Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" -Value $UpdatedcmdContent + # WriteLog "Update complete" #Download latest Defender Definitions WriteLog "Downloading latest Defender Definitions" @@ -3934,19 +4001,19 @@ try { $KBFilePath = Save-KB -Name $Name -Path $KBPath WriteLog "Latest .NET saved to $KBPath\$KBFilePath" } - #Update Latest Security Platform Update - if ($UpdateSecurityPlatform) { - WriteLog "`$UpdateSecurityPlatform is set to true, checking for latest Security Platform Update" - $Name = "Windows Security platform definition updates" - #Check if $KBPath exists, if not, create it - If (-not (Test-Path -Path $KBPath)) { - WriteLog "Creating $KBPath" - New-Item -Path $KBPath -ItemType Directory -Force | Out-Null - } - WriteLog "Searching for $Name from Microsoft Update Catalog and saving to $KBPath" - $KBFilePath = Save-KB -Name $Name -Path $KBPath - WriteLog "Latest Security Platform Update saved to $KBPath\$KBFilePath" - } + # #Update Latest Security Platform Update + # if ($UpdateSecurityPlatform) { + # WriteLog "`$UpdateSecurityPlatform is set to true, checking for latest Security Platform Update" + # $Name = "Windows Security platform definition updates" + # #Check if $KBPath exists, if not, create it + # If (-not (Test-Path -Path $KBPath)) { + # WriteLog "Creating $KBPath" + # New-Item -Path $KBPath -ItemType Directory -Force | Out-Null + # } + # WriteLog "Searching for $Name from Microsoft Update Catalog and saving to $KBPath" + # $KBFilePath = Save-KB -Name $Name -Path $KBPath + # WriteLog "Latest Security Platform Update saved to $KBPath\$KBFilePath" + # } #Add Windows packages From ddbf2b0339e07e0ba487324908c8c5fa30c6bf4b Mon Sep 17 00:00:00 2001 From: Zehadi Alam <63765084+zehadialam@users.noreply.github.com> Date: Thu, 5 Sep 2024 17:57:35 -0400 Subject: [PATCH 09/14] Use bcdedit to set Windows Boot Manager and default Windows boot loader to be first in the display order of UEFI firmware --- FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 b/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 index 34f1f9d..070000d 100644 --- a/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 +++ b/FFUDevelopment/WinPEDeployFFUFiles/ApplyFFU.ps1 @@ -554,6 +554,12 @@ If (Test-Path -Path $Drivers) WriteLog 'Copying drivers succeeded' } +WriteLog "Setting Windows Boot Manager to be first in the display order." +Invoke-Process bcdedit.exe "/set {fwbootmgr} displayorder {bootmgr} /addfirst" +WriteLog "Windows Boot Manager has been set to be first in the display order." +WriteLog "Setting default Windows boot loader to be first in the display order." +Invoke-Process bcdedit.exe "/set {bootmgr} displayorder {default} /addfirst" +WriteLog "The default Windows boot loader has been set to be first in the display order." #Copy DISM log to USBDrive WriteLog "Copying dism log to $USBDrive" invoke-process xcopy "X:\Windows\logs\dism\dism.log $USBDrive /Y" From 5b93135ebb117b5cf514b95117a704a198f8d296 Mon Sep 17 00:00:00 2001 From: HedgeComp <94635857+HedgeComp@users.noreply.github.com> Date: Fri, 6 Sep 2024 12:57:36 -0400 Subject: [PATCH 10/14] Update BuildFFUVM.ps1 Add Silent switch install to Onedrivesetup.exe --- FFUDevelopment/BuildFFUVM.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1 index 49d0a5b..23647b4 100644 --- a/FFUDevelopment/BuildFFUVM.ps1 +++ b/FFUDevelopment/BuildFFUVM.ps1 @@ -3876,7 +3876,7 @@ if ($InstallApps) { #Modify InstallAppsandSysprep.cmd to add in $OneDrivePath on the line after REM Install Defender Definitions WriteLog "Updating $AppsPath\InstallAppsandSysprep.cmd to include OneDrive client" $CmdContent = Get-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" - $UpdatedcmdContent = $CmdContent -replace '^(REM Install OneDrive Per Machine)', ("REM Install OneDrive Per Machine`r`nd:\OneDrive\OneDriveSetup.exe /allusers") + $UpdatedcmdContent = $CmdContent -replace '^(REM Install OneDrive Per Machine)', ("REM Install OneDrive Per Machine`r`nd:\OneDrive\OneDriveSetup.exe /allusers /silent") Set-Content -Path "$AppsPath\InstallAppsandSysprep.cmd" -Value $UpdatedcmdContent WriteLog "Update complete" } From 31c785b5da0cb8fcd7c84846901395ac253ef44c Mon Sep 17 00:00:00 2001 From: HedgeComp <94635857+HedgeComp@users.noreply.github.com> Date: Fri, 6 Sep 2024 13:00:09 -0400 Subject: [PATCH 11/14] Update BuildFFUVM.ps1 Add Hours to Total Time to complete only if greater than 1 hour --- FFUDevelopment/BuildFFUVM.ps1 | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1 index 49d0a5b..3fa3f0f 100644 --- a/FFUDevelopment/BuildFFUVM.ps1 +++ b/FFUDevelopment/BuildFFUVM.ps1 @@ -4349,8 +4349,12 @@ Write-Host "FFU build process completed at" $endTime # Calculate the total run time $runTime = $endTime - $startTime -# Format the runtime as minutes and seconds -$runTimeFormatted = 'Duration: {0:mm} min {0:ss} sec' -f $runTime +# Format the runtime with hours, minutes, and seconds +if ($runTime.TotalHours -ge 1) { + $runTimeFormatted = 'Duration: {0:hh} hr {0:mm} min {0:ss} sec' -f $runTime +} else { + $runTimeFormatted = 'Duration: {0:mm} min {0:ss} sec' -f $runTime +} if ($VerbosePreference -ne 'Continue'){ Write-Host $runTimeFormatted From 9c1fc59af95f3c3a41b6988245b5f8448644c1b9 Mon Sep 17 00:00:00 2001 From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com> Date: Fri, 6 Sep 2024 11:24:16 -0700 Subject: [PATCH 12/14] - Added new variables for the PPKG, Unattend, Autopilot, and PEDrivers validation --- FFUDevelopment/BuildFFUVM.ps1 | 19 ++++++++++++++++++- .../{prefixes.txt => SamplePrefixes.txt} | 0 2 files changed, 18 insertions(+), 1 deletion(-) rename FFUDevelopment/unattend/{prefixes.txt => SamplePrefixes.txt} (100%) diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1 index 49d0a5b..33fce48 100644 --- a/FFUDevelopment/BuildFFUVM.ps1 +++ b/FFUDevelopment/BuildFFUVM.ps1 @@ -375,6 +375,11 @@ if (-not $DefenderPath) { $DefenderPath = "$AppsPath\Defender" } if (-not $OneDrivePath) { $OneDrivePath = "$AppsPath\OneDrive" } 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 (-not $AutopilotFolder) { $AutopilotFolder = "$FFUDevelopmentPath\Autopilot" } +if (-not $PEDriversFolder) { $PEDriversFolder = "$FFUDevelopmentPath\PEDrivers" } + #FUNCTIONS function WriteLog($LogText) { @@ -3609,6 +3614,18 @@ if ($InstallDrivers -or $CopyDrivers) { } } } +#Validate PEDrivers folder +if ($CopyPEDrivers) { + WriteLog 'Doing PEDriver validation' + if (!(Test-Path -Path $PEDriversFolder)) { + WriteLog "-CopyPEDrivers is set to `$true, but the $PEDriversFolder folder is missing" + throw "-CopyPEDrivers is set to `$true, but the $PEDriversFolder folder is missing" + } + if ((Get-ChildItem -Path $PEDriversFolder -Recurse | Measure-Object -Property Length -Sum).Sum -lt 1MB) { + WriteLog "-CopyPEDrivers is set to `$true, but the $PEDriversFolder folder is empty" + throw "-CopyPEDrivers is set to `$true, but the $PEDriversFolder folder is empty" + } +} #Validate PPKG folder if ($CopyPPKG) { @@ -3806,7 +3823,7 @@ if ($InstallApps) { ###### 9/4/2024 - Windows Security Platform update is no longer available from Update Catalog. Will change to using ###### https://support.microsoft.com/en-us/topic/windows-security-update-a6ac7d2e-b1bf-44c0-a028-41720a242da3 - + # #Get Windows Security platform update # $Name = "Windows Security platform definition updates" # WriteLog "Searching for $Name from Microsoft Update Catalog and saving to $DefenderPath" diff --git a/FFUDevelopment/unattend/prefixes.txt b/FFUDevelopment/unattend/SamplePrefixes.txt similarity index 100% rename from FFUDevelopment/unattend/prefixes.txt rename to FFUDevelopment/unattend/SamplePrefixes.txt From 40616776ebe17429ee2cb2e7d9c57f003602b1e3 Mon Sep 17 00:00:00 2001 From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com> Date: Sat, 7 Sep 2024 09:53:14 -0700 Subject: [PATCH 13/14] - Added VMHostIPAddress and VMSwitchName validation to validate the IP address matches the VMSwitchName --- FFUDevelopment/BuildFFUVM.ps1 | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/FFUDevelopment/BuildFFUVM.ps1 b/FFUDevelopment/BuildFFUVM.ps1 index 98308f2..68cc570 100644 --- a/FFUDevelopment/BuildFFUVM.ps1 +++ b/FFUDevelopment/BuildFFUVM.ps1 @@ -3598,6 +3598,8 @@ Write-Host "To track progress, please open the log file $Logfile or use the -Ver WriteLog 'Begin Logging' +###PARAMETER VALIDATION + #Validate drivers folder if ($InstallDrivers -or $CopyDrivers) { WriteLog 'Doing driver validation' @@ -3612,6 +3614,7 @@ if ($InstallDrivers -or $CopyDrivers) { WriteLog "-InstallDrivers or -CopyDrivers is set to `$true, but the $DriversFolder folder is empty" throw "-InstallDrivers or -CopyDrivers is set to `$true, but the $DriversFolder folder is empty" } + WriteLog 'Driver validation complete' } } #Validate PEDrivers folder @@ -3625,6 +3628,7 @@ if ($CopyPEDrivers) { WriteLog "-CopyPEDrivers is set to `$true, but the $PEDriversFolder folder is empty" throw "-CopyPEDrivers is set to `$true, but the $PEDriversFolder folder is empty" } + WriteLog 'PEDriver validation complete' } #Validate PPKG folder @@ -3639,6 +3643,7 @@ if ($CopyPPKG) { WriteLog "-CopyPPKG is set to `$true, but the $PPKGFolder folder is missing a .PPKG file" throw "-CopyPPKG is set to `$true, but the $PPKGFolder folder is missing a .PPKG file" } + WriteLog 'PPKG validation complete' } #Validate Autopilot folder @@ -3653,6 +3658,7 @@ if ($CopyAutopilot) { WriteLog "-CopyAutopilot is set to `$true, but the $AutopilotFolder folder is missing a .JSON file" throw "-CopyAutopilot is set to `$true, but the $AutopilotFolder folder is missing a .JSON file" } + WriteLog 'Autopilot validation complete' } #Validate Unattend folder @@ -3667,6 +3673,7 @@ if ($CopyUnattend) { WriteLog "-CopyUnattend is set to `$true, but the $UnattendFolder folder is missing a .XML file" throw "-CopyUnattend is set to `$true, but the $UnattendFolder folder is missing a .XML file" } + WriteLog 'Unattend validation complete' } #Override $InstallApps value if using ESD to build FFU. This is due to a strange issue where building the FFU @@ -3688,6 +3695,25 @@ if (($InstallApps -and ($VMHostIPAddress -eq ''))) { throw "If variable InstallApps is set to `$true, VMHostIPAddress must also be set to capture the FFU. Please set -VMHostIPAddress and try again." } +if (($VMHostIPAddress) -and ($VMSwitchName)){ + WriteLog "Validating -VMSwitchName $VMSwitchName and -VMHostIPAddress $VMHostIPAddress" + #Check $VMSwitchName by using Get-VMSwitch + $VMSwitch = Get-VMSwitch -Name $VMSwitchName -ErrorAction SilentlyContinue + if (-not $VMSwitch) { + throw "-VMSwitchName $VMSwitchName not found. Please check the -VMSwitchName parameter and try again." + } + #Find the IP address of $VMSwitch and check if it matches $VMHostIPAddress + $interfaceAlias = "vEthernet ($VMSwitchName)" + $VMSwitchIPAddress = (Get-NetIPAddress -InterfaceAlias $interfaceAlias -AddressFamily 'IPv4' -ErrorAction SilentlyContinue).IPAddress + if (-not $VMSwitchIPAddress) { + throw "IP address for -VMSwitchName $VMSwitchName not found. Please check the -VMSwitchName parameter and try again." + } + if ($VMSwitchIPAddress -ne $VMHostIPAddress) { + throw "IP address for -VMSwitchName $VMSwitchName is $VMSwitchIPAddress, which does not match the -VMHostIPAddress $VMHostIPAddress. Please check the -VMHostIPAddress parameter and try again." + } + WriteLog '-VMSwitchName and -VMHostIPAddress validation complete' +} + if (-not ($ISOPath) -and ($OptionalFeatures -like '*netfx3*')) { throw "netfx3 specified as an optional feature, however Windows ISO isn't defined. Unable to get netfx3 source files from downloaded ESD media. Please specify a Windows ISO in the ISOPath parameter." } @@ -3714,10 +3740,8 @@ if (($WindowsArch -eq 'ARM64') -and ($UpdateOneDrive -eq $true)) { $UpdateOneDrive = $false WriteLog 'OneDrive currently fails to install on ARM64 VMs (even with the OneDrive ARM setup files). Setting UpdateOneDrive to false' } -# if(($WindowsArch -eq 'ARM64') -and ($UpdateLatestDefender -eq $true)){ -# $UpdateLatestDefender = $false -# WriteLog 'Defender ARM and x64 updates currently fail to install on ARM64 VMs. Setting UpdateLatestDefender to false' -# } + +###END PARAMETER VALIDATION #Get script variable values LogVariableValues @@ -4369,7 +4393,8 @@ $runTime = $endTime - $startTime # Format the runtime with hours, minutes, and seconds if ($runTime.TotalHours -ge 1) { $runTimeFormatted = 'Duration: {0:hh} hr {0:mm} min {0:ss} sec' -f $runTime -} else { +} +else { $runTimeFormatted = 'Duration: {0:mm} min {0:ss} sec' -f $runTime } From 198a544dbbc0999b4b7ee49b4467f75e2b14c1f0 Mon Sep 17 00:00:00 2001 From: rbalsleyMSFT <53497092+rbalsleyMSFT@users.noreply.github.com> Date: Tue, 10 Sep 2024 11:44:12 -0700 Subject: [PATCH 14/14] update change log --- ChangeLog.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ChangeLog.md b/ChangeLog.md index 614c856..b3b7e99 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,5 +1,21 @@ # Change Log +## **2409.1** + +### Fixes + +- Fix an issue with removal of Defender/OneDrive/Edge after FFU is complete +- Migrate Winget downloads to use [Export-WingetPackage cmdlet](https://github.com/microsoft/winget-cli/blob/master/doc/specs/%23658%20-%20WinGet%20Download.md#winget-powershell-cmdlet) as per issue #50 +- Add support for preview updates https://github.com/rbalsleyMSFT/FFU/pull/51 - thanks to @HedgeComp +- Refactor validation of Unattend/prefixes, PPKG, Autopilot to check for these files early in the process, similar to how we check for drivers +- Add better logging when unable to find HDD when applying FFU. Will inform to add WinPE drivers to Deployment Media if HDD not found. +- Remove ValidateScript on InstallDrivers and break it out in a validation block so -Make and -Model can be specified anywhere in the command line +- Add validation for VMHostIPAddress and VMSwtichName and inform the user if these don't match. Should prevent issues where the FFU isn't getting created. +- Removed installation of the Windows Security Platform Update as it has been removed from the MU Catalog. See issue #58 +- Thanks to w0 for PR #54 to change the validation set for WindowsSKU +- Thanks to @zehadialam for PR #60 to fix an issue with Windows boot loader for certain devices where Windows Boot Manager is not the first boot entry after the FFU is applied. +- Thanks to @HedgeComp for PR #64 and PR #65 + ## **2408.1** ### External Drive Support