mirror of
https://github.com/rbalsleyMSFT/FFU.git
synced 2026-06-14 02:09:35 -06:00
Compare commits
115 Commits
v2507.1Preview
...
2512.1
| Author | SHA1 | Date | |
|---|---|---|---|
| ceeabd1ebc | |||
| 15149ffa0b | |||
| 2f180747b7 | |||
| 25fe90253c | |||
| 86d122aacf | |||
| 9737d5c930 | |||
| c6088d91fa | |||
| 15fdf77ce4 | |||
| f7f001ac2e | |||
| 3524d02047 | |||
| 7948201e18 | |||
| 8d84137a27 | |||
| 2273cffbc2 | |||
| 63ef35a005 | |||
| 417be73b23 | |||
| 18367219c8 | |||
| 2a77cf1a02 | |||
| 41b0f7d742 | |||
| 24c81c234f | |||
| 4833d9f00d | |||
| 37e3497522 | |||
| 8229aa73fe | |||
| e67590d0a1 | |||
| 33f0608d84 | |||
| 3d1a586c73 | |||
| 7d36253668 | |||
| e076e9f4ca | |||
| 44aa4d3a32 | |||
| a1d08b6fa4 | |||
| fc4a71f7e1 | |||
| 9a59b9fea4 | |||
| 19081a2e1f | |||
| 3cb4003bcd | |||
| beb48e500e | |||
| 93c4679c46 | |||
| d6688def9d | |||
| 489d53f55c | |||
| 3deb8fb8d2 | |||
| 1af3a0f092 | |||
| de80ac551b | |||
| 89601efde0 | |||
| 235065322c | |||
| 11b3e120e2 | |||
| 667edf3724 | |||
| 4a10e27ddf | |||
| b4305a1edb | |||
| 7598ee96da | |||
| 6de7c861ed | |||
| 658c57e22c | |||
| 60cf1dab18 | |||
| 4ce9183bd3 | |||
| 7dd002396f | |||
| 1130a830c7 | |||
| 66a9026b8f | |||
| 458f1e517c | |||
| a13f9b481a | |||
| de70a22c42 | |||
| f3d3506e02 | |||
| 1daa14584a | |||
| 08c9214976 | |||
| c110dcd40e | |||
| eaa3e1e6af | |||
| 6562d16ce5 | |||
| 15a5b16b39 | |||
| d9c0c9c68e | |||
| d1ca123104 | |||
| f37647599a | |||
| cb14e84a26 | |||
| 8d7e4d1066 | |||
| c30ed923b6 | |||
| 50713188bf | |||
| e2ccd11f07 | |||
| f3316a017b | |||
| bdf1b63833 | |||
| 3ef26f2918 | |||
| 372360d739 | |||
| dc5877f398 | |||
| 49b2113fe1 | |||
| 556cfa1ee3 | |||
| 1ab4093d54 | |||
| bf27da5c66 | |||
| 1010b9fce7 | |||
| 3e34bd6bff | |||
| 3f892493c0 | |||
| 7d4567efbe | |||
| 9aed707a77 | |||
| 0c373e6b2c | |||
| a501b32a03 | |||
| 8ab6603999 | |||
| 85383f989a | |||
| 0423ac31d9 | |||
| 35f37f3a36 | |||
| 78d7bb9262 | |||
| 3c545be5c5 | |||
| c1983f75e6 | |||
| 7c3de6d77f | |||
| 17dc80f11b | |||
| 846d449aac | |||
| db9b7335f2 | |||
| 6f98473009 | |||
| 357261ec73 | |||
| 5bef901295 | |||
| 59e247c012 | |||
| a87c4796b5 | |||
| 4d289ee14a | |||
| 08feb7c9dd | |||
| 9cb06cb71e | |||
| 5ec607d94a | |||
| ac7ef119e0 | |||
| 03c8127bd3 | |||
| eb001e59b3 | |||
| 3e46d4b280 | |||
| eae07fcad0 | |||
| 41b65a76c1 | |||
| 67c992806f |
+215
@@ -1,5 +1,220 @@
|
||||
# Change Log
|
||||
|
||||
# 2512.1 UI Preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Refactored Cleanup logic into a shared module
|
||||
|
||||
Consolidates duplicated cleanup code by moving logic into a shared function, eliminating redundant implementations across multiple locations.
|
||||
|
||||
Removes standalone cleanup functions (Remove-FFU, Remove-Apps, Remove-Updates) and replaces scattered cleanup calls with a single invocation of Invoke-FFUPostBuildCleanup.
|
||||
|
||||
### Add 30 second delay to allow for Windows Security Platform to install
|
||||
|
||||
There was an issue where the Windows Security Platform would attempt to install in the VM during the build via `Update-Defender.ps1` however the install didn't always happen and on deployment of the FFU, Windows Update would show that the Windows Security Platform needed an update. I suspect this is related to the AppxSVC not being ready during Audit Mode. Adding a 30 second delay appears to work more reliably.
|
||||
|
||||
### Windows and .NET CU's now persist across builds
|
||||
|
||||
Content in the FFUDevelopment\KB folder was always deleted once it was used. Since the Windows CU is so large now, it doesn't make sense to delete it if a user wants it again and may not be using cached VHDX files.
|
||||
|
||||
Deletion of the KB folder is now correctly handled via the **Remove Downloaded Update Files** option on the Build tab.
|
||||
|
||||
### Skip CU downloads if the Windows ESD version is current or newer
|
||||
|
||||
Now that the Windows ESD media is kept up to date, there rarely will be a need to download the latest CU. There will always be a slight gap when the latest CU comes out and the updated media is available, but that's generally just a few days to a week.
|
||||
|
||||
The script will now do some parsing of the windows version of the ESD file and the latest CU and if the ESD is newer, the CU will not be downloaded.
|
||||
|
||||
### Fixes an issue with WingetWin32Apps.json file not being created if applications were pre-downloaded via the UI
|
||||
|
||||
Fixed a bug due to some code consolidation that broke scenarios where applications that were downloaded via the UI, but were not installing in the VM.
|
||||
|
||||
**Full Changelog**: https://github.com/rbalsleyMSFT/FFU/compare/v2511.1preview...v2511.2
|
||||
|
||||
# 2511.1 UI Preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
### Major changes to drivers
|
||||
|
||||
A few weeks ago I wrote a [lengthy post](https://github.com/rbalsleyMSFT/FFU/discussions/350) asking for some help testing some changes that were added.
|
||||
|
||||
The summary of that post is that there have been significant changes for both Dell and HP driver downloads to leverage the SystemID for each model. This increases the total number of driver models that are exposed in the UI. This also requires the `DriverMapping.json` to be modified to require the SystemID and query the SystemID from WMI when doing automatic matching.
|
||||
|
||||
#### Driver folder structure changes on the USB drive - breaking change
|
||||
|
||||
Driver folder structure on the USB drive has also changed. The new structure is `Drivers\Make\Model` (e.g. `D:\Drivers\Lenovo\Lenovo 300w`). This structure is consistent with how the UI and `BuildFFUVM.ps1` script download and store drivers and automatically copy them. So if you've been following that, then no changes are required.
|
||||
|
||||
Please read [the post](https://github.com/rbalsleyMSFT/FFU/discussions/350) for more details on these changes to drivers.
|
||||
|
||||
### Windows 11 25H2 is now the default option for MCT/ESD downloads
|
||||
|
||||
For MCT/ESD downloads: Adds dynamic products.cab download functionality for Windows 11 using Windows Update service API instead of static MCT links. This is due to a change in how the MCT pulls the products.cab file. In other words, the Windows 11 25H2 ESD media is now updated each month (usually shortly after patch Tuesday)
|
||||
|
||||
### Added 8 new hardware manufactures for automatic driver matching during deployment
|
||||
|
||||
Extends hardware detection and driver mapping capabilities to support Panasonic, Viglen, AZW, Fujitsu, Getac, ByteSpeed, and Intel devices when applying the FFU to a device. This does not mean FFU Builder supports downloading drivers from these manufacturers. You'll still need to download the drivers for them manually. You can now create your own `DriverMapping.json` file to include these manufacturers.
|
||||
|
||||
Thanks to @arwidmark and the [Modern Driver Management](https://msendpointmgr.com/modern-driver-management/) team for the WMI queries.
|
||||
|
||||
### Fixed an issue with long paths when applying drivers from USB
|
||||
|
||||
Implemented SUBST drive mappings to shorten driver file paths within WinPE as some paths were causing dism to error when servicing drivers. You should see a Z:\ drive when applying drivers from the USB drive.
|
||||
|
||||
### Added an option to skip driver selection when multiple driver models are detected during deployment
|
||||
|
||||
Allows users to bypass driver installation by entering 0 at the selection prompt, providing flexibility for deployments that don't require driver updates.
|
||||
|
||||
### Add HTTP fallback for BITS transfer network authentication errors
|
||||
|
||||
Fixes an issue with standard users elevating PowerShell as Admin and getting BITS errors when trying to download content.
|
||||
|
||||
### Add -BitsPriority script parameter
|
||||
|
||||
Introduces a new parameter `-BitsPriority` with options `(Foreground, High, Normal, Low)` to control BITS download priority across the build system and UI, allowing users to optimize transfer speeds when needed.
|
||||
|
||||
The feature adds a priority selector to the UI with four options (Foreground, High, Normal, Low) and propagates the selection through the build script and common modules. Priority can be set via UI or command-line parameter with Normal as the default.
|
||||
|
||||
### BYO Apps: Add MSI path quoting to handle spaces in msiexec arguments
|
||||
|
||||
When specifying Build Your Own Apps msiexec arguments, if there were spaces in the argument list that weren't quoted properly, you'd get an error. This should now automatically add missing spaces in case you forget to add them or there are spaces in your application name.
|
||||
|
||||
### Misc Fixes
|
||||
|
||||
* Fixed some reliability issues when trying to download Lenovo drivers
|
||||
* Fixed an issue with PPKG files with spaces
|
||||
* Replaced SerialNumber with UniqueID for USB drive identification when building USB drives. USB drive manufacturers may use the same serial number for different drives, potentially causing data loss if the wrong drive is chosen.
|
||||
* `-Threads` parameter has been added to `BuildFFUVM.ps1` which defaults to 5, matching the UI behavior. This value can be 1-64.
|
||||
* ESD media downloads now use BITS by default
|
||||
* Fixed an issue with multi-disk devices. Prior, if multiple disks were detected, ApplyFFU.ps1 would fail. Now a menu pops up asking the end user to select the disk they want to deploy the FFU to
|
||||
|
||||
## New Contributors
|
||||
|
||||
* @arwidmark made their first contribution in https://github.com/rbalsleyMSFT/FFU/pull/325
|
||||
|
||||
**Full Changelog**: https://github.com/rbalsleyMSFT/FFU/compare/v2509.1preview...v2511.1preview
|
||||
|
||||
# 2509.1 UI Preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
### [Refactor: Enhance artifact cleanup for disabled features](https://github.com/rbalsleyMSFT/FFU/commit/1ab4093d54b7d9bda9f47d7819694e66ae8de357)
|
||||
|
||||
Renames `Remove-DisabledUpdates` to `Remove-DisabledArtifacts` to better reflect its expanded scope.
|
||||
|
||||
This function now also removes Office installation scripts and downloaded content if the Office installation is disabled via the `$InstallOffice` flag.
|
||||
|
||||
The function call is moved to run before app installations to ensure artifacts are removed prior to the installation phase.
|
||||
|
||||
### [Removes the VM workaround for MCT ESD builds](https://github.com/rbalsleyMSFT/FFU/commit/dc5877f398316969299ee03800f3d07c7d98a9ab)
|
||||
|
||||
Comments out the logic that forces app installation when building from a downloaded ESD file. This workaround was implemented to prevent an OOBE reboot loop but is no longer required. This should speed up scenarios where you want to download the ESD media, install the latest CU and .NET CU, and capture the FFU.
|
||||
|
||||
### [Update default disk size to 50GB in FFU scripts and UI](https://github.com/rbalsleyMSFT/FFU/commit/372360d7392ad945be0db889a68e1fff0ed3b5d6)
|
||||
|
||||
Changed the default disk size parameter from 30GB to 50GB in BuildFFUVM.ps1 and FFUUI.Core.psm1 to accommodate larger virtual machines.
|
||||
Updated tooltip and default value in the UI XAML file to reflect the new disk size.
|
||||
|
||||
### [Adds auto-loading of previous configuration on startup](https://github.com/rbalsleyMSFT/FFU/commit/3ef26f2918977906ebe14e328f015ce4f1941dc3)
|
||||
|
||||
Implements a new feature to automatically load the previously saved environment when the UI is launched.
|
||||
|
||||
This improves user experience by restoring the last saved configuration, including selected applications and drivers, eliminating the need to manually reload them on each run.
|
||||
|
||||
The process loads the main `FFUConfig.json` and then proceeds to load associated Winget, BYO App, and Driver lists if they are defined. UI elements and checkboxes are updated accordingly to reflect the loaded state.
|
||||
|
||||
### [Improves UI state after environment autoload](https://github.com/rbalsleyMSFT/FFU/commit/bdf1b63833c83171aed63e8fc16702078ccd577b)
|
||||
|
||||
Updates the visibility of UI panels for Winget and drivers when a previous environment is automatically loaded.
|
||||
|
||||
This ensures that if Winget apps or driver models are present, their corresponding UI sections are made visible. Additionally, it updates the "select all" checkbox state for Winget results and attempts to pre-select the hardware make for loaded drivers.
|
||||
|
||||
### [Add restore defaults and centralize cleanup logic](https://github.com/rbalsleyMSFT/FFU/commit/f3316a017b73bf12cf1a66e3d03a63e29c437cb1)
|
||||
|
||||
Introduces a "Restore Defaults" feature in the UI to reset the environment. This action removes generated configuration files, ISOs, downloaded apps, updates, drivers, and FFUs.
|
||||
|
||||
The post-build cleanup logic is refactored from the main build script into a new common function. This new function is used by both the standard build process and the new restore defaults feature, promoting code reuse and simplifying maintenance.
|
||||
|
||||
### [Add option to dynamically build PE drivers](https://github.com/rbalsleyMSFT/FFU/commit/e2ccd11f07217b389f1622a69794224412e046e1)
|
||||
|
||||
Thanks to @JonasKloseBW for the original code for this in https://github.com/rbalsleyMSFT/FFU/pull/115
|
||||
|
||||
Introduces a new parameter, `UseDriversAsPEDrivers`, that allows WinPE drivers to be sourced directly from the main driver repository.
|
||||
|
||||
When enabled, the script scans all available drivers, parses their INF files, and copies only the essential driver types (e.g., storage, mouse, keyboard, touchpad, system devices) needed for WinPE. This eliminates the need to maintain a separate, manually curated `PEDrivers` folder.
|
||||
|
||||
The UI is updated with a new checkbox that becomes visible when "Copy PE Drivers" is selected, making this a sub-option. Parameter validation is also adjusted to support this new workflow.
|
||||
|
||||
### [Improve model name normalization for driver mapping](https://github.com/rbalsleyMSFT/FFU/commit/50713188bffcb64f1b0c1f9eb89e02a300e3de98)
|
||||
|
||||
Enhances the model name normalization function to better handle variations in hardware model strings. This change introduces specific rules to canonicalize "All-in-One" and screen size variants (e.g., "-in" or "inch") for more reliable matching against driver mapping rules.
|
||||
|
||||
Additionally, optimizes performance by normalizing the system model once before the comparison loop. Logging is also added to show the original and normalized model strings for easier debugging.
|
||||
|
||||
### [Defer cleanup of compressed driver source folders](https://github.com/rbalsleyMSFT/FFU/commit/c30ed923b68b933f719b9a2941043b813bf4fd3f)
|
||||
|
||||
Implements a deferred cleanup mechanism for driver source folders when they are compressed to a WIM and also used for WinPE.
|
||||
|
||||
When drivers are compressed, the original source folders are now preserved if they are also needed for WinPE driver injection. A marker file is created in these preserved folders.
|
||||
|
||||
A new cleanup step is added after the WinPE media creation to remove these preserved folders, ensuring they are available when needed but not left behind permanently.
|
||||
|
||||
### [Refactor config loading and improve error handling](https://github.com/rbalsleyMSFT/FFU/commit/8d7e4d106620761d0ae1a5133f6d6ba301131471)
|
||||
|
||||
Extracts the logic for importing supplemental assets (Winget, BYO, Drivers) into a new reusable function. This function is now called by both the manual and automatic configuration loaders, reducing code duplication.
|
||||
|
||||
Enhances the manual configuration loading process with more robust error handling. It now provides specific user-facing error messages for file read failures, empty files, and invalid JSON, improving the user experience when loading a malformed configuration.
|
||||
|
||||
When loading a configuration, if optional supplemental files like AppList.json are referenced but not found, an informational message is now displayed to the user instead of failing silently.
|
||||
|
||||
### [Add robust sanitization for names used in paths](https://github.com/rbalsleyMSFT/FFU/commit/cb14e84a26acaf5863aa3bb094dbf18424798875)
|
||||
|
||||
Introduces a new common function, `ConvertTo-SafeName`, to sanitize strings by removing characters that are invalid in Windows file paths.
|
||||
|
||||
This function is now used consistently when creating directory and file names for drivers (Dell, HP, Lenovo, Microsoft) and applications to prevent path-related errors. It replaces several ad-hoc sanitization methods with a single, more robust implementation.
|
||||
|
||||
### [Includes exit code fields when using Copy Apps button](https://github.com/rbalsleyMSFT/FFU/commit/f37647599a318da29b62154bebff8c8a857d3002)
|
||||
|
||||
Adds persistence of AdditionalExitCodes and IgnoreNonZeroExitCodes when exporting the UI list to prevent losing custom exit handling settings and maintain parity with the primary save routine.
|
||||
|
||||
### [Sanitizes app names for storage and paths](https://github.com/rbalsleyMSFT/FFU/commit/d1ca1231045e38316733495e1fdb8590a225be67)
|
||||
|
||||
Applies name sanitization when persisting the app list and when building/checking Win32 and Store download directories.
|
||||
Prevents invalid characters in folder names, aligns persisted names with on-disk structure, and improves detection of existing content to avoid redundant downloads and errors.
|
||||
|
||||
### [Adds exit-code overrides and UI for winget apps](https://github.com/rbalsleyMSFT/FFU/commit/d9c0c9c68ee1769230c9789b5c7cb84bcff4d642)
|
||||
|
||||
Adds per-app control for additional accepted exit codes and ignoring non‑zero exit codes to improve handling of installers with nonstandard returns.
|
||||
|
||||
Exposes editable fields in the app list UI, persists them across search defaults, import/export, and pre-download save, and applies overrides during app resolution to honor configured behavior.
|
||||
|
||||
### [Adds UI/CLI to copy additional FFUs to USB build](https://github.com/rbalsleyMSFT/FFU/commit/15a5b16b39887b71ae545c638d57183c97bdf629)
|
||||
|
||||
- Enables selecting multiple existing FFU images to include on the deployment USB for easier distribution and testing.
|
||||
- Adds a UI option with selectable, sortable list from the capture folder, refresh support, and persisted selections.
|
||||
- Validates that selections exist when the option is enabled to prevent empty runs.
|
||||
- Supports unattended/CLI flows by prompting early or accepting a preselected list for USB creation; deduplicates and logs chosen files.
|
||||
- Always includes the just-built (or latest available) FFU as a base.
|
||||
- Improves no-FFU handling and streamlines multi-FFU selection workflow.
|
||||
|
||||
### [Standardizes JSON output: depth, UTF-8, key order](https://github.com/rbalsleyMSFT/FFU/commit/6562d16ce500197b428b51915332c6649df302df)
|
||||
|
||||
- Sorts top-level config keys before serialization for deterministic files and cleaner diffs.
|
||||
- Increases JSON depth to 10 to retain nested settings.
|
||||
- Writes JSON as UTF-8 via Set-Content for consistent encoding.
|
||||
- Applies across config export and UI save flows.
|
||||
|
||||
### [Adds Windows 11 25H2 mapping](https://github.com/rbalsleyMSFT/FFU/commit/eaa3e1e6af5c25e0f8b185f8107e017782b0f00f)
|
||||
|
||||
Extends supported Windows 11 releases to include 25H2. Default is still 24H2.
|
||||
|
||||
* Update USBImagingToolCreator.ps1 by @jrollmann in https://github.com/rbalsleyMSFT/FFU/pull/262
|
||||
|
||||
## New Contributors
|
||||
|
||||
* @jrollmann made their first contribution in https://github.com/rbalsleyMSFT/FFU/pull/262
|
||||
|
||||
# 2507.1 UI Preview
|
||||
|
||||
Waaay too many to list. Just watch the Youtube video in the Readme :)
|
||||
|
||||
@@ -2,16 +2,6 @@
|
||||
<Add SourcePath="C:\FFUDevelopment\Apps\Office" OfficeClientEdition="64" Channel="Current">
|
||||
<Product ID="O365ProPlusRetail">
|
||||
<Language ID="MatchOS" />
|
||||
<ExcludeApp ID="Access" />
|
||||
<ExcludeApp ID="Lync" />
|
||||
<ExcludeApp ID="Publisher" />
|
||||
<ExcludeApp ID="Bing" />
|
||||
</Product>
|
||||
</Add>
|
||||
<Property Name="SharedComputerLicensing" Value="0" />
|
||||
<Property Name="FORCEAPPSHUTDOWN" Value="FALSE" />
|
||||
<Property Name="DeviceBasedLicensing" Value="0" />
|
||||
<Property Name="SCLCacheOverride" Value="0" />
|
||||
<Updates Enabled="TRUE" />
|
||||
<Display Level="None" AcceptEULA="TRUE" />
|
||||
</Configuration>
|
||||
@@ -7,47 +7,81 @@ function Invoke-Process {
|
||||
[string]$FilePath,
|
||||
|
||||
[Parameter()]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[string[]]$ArgumentList,
|
||||
|
||||
[Parameter()]
|
||||
[ValidateNotNullOrEmpty()]
|
||||
[bool]$Wait = $true
|
||||
[bool]$Wait = $true,
|
||||
|
||||
[Parameter()]
|
||||
[string[]]$AdditionalSuccessCodes,
|
||||
|
||||
[Parameter()]
|
||||
[bool]$IgnoreNonZeroExitCodes = $false
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
try {
|
||||
$stdOutTempFile = "$env:TEMP\$((New-Guid).Guid)"
|
||||
$stdErrTempFile = "$env:TEMP\$((New-Guid).Guid)"
|
||||
|
||||
$startProcessParams = @{
|
||||
FilePath = $FilePath
|
||||
ArgumentList = $ArgumentList
|
||||
RedirectStandardError = $stdErrTempFile
|
||||
RedirectStandardOutput = $stdOutTempFile
|
||||
Wait = $($Wait);
|
||||
PassThru = $true;
|
||||
NoNewWindow = $true;
|
||||
}
|
||||
if ($PSCmdlet.ShouldProcess("Process [$($FilePath)]", "Run with args: [$($ArgumentList)]")) {
|
||||
$cmd = Start-Process @startProcessParams
|
||||
$cmdOutput = Get-Content -Path $stdOutTempFile -Raw
|
||||
$cmdError = Get-Content -Path $stdErrTempFile -Raw
|
||||
if ($cmd.ExitCode -ne 0 -and $wait -eq $true) {
|
||||
if ($cmdError) {
|
||||
throw $cmdError.Trim()
|
||||
# Use .NET Process class for proper stream handling
|
||||
$pinfo = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$pinfo.FileName = $FilePath
|
||||
if ($ArgumentList) {
|
||||
$pinfo.Arguments = $ArgumentList -join ' '
|
||||
}
|
||||
$pinfo.RedirectStandardOutput = $true
|
||||
$pinfo.RedirectStandardError = $true
|
||||
$pinfo.UseShellExecute = $false
|
||||
$pinfo.CreateNoWindow = $true
|
||||
|
||||
$p = New-Object System.Diagnostics.Process
|
||||
$p.StartInfo = $pinfo
|
||||
|
||||
# Start the process
|
||||
$p.Start() | Out-Null
|
||||
|
||||
# Read output and error streams
|
||||
$cmdOutput = $p.StandardOutput.ReadToEnd()
|
||||
$cmdError = $p.StandardError.ReadToEnd()
|
||||
|
||||
if ($Wait) {
|
||||
$p.WaitForExit()
|
||||
}
|
||||
|
||||
$exitCode = $p.ExitCode
|
||||
# An exit code of 0 is always a success
|
||||
if ($exitCode -ne 0) {
|
||||
# If IgnoreNonZeroExitCodes is true, treat any non-zero exit code as a success
|
||||
if ($IgnoreNonZeroExitCodes) {
|
||||
Write-Host "Ignoring non-zero exit code $exitCode because IgnoreNonZeroExitCodes is set to true."
|
||||
}
|
||||
if ($cmdOutput) {
|
||||
throw $cmdOutput.Trim()
|
||||
# Check if the non-zero exit code is in the list of additional success codes
|
||||
elseif ($null -eq $AdditionalSuccessCodes -or $exitCode -notin $AdditionalSuccessCodes) {
|
||||
if ($cmdError) {
|
||||
throw $cmdError.Trim()
|
||||
}
|
||||
if ($cmdOutput) {
|
||||
throw $cmdOutput.Trim()
|
||||
}
|
||||
# If there's no output, throw a generic error with the exit code
|
||||
if (-not $cmdError -and -not $cmdOutput) {
|
||||
throw "Process exited with non-zero code: $exitCode"
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) {
|
||||
# WriteLog $cmdOutput
|
||||
Write-Host $cmdOutput
|
||||
}
|
||||
}
|
||||
|
||||
# Create a simple object with exit code for compatibility
|
||||
$result = [PSCustomObject]@{
|
||||
ExitCode = $exitCode
|
||||
}
|
||||
|
||||
return $result
|
||||
}
|
||||
}
|
||||
catch {
|
||||
@@ -55,12 +89,50 @@ function Invoke-Process {
|
||||
# WriteLog $_
|
||||
# Write-Host "Script failed - $Logfile for more info"
|
||||
throw $_
|
||||
}
|
||||
}
|
||||
|
||||
function Format-MsiArguments {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Ensures MSI file paths in msiexec arguments are properly quoted.
|
||||
.DESCRIPTION
|
||||
Detects /i arguments followed by an unquoted path ending in .msi
|
||||
and wraps the path in double quotes to handle paths with spaces.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$CommandLine,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Arguments
|
||||
)
|
||||
|
||||
# Only process if the command is msiexec
|
||||
if ($CommandLine -notmatch '^msiexec(\.exe)?$') {
|
||||
return $Arguments
|
||||
}
|
||||
finally {
|
||||
Remove-Item -Path $stdOutTempFile, $stdErrTempFile -Force -ErrorAction Ignore
|
||||
|
||||
# Regex pattern explanation:
|
||||
# (?i) - Case-insensitive matching
|
||||
# (/i)\s+ - Match /i followed by whitespace
|
||||
# (?!") - Negative lookahead: not already quoted
|
||||
# (.+?\.msi) - Capture path ending in .msi (lazy match to stop at first .msi)
|
||||
# (?=\s+/|\s*$) - Followed by another switch or end of string
|
||||
|
||||
# Pattern to match /i followed by an unquoted MSI path
|
||||
$pattern = '(?i)(/i)\s+(?!")(.+?\.msi)(?=\s+/|\s*$)'
|
||||
|
||||
if ($Arguments -match $pattern) {
|
||||
$originalArgs = $Arguments
|
||||
# Replace with quoted path
|
||||
$Arguments = $Arguments -replace $pattern, '$1 "$2"'
|
||||
Write-Host "Detected unquoted MSI path in msiexec arguments. Adjusted arguments:"
|
||||
Write-Host "Original: $originalArgs"
|
||||
Write-Host "Modified: $Arguments"
|
||||
}
|
||||
return $cmd
|
||||
|
||||
return $Arguments
|
||||
}
|
||||
|
||||
function Install-Applications {
|
||||
@@ -110,15 +182,67 @@ function Install-Applications {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
# Construct the argument list properly, handling potential array vs string
|
||||
$argumentsToPass = if ($app.Arguments -is [array]) { $app.Arguments } else { @($app.Arguments) }
|
||||
# Check for 'PAUSE' command
|
||||
if ($app.CommandLine -eq 'PAUSE') {
|
||||
Write-Host "Pausing script as requested by '$($app.Name)'. Press Enter to continue..."
|
||||
$null = Read-Host
|
||||
continue
|
||||
}
|
||||
|
||||
Write-Host "Running command: $($app.CommandLine) $($argumentsToPass -join ' ')"
|
||||
$result = Invoke-Process -FilePath $($app.CommandLine) -ArgumentList $argumentsToPass
|
||||
try {
|
||||
# Normalize arguments: treat null/empty/whitespace as no arguments
|
||||
$argumentsToPass = $null
|
||||
if ($null -ne $app.Arguments) {
|
||||
if ($app.Arguments -is [array]) {
|
||||
$trimmed = $app.Arguments | ForEach-Object { ($_ | ForEach-Object { if ($_ -ne $null) { $_.ToString().Trim() } else { $_ } }) } | Where-Object { $_ -and (-not [string]::IsNullOrWhiteSpace($_)) }
|
||||
if ($trimmed.Count -gt 0) {
|
||||
$argumentsToPass = $trimmed
|
||||
}
|
||||
}
|
||||
else {
|
||||
$single = $app.Arguments.ToString().Trim()
|
||||
if (-not [string]::IsNullOrWhiteSpace($single)) {
|
||||
$argumentsToPass = @($single)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Check for and parse AdditionalExitCodes
|
||||
$additionalSuccessCodes = @()
|
||||
if ($app.PSObject.Properties['AdditionalExitCodes'] -and -not [string]::IsNullOrWhiteSpace($app.AdditionalExitCodes)) {
|
||||
$additionalSuccessCodes = $app.AdditionalExitCodes -split ',' | ForEach-Object { $_.Trim() }
|
||||
Write-Host "Additional success exit codes for $($app.Name): $($additionalSuccessCodes -join ', ')"
|
||||
}
|
||||
|
||||
# Check for IgnoreNonZeroExitCodes
|
||||
$ignoreNonZeroExitCodes = $false
|
||||
if ($app.PSObject.Properties['IgnoreNonZeroExitCodes'] -and $app.IgnoreNonZeroExitCodes -is [bool]) {
|
||||
$ignoreNonZeroExitCodes = $app.IgnoreNonZeroExitCodes
|
||||
}
|
||||
|
||||
# Auto-quote MSI paths if using msiexec and path contains spaces but no quotes
|
||||
if ($null -ne $argumentsToPass -and $argumentsToPass.Count -gt 0) {
|
||||
$joinedArgs = $argumentsToPass -join ' '
|
||||
$formattedArgs = Format-MsiArguments -CommandLine $app.CommandLine -Arguments $joinedArgs
|
||||
if ($formattedArgs -ne $joinedArgs) {
|
||||
$argumentsToPass = @($formattedArgs)
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $argumentsToPass -or $argumentsToPass.Count -eq 0) {
|
||||
Write-Host "Running command: $($app.CommandLine) (no arguments)"
|
||||
$result = Invoke-Process -FilePath $app.CommandLine -AdditionalSuccessCodes $additionalSuccessCodes -IgnoreNonZeroExitCodes $ignoreNonZeroExitCodes
|
||||
}
|
||||
else {
|
||||
Write-Host "Running command: $($app.CommandLine) $($argumentsToPass -join ' ')"
|
||||
$result = Invoke-Process -FilePath $app.CommandLine -ArgumentList $argumentsToPass -AdditionalSuccessCodes $additionalSuccessCodes -IgnoreNonZeroExitCodes $ignoreNonZeroExitCodes
|
||||
}
|
||||
Write-Host "$($app.Name) exited with exit code: $($result.ExitCode)`r`n"
|
||||
} catch {
|
||||
}
|
||||
catch {
|
||||
Write-Error "Error occurred while installing $($app.Name): $_"
|
||||
Read-Host "An error occurred, and the script cannot continue. Press Enter to exit."
|
||||
throw $_
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,16 +264,20 @@ if (Test-Path -Path $wingetAppsJsonFile) {
|
||||
if ($wingetContent -is [array]) {
|
||||
$wingetApps = $wingetContent
|
||||
Write-Host "Found $(($wingetApps | Measure-Object).Count) WinGet Win32 apps."
|
||||
} elseif ($wingetContent) {
|
||||
}
|
||||
elseif ($wingetContent) {
|
||||
$wingetApps = @($wingetContent) # Ensure it's an array
|
||||
Write-Host "Found 1 WinGet Win32 app."
|
||||
} else {
|
||||
Write-Host "WinGetWin32Apps.json is empty or invalid."
|
||||
}
|
||||
} catch {
|
||||
else {
|
||||
Write-Host "WinGetWin32Apps.json is empty or invalid."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to read or parse WinGetWin32Apps.json file: $_"
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
Write-Host "WinGetWin32Apps.json file not found. Skipping."
|
||||
}
|
||||
|
||||
@@ -166,16 +294,20 @@ if (Test-Path -Path $userAppsJsonFile) {
|
||||
if ($userContent -is [array]) {
|
||||
$userApps = $userContent
|
||||
Write-Host "Found $(($userApps | Measure-Object).Count) user-defined apps."
|
||||
} elseif ($userContent) {
|
||||
}
|
||||
elseif ($userContent) {
|
||||
$userApps = @($userContent) # Ensure it's an array
|
||||
Write-Host "Found 1 user-defined app."
|
||||
} else {
|
||||
Write-Host "UserAppList.json is empty or invalid."
|
||||
}
|
||||
} catch {
|
||||
else {
|
||||
Write-Host "UserAppList.json is empty or invalid."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to read or parse UserAppList.json file: $_"
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
Write-Host "UserAppList.json file not found. Skipping."
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@ else {
|
||||
|
||||
# Example of how to use the AppsScriptVariables hashtable to control script execution
|
||||
|
||||
# Note: The UI saves the values as strings, so if you type true for a value, it'll save to the config file as a string, not boolean
|
||||
|
||||
# Example: Check if a variable named 'foo' is set to string 'bar' and run a script accordingly
|
||||
# if ($AppsScriptVariables['foo'] -eq 'bar') {
|
||||
# Write-Host "Foo would have installed"
|
||||
@@ -46,8 +48,8 @@ else {
|
||||
# Write-Host "Foo would not have installed"
|
||||
# }
|
||||
|
||||
# Example: Check if a variable named 'foo' is set to boolean $true and run a script accordingly
|
||||
# if ($AppsScriptVariables[Teams] -eq $true) {
|
||||
# Example: Check if a variable named 'Teams' is set to string 'true' and run a script accordingly
|
||||
# if ($AppsScriptVariables['Teams'] -eq 'true') {
|
||||
# Write-Host "Teams would have been installed"
|
||||
# }
|
||||
# else {
|
||||
@@ -55,5 +57,4 @@ else {
|
||||
# }
|
||||
|
||||
# Your code below here
|
||||
|
||||
Write-Host 'Invoke-AppsScript.ps1 finished'
|
||||
@@ -1,14 +1,90 @@
|
||||
#The below lines will remove the unattend.xml that gets the machine into audit mode. If not removed, the OS will get stuck booting to audit mode each time.
|
||||
#Also kills the sysprep process in order to automate sysprep generalize
|
||||
# Convert these commands to native powershell
|
||||
# del c:\windows\panther\unattend\unattend.xml /F /Q
|
||||
# del c:\windows\panther\unattend.xml /F /Q
|
||||
# taskkill /IM sysprep.exe
|
||||
# timeout /t 10
|
||||
# & c:\windows\system32\sysprep\sysprep.exe /quiet /generalize /oobe
|
||||
|
||||
Write-Host "Removing existing unattend.xml files and stopping sysprep process if running..."
|
||||
Remove-Item -Path "C:\windows\panther\unattend\unattend.xml" -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path "C:\windows\panther\unattend.xml" -Force -ErrorAction SilentlyContinue
|
||||
Stop-Process -Name "sysprep" -Force -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 10
|
||||
& "C:\windows\system32\sysprep\sysprep.exe" /quiet /generalize /oobe
|
||||
|
||||
# Detect and remediate per-user, non-provisioned Appx packages that would block Sysprep.
|
||||
Write-Host "Checking for per-user Appx packages not provisioned for all users (potential Sysprep blockers)..."
|
||||
|
||||
# Build hash set of provisioned package families (DisplayName_PublisherId).
|
||||
$provFamilies = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
|
||||
Get-AppxProvisionedPackage -Online | ForEach-Object {
|
||||
$family = '{0}_{1}' -f $_.DisplayName, $_.PublisherId
|
||||
[void]$provFamilies.Add($family)
|
||||
}
|
||||
|
||||
# Collect current user Appx packages excluding frameworks, resource packs, and non-removable packages.
|
||||
$userApps = Get-AppxPackage -User $env:USERNAME | Where-Object {
|
||||
$_.Status -eq 'Ok' -and
|
||||
-not $_.IsFramework -and
|
||||
-not $_.IsResourcePackage -and
|
||||
-not $_.NonRemovable
|
||||
}
|
||||
|
||||
# Identify packages not provisioned (per-user only).
|
||||
$notProvisioned = foreach ($pkg in $userApps) {
|
||||
if (-not $provFamilies.Contains($pkg.PackageFamilyName)) {
|
||||
[PSCustomObject]@{
|
||||
Name = $pkg.Name
|
||||
PackageFamilyName = $pkg.PackageFamilyName
|
||||
Version = $pkg.Version
|
||||
SignatureKind = $pkg.SignatureKind
|
||||
PackageFullName = $pkg.PackageFullName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($notProvisioned) {
|
||||
Write-Host "Found $($notProvisioned.Count) per-user Appx package(s) not provisioned for all users:"
|
||||
$notProvisioned | Sort-Object PackageFamilyName | Format-Table -AutoSize -Property Name,PackageFamilyName,Version
|
||||
Write-Host "Attempting removal of per-user, non-provisioned Appx packages..."
|
||||
foreach ($pkg in $notProvisioned) {
|
||||
try {
|
||||
Write-Host "Removing $($pkg.PackageFullName)..."
|
||||
Remove-AppxPackage -Package $pkg.PackageFullName -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
Write-Warning "Failed to remove $($pkg.PackageFullName): $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Re-check after attempted removals.
|
||||
$remaining = @()
|
||||
$currentUserApps = Get-AppxPackage -User $env:USERNAME | Where-Object {
|
||||
$_.Status -eq 'Ok' -and
|
||||
-not $_.IsFramework -and
|
||||
-not $_.IsResourcePackage -and
|
||||
-not $_.NonRemovable
|
||||
}
|
||||
foreach ($pkg in $currentUserApps) {
|
||||
if (-not $provFamilies.Contains($pkg.PackageFamilyName)) {
|
||||
$remaining += $pkg
|
||||
}
|
||||
}
|
||||
|
||||
if ($remaining.Count -gt 0) {
|
||||
Write-Error "Unable to remove all per-user, non-provisioned Appx packages. Sysprep cannot continue."
|
||||
$remaining | Sort-Object PackageFamilyName | Format-Table -AutoSize -Property Name,PackageFamilyName,Version
|
||||
throw "Sysprep aborted due to unresolved per-user Appx packages. Resolve manually and re-run."
|
||||
}
|
||||
else {
|
||||
Write-Host "All per-user, non-provisioned Appx packages were successfully removed."
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Host "No per-user, non-provisioned Appx packages detected."
|
||||
}
|
||||
|
||||
# If an Unattend.xml has been provided on the mounted Apps ISO (D:\Unattend\Unattend.xml),
|
||||
# pass it to sysprep; otherwise, run without /unattend.
|
||||
$unattendOnAppsIso = "D:\Unattend\Unattend.xml"
|
||||
if (Test-Path -Path $unattendOnAppsIso) {
|
||||
Write-Host "Using $unattendOnAppsIso from Apps ISO..."
|
||||
& "C:\windows\system32\sysprep\sysprep.exe" /quiet /generalize /oobe /unattend:$unattendOnAppsIso
|
||||
}
|
||||
else {
|
||||
& "C:\windows\system32\sysprep\sysprep.exe" /quiet /generalize /oobe
|
||||
}
|
||||
|
||||
+1818
-691
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@
|
||||
|
||||
This script acts as the primary host for the UI, connecting the user interface with the underlying build and logic modules.
|
||||
#>
|
||||
#Requires -RunAsAdministrator
|
||||
|
||||
[CmdletBinding()]
|
||||
[System.STAThread()]
|
||||
@@ -43,14 +44,17 @@ $script:uiState = [PSCustomObject]@{
|
||||
vmSwitchMap = @{};
|
||||
logData = $null;
|
||||
logStreamReader = $null;
|
||||
pollTimer = $null
|
||||
pollTimer = $null;
|
||||
lastConfigFilePath = $null
|
||||
};
|
||||
Flags = @{
|
||||
installAppsForcedByUpdates = $false;
|
||||
prevInstallAppsStateBeforeUpdates = $null;
|
||||
installAppsCheckedByOffice = $false;
|
||||
lastSortProperty = $null;
|
||||
lastSortAscending = $true
|
||||
lastSortAscending = $true;
|
||||
isBuilding = $false;
|
||||
isCleanupRunning = $false
|
||||
};
|
||||
Defaults = @{};
|
||||
LogFilePath = "$FFUDevelopmentPath\FFUDevelopment_UI.log"
|
||||
@@ -123,6 +127,14 @@ $window.Add_Loaded({
|
||||
Initialize-UIDefaults -State $script:uiState
|
||||
Initialize-DynamicUIElements -State $script:uiState
|
||||
Register-EventHandlers -State $script:uiState
|
||||
|
||||
# Attempt automatic load of previous environment (silent)
|
||||
try {
|
||||
Invoke-AutoLoadPreviousEnvironment -State $script:uiState
|
||||
}
|
||||
catch {
|
||||
WriteLog "Auto-load previous environment failed: $($_.Exception.Message)"
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -132,7 +144,245 @@ $script:uiState.Controls.btnRun.Add_Click({
|
||||
# Get a local reference to the button for convenience in this handler
|
||||
$btnRun = $script:uiState.Controls.btnRun
|
||||
try {
|
||||
# Disable button to prevent multiple clicks
|
||||
# If a build is running and cleanup is not already running, treat this click as Cancel
|
||||
if ($script:uiState.Flags.isBuilding -and -not $script:uiState.Flags.isCleanupRunning) {
|
||||
$btnRun.IsEnabled = $false
|
||||
$script:uiState.Controls.txtStatus.Text = "Cancel requested. Stopping build..."
|
||||
WriteLog "Cancel requested by user. Stopping background build job."
|
||||
|
||||
# Stop the timer
|
||||
if ($null -ne $script:uiState.Data.pollTimer) {
|
||||
$script:uiState.Data.pollTimer.Stop()
|
||||
$script:uiState.Data.pollTimer = $null
|
||||
}
|
||||
|
||||
# Close the log stream
|
||||
if ($null -ne $script:uiState.Data.logStreamReader) {
|
||||
$script:uiState.Data.logStreamReader.Close()
|
||||
$script:uiState.Data.logStreamReader.Dispose()
|
||||
$script:uiState.Data.logStreamReader = $null
|
||||
}
|
||||
|
||||
# Stop and remove the running build job
|
||||
$jobToStop = $script:uiState.Data.currentBuildJob
|
||||
$script:uiState.Data.currentBuildJob = $null
|
||||
if ($null -ne $jobToStop) {
|
||||
try {
|
||||
# Attempt graceful stop first
|
||||
Stop-Job -Job $jobToStop -ErrorAction SilentlyContinue
|
||||
Wait-Job -Job $jobToStop -Timeout 5 -ErrorAction SilentlyContinue | Out-Null
|
||||
}
|
||||
catch {
|
||||
WriteLog "Stop-Job threw: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# If the job's hosting process is still alive, kill its process tree to stop child tools like DISM
|
||||
try {
|
||||
$jobProcId = $null
|
||||
if ($null -ne $jobToStop.ChildJobs -and $jobToStop.ChildJobs.Count -gt 0) {
|
||||
$jobProcId = $jobToStop.ChildJobs[0].ProcessId
|
||||
}
|
||||
if ($jobProcId) {
|
||||
# Recursively terminate the job process and any children
|
||||
function Stop-ProcessTree {
|
||||
param([int]$parentPid)
|
||||
$children = Get-CimInstance Win32_Process -Filter "ParentProcessId=$parentPid" -ErrorAction SilentlyContinue
|
||||
foreach ($child in $children) {
|
||||
Stop-ProcessTree -parentPid $child.ProcessId
|
||||
}
|
||||
try { Stop-Process -Id $parentPid -Force -ErrorAction SilentlyContinue } catch {}
|
||||
}
|
||||
Stop-ProcessTree -parentPid $jobProcId
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error terminating job process tree: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Safety net: kill any active DISM capture still running
|
||||
try {
|
||||
$dismCaptures = Get-CimInstance Win32_Process -Filter "Name='DISM.EXE'" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match '/Capture-FFU' }
|
||||
foreach ($p in $dismCaptures) {
|
||||
try { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } catch {}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error stopping DISM capture processes: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Also stop Office ODT setup.exe if running (to avoid recreating files after cleanup)
|
||||
try {
|
||||
$officePathForKill = $null
|
||||
|
||||
# Prefer explicit UI path
|
||||
$uiOfficePath = $script:uiState.Controls.txtOfficePath.Text
|
||||
if (-not [string]::IsNullOrWhiteSpace($uiOfficePath)) {
|
||||
$officePathForKill = $uiOfficePath
|
||||
}
|
||||
else {
|
||||
# Fall back to the last config path only if known
|
||||
$lastConfigPathLocal = $script:uiState.Data.lastConfigFilePath
|
||||
if (-not [string]::IsNullOrWhiteSpace($lastConfigPathLocal)) {
|
||||
$ffuDevRoot = Split-Path (Split-Path $lastConfigPathLocal -Parent) -Parent
|
||||
if (-not [string]::IsNullOrWhiteSpace($ffuDevRoot)) {
|
||||
$officePathForKill = Join-Path $ffuDevRoot 'Apps\Office'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Only proceed when a valid Office folder exists
|
||||
if ($officePathForKill -and (Test-Path -LiteralPath $officePathForKill -PathType Container)) {
|
||||
$setupProcs = Get-CimInstance Win32_Process -Filter "Name='setup.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.ExecutablePath -like "$officePathForKill*" }
|
||||
foreach ($p in $setupProcs) {
|
||||
try { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error stopping Office setup.exe processes: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
try {
|
||||
Remove-Job -Job $jobToStop -Force -ErrorAction SilentlyContinue
|
||||
WriteLog "Background build job stopped and removed."
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error removing background build job: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Start cleanup using the same BuildFFUVM.ps1 via -Cleanup short-circuit
|
||||
$lastConfigPath = $script:uiState.Data.lastConfigFilePath
|
||||
if ([string]::IsNullOrWhiteSpace($lastConfigPath)) {
|
||||
WriteLog "No stored config file path found. Cleanup cannot proceed."
|
||||
$script:uiState.Controls.txtStatus.Text = "Build canceled. No config found for cleanup."
|
||||
$script:uiState.Flags.isBuilding = $false
|
||||
$script:uiState.Flags.isCleanupRunning = $false
|
||||
$btnRun.Content = "Build FFU"
|
||||
$btnRun.IsEnabled = $true
|
||||
return
|
||||
}
|
||||
|
||||
$ffuDevPath = Split-Path (Split-Path $lastConfigPath -Parent) -Parent
|
||||
$mainLogPath = Join-Path $ffuDevPath "FFUDevelopment.log"
|
||||
|
||||
WriteLog "Starting cleanup without deleting FFUDevelopment.log (will append new entries)."
|
||||
|
||||
$script:uiState.Controls.txtStatus.Text = "Cancel in progress... Cleaning environment..."
|
||||
WriteLog "Starting cleanup job (BuildFFUVM.ps1 -Cleanup)."
|
||||
|
||||
# Prepare parameters for cleanup
|
||||
# Inform user: in-progress items will be removed; ask whether to also remove other items downloaded during this run
|
||||
$removeCurrentRunToo = $false
|
||||
$promptText = "Cancel requested.`n`nWe'll remove the download currently in progress to avoid partial/corrupt content.`n`nDo you also want to remove other items downloaded during this run? Previously downloaded items will be kept."
|
||||
$result = [System.Windows.MessageBox]::Show($promptText, "Cancel cleanup options", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question)
|
||||
if ($result -eq [System.Windows.MessageBoxResult]::Yes) { $removeCurrentRunToo = $true }
|
||||
|
||||
$cleanupParams = @{
|
||||
ConfigFile = $lastConfigPath
|
||||
Cleanup = $true
|
||||
# Avoid wiping all user content on cancel
|
||||
RemoveApps = $false
|
||||
RemoveUpdates = $false
|
||||
CleanupDrivers = $false
|
||||
# Scoped removal to current run only (optional per user choice)
|
||||
CleanupCurrentRunDownloads = $removeCurrentRunToo
|
||||
}
|
||||
|
||||
$cleanupScriptBlock = {
|
||||
param($buildParams, $PSScriptRoot)
|
||||
& "$PSScriptRoot\BuildFFUVM.ps1" @buildParams
|
||||
}
|
||||
|
||||
# Start cleanup job
|
||||
$script:uiState.Data.currentBuildJob = Start-Job -ScriptBlock $cleanupScriptBlock -ArgumentList @($cleanupParams, $PSScriptRoot)
|
||||
|
||||
# Wait for log file to appear (or open immediately if it exists)
|
||||
$logWaitTimeout = 60
|
||||
$watch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
while (-not (Test-Path $mainLogPath) -and $watch.Elapsed.TotalSeconds -lt $logWaitTimeout) {
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
$watch.Stop()
|
||||
|
||||
# Open log stream for cleanup (tail to end to avoid re-reading the whole file)
|
||||
if (Test-Path $mainLogPath) {
|
||||
$fileStream = [System.IO.File]::Open($mainLogPath, 'Open', 'Read', 'ReadWrite')
|
||||
[void]$fileStream.Seek(0, [System.IO.SeekOrigin]::End)
|
||||
$script:uiState.Data.logStreamReader = [System.IO.StreamReader]::new($fileStream)
|
||||
}
|
||||
else {
|
||||
WriteLog "Warning: Main log file not found at $mainLogPath after waiting. Monitor tab will not update during cleanup."
|
||||
}
|
||||
|
||||
# Create a timer to poll the cleanup job
|
||||
$script:uiState.Data.pollTimer = New-Object System.Windows.Threading.DispatcherTimer
|
||||
$script:uiState.Data.pollTimer.Interval = [TimeSpan]::FromSeconds(1)
|
||||
$script:uiState.Flags.isCleanupRunning = $true
|
||||
|
||||
$script:uiState.Data.pollTimer.Add_Tick({
|
||||
param($sender, $e)
|
||||
$currentJob = $script:uiState.Data.currentBuildJob
|
||||
|
||||
# Read new lines from log
|
||||
if ($null -ne $script:uiState.Data.logStreamReader) {
|
||||
while ($null -ne ($line = $script:uiState.Data.logStreamReader.ReadLine())) {
|
||||
$script:uiState.Data.logData.Add($line)
|
||||
if ($script:uiState.Flags.autoScrollLog) {
|
||||
$script:uiState.Controls.lstLogOutput.ScrollIntoView($line)
|
||||
$script:uiState.Controls.lstLogOutput.SelectedIndex = $script:uiState.Controls.lstLogOutput.Items.Count - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $currentJob -or $null -eq $script:uiState.Data.pollTimer) {
|
||||
if ($null -ne $sender) { $sender.Stop() }
|
||||
$script:uiState.Data.pollTimer = $null
|
||||
return
|
||||
}
|
||||
|
||||
if ($currentJob.State -in 'Completed', 'Failed', 'Stopped') {
|
||||
if ($null -ne $sender) { $sender.Stop() }
|
||||
$script:uiState.Data.pollTimer = $null
|
||||
|
||||
if ($null -ne $script:uiState.Data.logStreamReader) {
|
||||
$lastLine = $null
|
||||
while ($null -ne ($line = $script:uiState.Data.logStreamReader.ReadLine())) {
|
||||
$script:uiState.Data.logData.Add($line)
|
||||
$lastLine = $line
|
||||
}
|
||||
if ($script:uiState.Flags.autoScrollLog -and $null -ne $lastLine) {
|
||||
$script:uiState.Controls.lstLogOutput.ScrollIntoView($lastLine)
|
||||
$script:uiState.Controls.lstLogOutput.SelectedIndex = $script:uiState.Controls.lstLogOutput.Items.Count - 1
|
||||
}
|
||||
$script:uiState.Data.logStreamReader.Close()
|
||||
$script:uiState.Data.logStreamReader.Dispose()
|
||||
$script:uiState.Data.logStreamReader = $null
|
||||
}
|
||||
|
||||
$script:uiState.Controls.txtStatus.Text = "Build canceled. Environment cleaned."
|
||||
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
|
||||
$script:uiState.Controls.pbOverallProgress.Value = 0
|
||||
|
||||
# Receive and remove cleanup job
|
||||
$currentJob | Receive-Job -ErrorAction SilentlyContinue | Out-Null
|
||||
Remove-Job -Job $currentJob -Force
|
||||
$script:uiState.Data.currentBuildJob = $null
|
||||
|
||||
# Reset flags and button
|
||||
$script:uiState.Flags.isCleanupRunning = $false
|
||||
$script:uiState.Flags.isBuilding = $false
|
||||
$btn = $script:uiState.Controls.btnRun
|
||||
$btn.Content = "Build FFU"
|
||||
$btn.IsEnabled = $true
|
||||
}
|
||||
})
|
||||
|
||||
$script:uiState.Data.pollTimer.Start()
|
||||
return
|
||||
}
|
||||
|
||||
# Not currently building: start a new build
|
||||
$btnRun.IsEnabled = $false
|
||||
|
||||
# Switch to Monitor Tab
|
||||
@@ -151,8 +401,21 @@ $script:uiState.Controls.btnRun.Add_Click({
|
||||
|
||||
# Gather config on the UI thread before starting the job
|
||||
$config = Get-UIConfig -State $script:uiState
|
||||
|
||||
# Validate Additional FFU selection if enabled
|
||||
if ($config.BuildUSBDrive -and $config.CopyAdditionalFFUFiles -and (($null -eq $config.AdditionalFFUFiles) -or ($config.AdditionalFFUFiles.Count -eq 0))) {
|
||||
[System.Windows.MessageBox]::Show("Please select at least one additional FFU file to copy, or uncheck 'Copy Additional FFU Files'.", "Selection Required", "OK", "Warning") | Out-Null
|
||||
$btnRun.IsEnabled = $true
|
||||
$script:uiState.Controls.txtStatus.Text = "Build canceled: Additional FFU selection required."
|
||||
return
|
||||
}
|
||||
|
||||
$configFilePath = Join-Path $config.FFUDevelopmentPath "\config\FFUConfig.json"
|
||||
$config | ConvertTo-Json -Depth 10 | Set-Content -Path $configFilePath -Encoding UTF8
|
||||
# Sort top-level keys alphabetically for consistent output
|
||||
$sortedConfig = [ordered]@{}
|
||||
foreach ($k in ($config.Keys | Sort-Object)) { $sortedConfig[$k] = $config[$k] }
|
||||
$sortedConfig | ConvertTo-Json -Depth 10 | Set-Content -Path $configFilePath -Encoding UTF8
|
||||
$script:uiState.Data.lastConfigFilePath = $configFilePath
|
||||
|
||||
if ($config.InstallOffice -and $config.OfficeConfigXMLFile) {
|
||||
Copy-Item -Path $config.OfficeConfigXMLFile -Destination $config.OfficePath -Force
|
||||
@@ -283,25 +546,21 @@ $script:uiState.Controls.btnRun.Add_Click({
|
||||
$script:uiState.Data.logStreamReader = $null
|
||||
}
|
||||
|
||||
# Determine final status based on job result and whether cleanup was running (should be false here)
|
||||
$finalStatusText = "FFU build completed successfully."
|
||||
if ($currentJob.State -eq 'Failed') {
|
||||
$reason = $null
|
||||
|
||||
# Use Receive-Job with -ErrorVariable to reliably capture the error stream from the job,
|
||||
# as suggested by the research on handling job errors.
|
||||
Receive-Job -Job $currentJob -Keep -ErrorVariable jobErrors -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
if ($null -ne $jobErrors -and $jobErrors.Count -gt 0) {
|
||||
# The terminating error is typically the last one in the stream.
|
||||
$reason = ($jobErrors | Select-Object -Last 1).ToString()
|
||||
}
|
||||
|
||||
# If Receive-Job didn't surface an error, fall back to the JobStateInfo.Reason property.
|
||||
if ([string]::IsNullOrWhiteSpace($reason) -and $currentJob.JobStateInfo.Reason) {
|
||||
$reason = $currentJob.JobStateInfo.Reason.Message
|
||||
}
|
||||
|
||||
# Final fallback if no specific reason can be found.
|
||||
if ([string]::IsNullOrWhiteSpace($reason)) {
|
||||
$reason = "An unknown error occurred. The job failed without a specific reason."
|
||||
}
|
||||
@@ -318,19 +577,27 @@ $script:uiState.Controls.btnRun.Add_Click({
|
||||
|
||||
# Update UI elements
|
||||
$script:uiState.Controls.txtStatus.Text = $finalStatusText
|
||||
$script:uiState.Controls.btnRun.IsEnabled = $true
|
||||
|
||||
# Clean up the job object
|
||||
# Receive & remove job and clear state
|
||||
$currentJob | Receive-Job -ErrorAction SilentlyContinue | Out-Null
|
||||
Remove-Job -Job $currentJob -Force
|
||||
|
||||
# Clear the job from the state
|
||||
$script:uiState.Data.currentBuildJob = $null
|
||||
|
||||
# Reset button and flags for next run
|
||||
$script:uiState.Flags.isBuilding = $false
|
||||
$script:uiState.Flags.isCleanupRunning = $false
|
||||
$script:uiState.Controls.btnRun.Content = "Build FFU"
|
||||
$script:uiState.Controls.btnRun.IsEnabled = $true
|
||||
}
|
||||
})
|
||||
|
||||
# Start the timer
|
||||
$script:uiState.Data.pollTimer.Start()
|
||||
|
||||
# Mark building and toggle button to Cancel
|
||||
$script:uiState.Flags.isBuilding = $true
|
||||
$btnRun.Content = "Cancel"
|
||||
$btnRun.IsEnabled = $true
|
||||
}
|
||||
catch {
|
||||
# This catch block handles errors during the setup of the job (e.g., Get-UIConfig fails)
|
||||
@@ -350,6 +617,9 @@ $script:uiState.Controls.btnRun.Add_Click({
|
||||
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
|
||||
if ($null -ne $script:uiState.Controls.btnRun) {
|
||||
$script:uiState.Controls.btnRun.IsEnabled = $true
|
||||
$script:uiState.Controls.btnRun.Content = "Build FFU"
|
||||
$script:uiState.Flags.isBuilding = $false
|
||||
$script:uiState.Flags.isCleanupRunning = $false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -115,9 +115,9 @@
|
||||
<TextBox x:Name="txtVMHostIPAddress" Grid.Row="2" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch"/>
|
||||
<!-- Row 3: Disk Size (GB) -->
|
||||
<StackPanel Grid.Row="3" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5">
|
||||
<TextBlock Text="Disk Size (GB)" ToolTip="Size of the virtual hard disk for the virtual machine. Default is a 30GB dynamic disk."/>
|
||||
<TextBlock Text="Disk Size (GB)" ToolTip="Size of the virtual hard disk for the virtual machine. Default is a 50GB dynamic disk."/>
|
||||
</StackPanel>
|
||||
<TextBox x:Name="txtDiskSize" Grid.Row="3" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" Text="30" ToolTip="Size of the virtual hard disk for the virtual machine. Default is a 30GB dynamic disk."/>
|
||||
<TextBox x:Name="txtDiskSize" Grid.Row="3" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" Text="50" ToolTip="Size of the virtual hard disk for the virtual machine. Default is a 50GB dynamic disk."/>
|
||||
<!-- Row 4: Memory (GB) -->
|
||||
<StackPanel Grid.Row="4" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5">
|
||||
<TextBlock Text="Memory (GB)" ToolTip="Amount of memory to allocate for the virtual machine. Recommended to use 8GB if possible, especially for Windows 11. Default is 4GB."/>
|
||||
@@ -221,7 +221,6 @@
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" Margin="0,5,0,0">
|
||||
<StackPanel x:Name="stackFeaturesContainer" Margin="15,5">
|
||||
<TextBlock Text="Selected features (semicolon):" Margin="0,10,0,5" FontStyle="Italic"/>
|
||||
<TextBox x:Name="txtOptionalFeatures" IsReadOnly="True" Width="350" Margin="0,0,0,10" ToolTip="Provide a semicolon-separated list of Windows optional features you want to include in the FFU (e.g., netfx3;TFTP)."/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Expander>
|
||||
@@ -360,7 +359,7 @@
|
||||
|
||||
<!-- Arguments -->
|
||||
<TextBlock Text="Arguments:" Margin="0,0,0,5"/>
|
||||
<TextBox x:Name="txtAppArguments" Margin="0,0,0,10" ToolTip="Enter the arguments for the command line. If the application is an msi, the command line should only contain msiexec and the rest of the command line arguments would go here (e.g. /i D:\Win32\Mozilla firefox\setup.msi /qn /norestart)."/>
|
||||
<TextBox x:Name="txtAppArguments" Margin="0,0,0,10" ToolTip="Enter the arguments for the command line. If the application is an msi, the command line should only contain msiexec and the rest of the command line arguments would go here (e.g. /i "D:\Win32\Mozilla firefox\setup.msi" /qn /norestart)."/>
|
||||
|
||||
<!-- Source -->
|
||||
<TextBlock Text="Source:" Margin="0,0,0,5"/>
|
||||
@@ -373,6 +372,13 @@
|
||||
<Button x:Name="btnBrowseAppSource" Grid.Column="1" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Additional Exit Codes -->
|
||||
<TextBlock Text="Additional Exit Codes:" Margin="0,0,0,5"/>
|
||||
<TextBox x:Name="txtAppAdditionalExitCodes" Margin="0,0,0,10" ToolTip="Enter a comma-separated list of additional success exit codes."/>
|
||||
|
||||
<!-- Ignore Non-Zero Exit Codes Checkbox -->
|
||||
<CheckBox x:Name="chkIgnoreExitCodes" Content="Ignore all non-zero exit codes" Margin="0,0,0,10" ToolTip="If checked, any non-zero exit code will be considered a success."/>
|
||||
|
||||
<!-- Add Application Button -->
|
||||
<Button x:Name="btnAddApplication" Content="Add Application" Width="120" HorizontalAlignment="Left" Margin="0,10,0,10" Padding="10,5" ToolTip="Add the application to the list"/>
|
||||
|
||||
@@ -385,25 +391,6 @@
|
||||
|
||||
<!-- Applications ListView -->
|
||||
<ListView x:Name="lstApplications" Grid.Column="0" Height="200">
|
||||
<ListView.View>
|
||||
<GridView>
|
||||
<GridViewColumn Header="Priority" DisplayMemberBinding="{Binding Priority}" Width="60"/>
|
||||
<GridViewColumn Header="Name" DisplayMemberBinding="{Binding Name}" Width="150"/>
|
||||
<GridViewColumn Header="Command Line" DisplayMemberBinding="{Binding CommandLine}" Width="200"/>
|
||||
<GridViewColumn Header="Arguments" DisplayMemberBinding="{Binding Arguments}" Width="200"/>
|
||||
<GridViewColumn Header="Source" DisplayMemberBinding="{Binding Source}" Width="150"/>
|
||||
<GridViewColumn Header="Copy Status" DisplayMemberBinding="{Binding CopyStatus}" Width="150"/>
|
||||
<GridViewColumn Header="Action" Width="85">
|
||||
<GridViewColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Grid HorizontalAlignment="Stretch">
|
||||
<Button Content="Remove" Tag="{Binding Priority}" Width="70" HorizontalAlignment="Center" ToolTip="Remove this application from the list and reorder priorities"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</GridViewColumn.CellTemplate>
|
||||
</GridViewColumn>
|
||||
</GridView>
|
||||
</ListView.View>
|
||||
</ListView>
|
||||
|
||||
<!-- Reorder Buttons -->
|
||||
@@ -420,7 +407,9 @@
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left" Margin="0,0,0,10">
|
||||
<Button x:Name="btnSaveBYOApplications" Content="Save UserAppList.json" Margin="0,0,10,0" Padding="10,5" ToolTip="Save application list to JSON file"/>
|
||||
<Button x:Name="btnLoadBYOApplications" Content="Import UserAppList.json" Margin="0,0,10,0" Padding="10,5" ToolTip="Import application list from JSON file"/>
|
||||
<Button x:Name="btnEditApplication" Content="Edit Application" IsEnabled="False" Margin="0,0,10,0" Padding="10,5" ToolTip="Edit the selected application's details"/>
|
||||
<Button x:Name="btnCopyBYOApps" Content="Copy Apps" IsEnabled="False" Margin="0,0,10,0" Padding="10,5" ToolTip="Copy applications with a specified source path to the AppsPath\Win32 folder"/>
|
||||
<Button x:Name="btnRemoveSelectedBYOApps" Content="Remove Selected" IsEnabled="False" Margin="0,0,10,0" Padding="10,5" ToolTip="Remove selected applications from the list"/>
|
||||
<Button x:Name="btnClearBYOApplications" Content="Clear List" Padding="10,5" ToolTip="Clear all applications from the list"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
@@ -639,9 +628,10 @@
|
||||
<CheckBox x:Name="chkCompressDriversToWIM" Content="Compress Driver Model Folder to WIM" Margin="0,0,5,0" ToolTip="When set to $true, will compress each downloaded driver model folder into a separate WIM file within the Drivers folder. This is useful with Copy Drivers to USB drive."/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Row 12: Copy PE Drivers Checkbox -->
|
||||
<StackPanel Grid.Row="12" Orientation="Horizontal" Margin="5">
|
||||
<CheckBox x:Name="chkCopyPEDrivers" Content="Copy PE Drivers" Margin="0,0,5,0" ToolTip="When set to $true, will copy the drivers from the $FFUDevelopmentPath\PEDrivers folder to the WinPE deployment media. Default is $false."/>
|
||||
<!-- Row 12: PE Driver Options (UseDriversAsPEDrivers is a dependent sub-option) -->
|
||||
<StackPanel Grid.Row="12" Margin="5">
|
||||
<CheckBox x:Name="chkCopyPEDrivers" Content="Copy PE Drivers" Margin="0,0,0,5" ToolTip="When set to $true, will copy the drivers from the $FFUDevelopmentPath\PEDrivers folder to the WinPE deployment media. Default is $false."/>
|
||||
<CheckBox x:Name="chkUseDriversAsPEDrivers" Content="Use Drivers Folder as PE Drivers Source" Margin="25,0,0,0" Visibility="Collapsed" ToolTip="When set to $true (and Copy PE Drivers is also checked), bypasses the PE Drivers Folder path and instead scans the Drivers folder to gather only required WinPE drivers. Hidden unless Copy PE Drivers is checked."/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
@@ -651,7 +641,7 @@
|
||||
<TabItem Header="Build" Padding="20">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<Grid Margin="10">
|
||||
<!-- Define 10 rows for the Build tab -->
|
||||
<!-- Define 12 rows for the Build tab -->
|
||||
<Grid.RowDefinitions>
|
||||
<!-- Row 0: Header -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
@@ -667,13 +657,15 @@
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Row 6: Threads -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Row 7: General Build Options Header -->
|
||||
<!-- Row 7: BITS Priority -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Row 8: General Build Options Checkboxes -->
|
||||
<!-- Row 8: General Build Options Header -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Row 9: Build USB Drive Section -->
|
||||
<!-- Row 9: General Build Options Checkboxes -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Row 10: Post-Build Cleanup -->
|
||||
<!-- Row 10: Build USB Drive Section -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
<!-- Row 11: Post-Build Cleanup -->
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<Grid.ColumnDefinitions>
|
||||
@@ -739,11 +731,25 @@
|
||||
<TextBlock Grid.Column="0" Text="Threads" VerticalAlignment="Center" ToolTip="Controls the number of parallel threads used by ForEach-Object -Parallel and sets the value of the -ThrottleLimit parameter. Default is 5. Used in Winget, Application Copy, and driver downloads"/>
|
||||
<TextBox x:Name="txtThreads" Grid.Column="1" Margin="5" VerticalAlignment="Center" Width="50" HorizontalAlignment="Left" Text="5" ToolTip="Controls the number of parallel threads used by ForEach-Object -Parallel and sets the value of the -ThrottleLimit parameter. Default is 5. Used in Winget, Application Copy, and driver downloads"/>
|
||||
</Grid>
|
||||
<!-- Row 7: General Build Options Header -->
|
||||
<TextBlock Grid.Row="7" Text="General Build Options" FontWeight="Bold" FontSize="16" Margin="0,10,0,5"/>
|
||||
<!-- Row 7: BITS Priority -->
|
||||
<Grid Grid.Row="7" Margin="0,5">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="200"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="BITS Priority" VerticalAlignment="Center" ToolTip="Controls the BITS download priority used by the UI and BuildFFUVM.ps1. Switch to Foreground to maximize download speed if needed."/>
|
||||
<ComboBox x:Name="cmbBitsPriority" Grid.Column="1" Margin="5" VerticalAlignment="Center" Width="150" HorizontalAlignment="Left" ToolTip="Controls the BITS download priority used by the UI and BuildFFUVM.ps1. Switch to Foreground to maximize download speed if needed.">
|
||||
<sys:String>Foreground</sys:String>
|
||||
<sys:String>High</sys:String>
|
||||
<sys:String>Normal</sys:String>
|
||||
<sys:String>Low</sys:String>
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
<!-- Row 8: General Build Options Header -->
|
||||
<TextBlock Grid.Row="8" Text="General Build Options" FontWeight="Bold" FontSize="16" Margin="0,10,0,5"/>
|
||||
|
||||
<!-- Row 8: General Build Options Checkboxes -->
|
||||
<WrapPanel Grid.Row="8" Margin="0,5">
|
||||
<!-- Row 9: General Build Options Checkboxes -->
|
||||
<WrapPanel Grid.Row="9" Margin="0,5">
|
||||
<CheckBox x:Name="chkBuildUSBDriveEnable" Content="Build USB Drive" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will partition and format a USB drive and copy the captured FFU to the drive."/>
|
||||
<CheckBox x:Name="chkCompactOS" Content="Compact OS" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will compact the OS when building the FFU."/>
|
||||
<CheckBox x:Name="chkUpdateADK" Content="Update ADK" Margin="5" VerticalAlignment="Center" Tag="When set to $true, the script will check for and install/update to the latest Windows ADK and WinPE add-on."/>
|
||||
@@ -751,11 +757,12 @@
|
||||
<CheckBox x:Name="chkAllowVHDXCaching" Content="Allow VHDX Caching" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will cache the VHDX file to cache folder and create a config json file to track Windows build information."/>
|
||||
<CheckBox x:Name="chkCreateCaptureMedia" Content="Create Capture Media" Margin="5" VerticalAlignment="Center" Tag="When set to $true, this will create WinPE capture media for use when InstallApps is set to $true."/>
|
||||
<CheckBox x:Name="chkCreateDeploymentMedia" Content="Create Deployment Media" Margin="5" VerticalAlignment="Center" Tag="When set to $true, this will create WinPE deployment media for use when deploying to a physical device."/>
|
||||
<CheckBox x:Name="chkInjectUnattend" Content="Inject Unattend.xml" Margin="5" VerticalAlignment="Center" Tag="When set to $true and Install Apps is enabled, copies unattend_[arch].xml from $FFUDevelopmentPath\unattend into Apps\Unattend\Unattend.xml to be used by sysprep."/>
|
||||
<CheckBox x:Name="chkVerbose" Content="Verbose" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will enable write-verbose output to the console for the build script."/>
|
||||
</WrapPanel>
|
||||
|
||||
<!-- Row 9: Build USB Drive Section -->
|
||||
<StackPanel Grid.Row="9" Margin="0,10,0,5" x:Name="usbDriveSection" Visibility="Collapsed">
|
||||
<!-- Row 10: Build USB Drive Section -->
|
||||
<StackPanel Grid.Row="10" Margin="0,10,0,5" x:Name="usbDriveSection" Visibility="Collapsed">
|
||||
<TextBlock Text="Build USB Drive Settings" FontWeight="Bold" FontSize="16" Margin="0,0,0,10"/>
|
||||
<StackPanel Margin="5,0,0,10">
|
||||
<CheckBox x:Name="chkAllowExternalHardDiskMedia" Content="Allow External Hard Disk Media" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will allow the use of external hard disk media."/>
|
||||
@@ -765,6 +772,35 @@
|
||||
<CheckBox x:Name="chkCopyAutopilot" Content="Copy Autopilot Profile" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will copy the Autopilot profile to the USB drive."/>
|
||||
<CheckBox x:Name="chkCopyUnattend" Content="Copy Unattend.xml" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will copy the Unattend.xml file to the USB drive."/>
|
||||
<CheckBox x:Name="chkCopyPPKG" Content="Copy Provisioning Package" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will copy the provisioning package to the USB drive."/>
|
||||
<CheckBox x:Name="chkCopyAdditionalFFUFiles" Content="Copy Additional FFU Files" Margin="5" VerticalAlignment="Center" Tag="When set to $true, allows selecting existing FFU files in the capture folder to also copy to the USB drive."/>
|
||||
|
||||
<!-- Additional FFU Selection Section -->
|
||||
<Grid x:Name="additionalFFUPanel" Margin="5,0,0,10" Visibility="Collapsed">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
<!-- Header row -->
|
||||
<DockPanel Grid.Row="0" Margin="0,5" LastChildFill="False">
|
||||
<TextBlock Text="Additional FFU Files" DockPanel.Dock="Left" FontWeight="Bold" VerticalAlignment="Center" Margin="0,0,10,0"/>
|
||||
<Button x:Name="btnRefreshAdditionalFFUs" Content="Refresh" DockPanel.Dock="Left" Padding="10,5" ToolTip="Refresh the list of FFU files from the capture folder"/>
|
||||
</DockPanel>
|
||||
<!-- ListView row -->
|
||||
<ListView x:Name="lstAdditionalFFUs" Grid.Row="1" Margin="0,5" Height="150">
|
||||
<ListView.View>
|
||||
<GridView>
|
||||
<GridViewColumn Header="FFU Name" DisplayMemberBinding="{Binding Name}" Width="300"/>
|
||||
<GridViewColumn Header="Last Modified" DisplayMemberBinding="{Binding LastModified}" Width="200"/>
|
||||
</GridView>
|
||||
</ListView.View>
|
||||
</ListView>
|
||||
</Grid>
|
||||
|
||||
<!-- Max USB Drives -->
|
||||
<StackPanel Orientation="Horizontal" Margin="5">
|
||||
<TextBlock Text="Max USB Drives" VerticalAlignment="Center" ToolTip="Maximum number of USB drives to build at once. Enter 0 to process all discovered (or all selected) drives."/>
|
||||
<TextBox x:Name="txtMaxUSBDrives" Width="50" Margin="10,0,0,0" Text="5" VerticalAlignment="Center" ToolTip="Maximum number of USB drives to build at once. Enter 0 to process all discovered (or all selected) drives."/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- USB Drive Selection Section -->
|
||||
<Grid x:Name="usbDriveSelectionPanel" Margin="5,0,0,0">
|
||||
@@ -782,7 +818,7 @@
|
||||
<GridView>
|
||||
|
||||
<GridViewColumn Header="Model" DisplayMemberBinding="{Binding Model}" Width="200"/>
|
||||
<GridViewColumn Header="Serial Number" DisplayMemberBinding="{Binding SerialNumber}" Width="150"/>
|
||||
<GridViewColumn Header="Unique ID" DisplayMemberBinding="{Binding UniqueId}" Width="300"/>
|
||||
<GridViewColumn Header="Size (GB)" DisplayMemberBinding="{Binding Size}" Width="80"/>
|
||||
</GridView>
|
||||
</ListView.View>
|
||||
@@ -791,8 +827,8 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Row 10: Post-Build Cleanup -->
|
||||
<StackPanel Grid.Row="10" Margin="0,10,0,5">
|
||||
<!-- Row 11: Post-Build Cleanup -->
|
||||
<StackPanel Grid.Row="11" Margin="0,10,0,5">
|
||||
<TextBlock Text="Post-Build Cleanup" FontWeight="Bold" FontSize="16" Margin="0,0,0,5"/>
|
||||
<CheckBox x:Name="chkCleanupAppsISO" Content="Cleanup Apps ISO" Margin="5" VerticalAlignment="Center" Tag="Remove Apps ISO after FFU capture."/>
|
||||
<CheckBox x:Name="chkCleanupCaptureISO" Content="Cleanup Capture ISO" Margin="5" VerticalAlignment="Center" Tag="Remove WinPE capture ISO after FFU capture."/>
|
||||
@@ -820,6 +856,7 @@
|
||||
<TextBlock x:Name="txtStatus" Grid.Row="2" Margin="0,5,0,0"/>
|
||||
<!-- Buttons (Build Config File / Load Config File / Build FFU) -->
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,0,20,20">
|
||||
<Button x:Name="btnRestoreDefaults" Content="Restore Defaults" Width="150" Margin="0,0,10,0" FontSize="14" Padding="10,5"/>
|
||||
<Button x:Name="btnBuildConfig" Content="Save Config File" Width="150" Margin="0,0,10,0" FontSize="14" Padding="10,5"/>
|
||||
<Button x:Name="btnLoadConfig" Content="Load Config File" Width="150" Margin="0,0,10,0" FontSize="14" Padding="10,5"/>
|
||||
<Button x:Name="btnRun" Content="Build FFU" Width="120" FontSize="14" Padding="10,5"/>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Start & Initialization"
|
||||
A[Start] --> B{Load ConfigFile?};
|
||||
B --> C[Process Parameters];
|
||||
C --> D{Validate Parameters};
|
||||
D --> E{"dirty.txt exists?"};
|
||||
E -- Yes --> F[Run Cleanup Routine];
|
||||
F --> G["Create new dirty.txt"];
|
||||
E -- No --> G;
|
||||
end
|
||||
|
||||
G --> H{"-InstallDrivers or -CopyDrivers?"};
|
||||
|
||||
subgraph "Pre-Build Preparations"
|
||||
H -- Yes --> I{Driver Source?};
|
||||
I -- "-DriversJsonPath" --> J[Download Drivers via JSON in Parallel];
|
||||
I -- "-Make and -Model" --> K[Download Drivers for specific Make/Model];
|
||||
I -- "Local Folder" --> L[Use Existing Drivers in Drivers Folder];
|
||||
|
||||
subgraph "ADK & WinPE"
|
||||
M[Check for ADK & WinPE Add-on];
|
||||
M --> N{Latest Version Installed?};
|
||||
N -- No --> O[Uninstall Old & Install Latest ADK/WinPE];
|
||||
N -- Yes --> P[Get ADK Path];
|
||||
O --> P;
|
||||
end
|
||||
|
||||
Q{"-InstallApps?"};
|
||||
subgraph "Application & In-VM Content Preparation"
|
||||
direction LR
|
||||
R[Check for existing downloaded apps];
|
||||
R --> S{Download missing WinGet apps};
|
||||
S --> T{"-InstallOffice?"};
|
||||
T -- Yes --> U[Download ODT & Office content];
|
||||
T -- No --> V[Continue];
|
||||
U --> V;
|
||||
V --> W["Download in-VM updates: Defender, MSRT, etc."];
|
||||
W --> X["Create Apps.iso"];
|
||||
end
|
||||
end
|
||||
|
||||
J --> M;
|
||||
K --> M;
|
||||
L --> M;
|
||||
H -- No --> M;
|
||||
P --> Q;
|
||||
Q -- Yes --> R;
|
||||
X --> Y;
|
||||
Q -- No --> Y{"-AllowVHDXCaching?"};
|
||||
|
||||
subgraph "VHDX Management"
|
||||
Y -- Yes --> Z[Check for matching cached VHDX];
|
||||
Z --> AA{Cache Hit?};
|
||||
AA -- Yes --> AB[Use Cached VHDX];
|
||||
AA -- No --> AC[Create New VHDX];
|
||||
Y -- No --> AC;
|
||||
|
||||
subgraph "VHDX Creation Workflow"
|
||||
AC --> AD{ISOPath provided?};
|
||||
AD -- No --> AE[Download Windows ESD media];
|
||||
AD -- Yes --> AF[Use provided ISO];
|
||||
AE --> AG[Create & Partition VHDX];
|
||||
AF --> AG;
|
||||
AG --> AH[Apply Base Windows Image to VHDX];
|
||||
AH --> AI{"Updates specified? (CU, dotNET, etc.)"};
|
||||
AI -- Yes --> AJ[Apply Updates to Offline VHDX];
|
||||
AJ --> AK[Run Component Cleanup];
|
||||
AI -- No --> AK;
|
||||
AK --> AL{"Optional Features specified?"};
|
||||
AL -- Yes --> AM[Enable Optional Features];
|
||||
AL -- No --> AN[Finalize VHDX Setup];
|
||||
AM --> AN;
|
||||
AN --> AO{"-AllowVHDXCaching?"};
|
||||
AO -- Yes --> AP[Optimize and Copy VHDX to Cache];
|
||||
AO -- No --> AQ[Continue];
|
||||
AP --> AQ;
|
||||
end
|
||||
end
|
||||
|
||||
AB --> BA;
|
||||
AQ --> BA{"-InstallApps?"};
|
||||
|
||||
subgraph "FFU Creation"
|
||||
subgraph "VM-Based Capture (-InstallApps)"
|
||||
direction LR
|
||||
BB[Create Hyper-V VM from VHDX];
|
||||
BB --> BC["Create WinPE Capture Media iso"];
|
||||
BC --> BD[Configure network share for capture];
|
||||
BD --> BE["Start VM: Boots to Audit Mode"];
|
||||
BE --> BF[Orchestrator runs: Installs apps, syspreps, shuts down];
|
||||
BF --> BG[VM reboots from Capture Media];
|
||||
BG --> BH["CaptureFFU.ps1 runs, saves FFU to share, shuts down"];
|
||||
end
|
||||
|
||||
subgraph "Direct VHDX Capture"
|
||||
BI[Capture FFU directly from VHDX using DISM];
|
||||
end
|
||||
end
|
||||
|
||||
BA -- Yes --> BB;
|
||||
BA -- No --> BI;
|
||||
|
||||
subgraph "Post-Processing & Media Creation"
|
||||
BK{"-InstallDrivers?"};
|
||||
BK -- Yes --> BL[Mount FFU & Inject Drivers];
|
||||
BK -- No --> BM[Continue];
|
||||
BL --> BM;
|
||||
BM --> BN{"-Optimize?"};
|
||||
BN -- Yes --> BO[Optimize FFU using DISM];
|
||||
BN -- No --> BP[Continue];
|
||||
BO --> BP;
|
||||
BP --> BQ{"-BuildUSBDrive?"};
|
||||
BQ -- Yes --> BR[Create WinPE Deployment Media];
|
||||
BR --> BS["Partition USB Drive(s)"];
|
||||
BS --> BT[Copy FFU, Deploy scripts & other assets to USB];
|
||||
BQ -- No --> BU[Continue];
|
||||
BT --> BU;
|
||||
end
|
||||
|
||||
BH --> BK;
|
||||
BI --> BK;
|
||||
|
||||
subgraph "Final Cleanup"
|
||||
BU --> BV[Cleanup VM, VHDX, temp files];
|
||||
BV --> BW["Remove dirty.txt"];
|
||||
BW --> BX[End];
|
||||
end
|
||||
@@ -0,0 +1,127 @@
|
||||
# Provides shared cleanup functionality for both UI and build script.
|
||||
|
||||
function Invoke-FFUPostBuildCleanup {
|
||||
param(
|
||||
[string]$RootPath,
|
||||
[string]$AppsPath,
|
||||
[string]$DriversPath,
|
||||
[string]$FFUCapturePath,
|
||||
[string]$CaptureISOPath,
|
||||
[string]$DeployISOPath,
|
||||
[string]$AppsISOPath,
|
||||
[string]$KBPath,
|
||||
[bool]$RemoveCaptureISO = $false,
|
||||
[bool]$RemoveDeployISO = $false,
|
||||
[bool]$RemoveAppsISO = $false,
|
||||
[bool]$RemoveDrivers = $false,
|
||||
[bool]$RemoveFFU = $false,
|
||||
[bool]$RemoveApps = $false,
|
||||
[bool]$RemoveUpdates = $false
|
||||
)
|
||||
$originalProgressPreference = $ProgressPreference
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
try {
|
||||
WriteLog "CommonCleanup: Starting cleanup (CaptureISO=$RemoveCaptureISO DeployISO=$RemoveDeployISO AppsISO=$RemoveAppsISO Drivers=$RemoveDrivers FFU=$RemoveFFU Apps=$RemoveApps Updates=$RemoveUpdates KBPath=$KBPath)."
|
||||
|
||||
# Primary ISO paths (new naming/location)
|
||||
if ($RemoveCaptureISO -and -not [string]::IsNullOrWhiteSpace($CaptureISOPath) -and (Test-Path -LiteralPath $CaptureISOPath)) {
|
||||
WriteLog "CommonCleanup: Removing $CaptureISOPath"
|
||||
try { Remove-Item -LiteralPath $CaptureISOPath -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $CaptureISOPath : $($_.Exception.Message)" }
|
||||
}
|
||||
if ($RemoveDeployISO -and -not [string]::IsNullOrWhiteSpace($DeployISOPath) -and (Test-Path -LiteralPath $DeployISOPath)) {
|
||||
WriteLog "CommonCleanup: Removing $DeployISOPath"
|
||||
try { Remove-Item -LiteralPath $DeployISOPath -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $DeployISOPath : $($_.Exception.Message)" }
|
||||
}
|
||||
if ($RemoveAppsISO -and -not [string]::IsNullOrWhiteSpace($AppsISOPath) -and (Test-Path -LiteralPath $AppsISOPath)) {
|
||||
WriteLog "CommonCleanup: Removing $AppsISOPath"
|
||||
try { Remove-Item -LiteralPath $AppsISOPath -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $AppsISOPath : $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
# Legacy / root-level WinPE ISOs (pattern-based)
|
||||
if ($RemoveCaptureISO) {
|
||||
Get-ChildItem -LiteralPath $RootPath -Filter 'WinPE_FFU_Capture*.iso' -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
try { WriteLog "CommonCleanup: Removing legacy capture ISO $($_.FullName)"; Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing legacy capture ISO $($_.FullName) : $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
if ($RemoveDeployISO) {
|
||||
Get-ChildItem -LiteralPath $RootPath -Filter 'WinPE_FFU_Deploy*.iso' -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
try { WriteLog "CommonCleanup: Removing legacy deploy ISO $($_.FullName)"; Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing legacy deploy ISO $($_.FullName) : $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
|
||||
if ($RemoveDrivers -and -not [string]::IsNullOrWhiteSpace($DriversPath) -and (Test-Path -LiteralPath $DriversPath -PathType Container)) {
|
||||
WriteLog "CommonCleanup: Removing contents of $DriversPath (preserving Drivers.json and DriverMapping.json)"
|
||||
try {
|
||||
# Preserve drivers json files
|
||||
$driverItems = Get-ChildItem -LiteralPath $DriversPath -Force -ErrorAction SilentlyContinue | Where-Object { @('Drivers.json', 'DriverMapping.json') -notcontains $_.Name }
|
||||
if ($driverItems) {
|
||||
$driverItems | Remove-Item -Force -Recurse -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
catch { WriteLog "CommonCleanup: Driver content cleanup issue: $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
if ($RemoveFFU -and -not [string]::IsNullOrWhiteSpace($FFUCapturePath) -and (Test-Path -LiteralPath $FFUCapturePath -PathType Container)) {
|
||||
WriteLog "CommonCleanup: Removing FFU files in $FFUCapturePath"
|
||||
Get-ChildItem -LiteralPath $FFUCapturePath -Filter *.ffu -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
try { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing FFU $($_.FullName) : $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
|
||||
if ($RemoveApps -and -not [string]::IsNullOrWhiteSpace($AppsPath) -and (Test-Path -LiteralPath $AppsPath -PathType Container)) {
|
||||
$win32 = Join-Path $AppsPath 'Win32'
|
||||
$store = Join-Path $AppsPath 'MSStore'
|
||||
if (Test-Path -LiteralPath $win32) {
|
||||
WriteLog "CommonCleanup: Removing $win32"
|
||||
try { Remove-Item -LiteralPath $win32 -Recurse -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $win32 : $($_.Exception.Message)" }
|
||||
}
|
||||
if (Test-Path -LiteralPath $store) {
|
||||
WriteLog "CommonCleanup: Removing $store"
|
||||
try { Remove-Item -LiteralPath $store -Recurse -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $store : $($_.Exception.Message)" }
|
||||
}
|
||||
$office = Join-Path $AppsPath 'Office'
|
||||
if ((Test-Path -LiteralPath $office) -and $InstallOffice) {
|
||||
WriteLog "CommonCleanup: Checking for Office artifacts in $office"
|
||||
$officeSub = Join-Path $office 'Office'
|
||||
if (Test-Path -LiteralPath $officeSub) {
|
||||
WriteLog "CommonCleanup: Removing $officeSub"
|
||||
try { Remove-Item -LiteralPath $officeSub -Recurse -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $officeSub : $($_.Exception.Message)" }
|
||||
}
|
||||
$setupExe = Join-Path $office 'setup.exe'
|
||||
if (Test-Path -LiteralPath $setupExe) {
|
||||
WriteLog "CommonCleanup: Removing $setupExe"
|
||||
try { Remove-Item -LiteralPath $setupExe -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $setupExe : $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($RemoveUpdates) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($AppsPath) -and (Test-Path -LiteralPath $AppsPath)) {
|
||||
# Remove per-run app update payloads stored under Apps
|
||||
$appUpdateDirs = @('Defender', 'Edge', 'MSRT', 'OneDrive')
|
||||
foreach ($d in $appUpdateDirs) {
|
||||
$target = Join-Path $AppsPath $d
|
||||
if (Test-Path -LiteralPath $target) {
|
||||
WriteLog "CommonCleanup: Removing update folder $target"
|
||||
try { Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $target : $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($KBPath) -and (Test-Path -LiteralPath $KBPath)) {
|
||||
# Remove Windows/.NET CU downloads stored under KB
|
||||
WriteLog "CommonCleanup: Removing downloaded updates in $KBPath"
|
||||
try { Remove-Item -LiteralPath $KBPath -Recurse -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $KBPath : $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
|
||||
WriteLog "CommonCleanup: Completed."
|
||||
}
|
||||
catch {
|
||||
WriteLog "CommonCleanup: Fatal cleanup error $($_.Exception.Message)"
|
||||
}
|
||||
finally {
|
||||
$ProgressPreference = $originalProgressPreference
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Invoke-FFUPostBuildCleanup
|
||||
@@ -12,6 +12,10 @@ $script:CommonCoreLogFilePath = $null
|
||||
# Mutex for log file access
|
||||
$script:commonCoreLogMutexName = "Global\FFUCommonCoreLogMutex" # Unique name
|
||||
$script:commonCoreLogMutex = New-Object System.Threading.Mutex($false, $script:commonCoreLogMutexName)
|
||||
$script:BitsTransferPriority = 'Normal'
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:FFU_BITS_PRIORITY)) {
|
||||
$script:BitsTransferPriority = $env:FFU_BITS_PRIORITY
|
||||
}
|
||||
|
||||
# Function to set the log file path for this module
|
||||
function Set-CommonCoreLogPath {
|
||||
@@ -31,6 +35,23 @@ function Set-CommonCoreLogPath {
|
||||
}
|
||||
}
|
||||
|
||||
function Set-BitsTransferPriority {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet('Foreground', 'High', 'Normal', 'Low')]
|
||||
[string]$Priority
|
||||
)
|
||||
$script:BitsTransferPriority = $Priority
|
||||
try {
|
||||
Set-Item -Path Env:FFU_BITS_PRIORITY -Value $Priority -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to set FFU_BITS_PRIORITY environment variable: $($_.Exception.Message)"
|
||||
}
|
||||
WriteLog "BITS transfer priority set to $Priority."
|
||||
}
|
||||
|
||||
# Centralized WriteLog function
|
||||
function WriteLog {
|
||||
[CmdletBinding()]
|
||||
@@ -143,20 +164,36 @@ function Start-BitsTransferWithRetry {
|
||||
[string]$Source,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Destination,
|
||||
[int]$Retries = 3
|
||||
[int]$Retries = 3,
|
||||
[ValidateSet('Foreground','High','Normal','Low')]
|
||||
[string]$Priority
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Priority)) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:FFU_BITS_PRIORITY)) {
|
||||
$Priority = $env:FFU_BITS_PRIORITY
|
||||
}
|
||||
elseif (-not [string]::IsNullOrWhiteSpace($script:BitsTransferPriority)) {
|
||||
$Priority = $script:BitsTransferPriority
|
||||
}
|
||||
else {
|
||||
$Priority = 'Normal'
|
||||
}
|
||||
}
|
||||
|
||||
$attempt = 0
|
||||
$lastError = $null
|
||||
$notLoggedOnHResult = [int]0x800704dd
|
||||
$fallbackTriggered = $false
|
||||
|
||||
while ($attempt -lt $Retries) {
|
||||
while ($attempt -lt $Retries -and -not $fallbackTriggered) {
|
||||
$OriginalVerbosePreference = $VerbosePreference
|
||||
$OriginalProgressPreference = $ProgressPreference
|
||||
try {
|
||||
$VerbosePreference = 'SilentlyContinue'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
Start-BitsTransfer -Source $Source -Destination $Destination -ErrorAction Stop
|
||||
Start-BitsTransfer -Source $Source -Destination $Destination -Priority $Priority -ErrorAction Stop
|
||||
|
||||
$ProgressPreference = $OriginalProgressPreference
|
||||
$VerbosePreference = $OriginalVerbosePreference
|
||||
@@ -166,7 +203,24 @@ function Start-BitsTransferWithRetry {
|
||||
catch {
|
||||
$lastError = $_
|
||||
$attempt++
|
||||
WriteLog "Attempt $attempt of $Retries failed to download $Source. Error: $($lastError.Exception.Message)."
|
||||
$errorMessage = $lastError.Exception.Message
|
||||
WriteLog "Attempt $attempt of $Retries failed to download $Source. Error: $errorMessage."
|
||||
$hResult = $null
|
||||
if ($null -ne $lastError.Exception) {
|
||||
$hResult = $lastError.Exception.HResult
|
||||
}
|
||||
$needsHttpFallback = $false
|
||||
if ($hResult -eq $notLoggedOnHResult) {
|
||||
$needsHttpFallback = $true
|
||||
}
|
||||
elseif ($errorMessage -match '0x800704DD' -or $errorMessage -match 'not.*logged on to the network') {
|
||||
$needsHttpFallback = $true
|
||||
}
|
||||
if ($needsHttpFallback) {
|
||||
WriteLog "BITS cannot download $Source because the current session is not logged on to the network. Falling back to Invoke-WebRequest."
|
||||
$fallbackTriggered = $true
|
||||
break
|
||||
}
|
||||
Start-Sleep -Seconds (1 * $attempt)
|
||||
}
|
||||
finally {
|
||||
@@ -179,6 +233,41 @@ function Start-BitsTransferWithRetry {
|
||||
}
|
||||
}
|
||||
|
||||
if ($fallbackTriggered) {
|
||||
$remainingAttempts = $Retries - $attempt
|
||||
if ($remainingAttempts -lt 1) {
|
||||
$remainingAttempts = 1
|
||||
}
|
||||
$httpAttempt = 0
|
||||
while ($httpAttempt -lt $remainingAttempts) {
|
||||
$httpAttempt++
|
||||
$OriginalVerbosePreference = $VerbosePreference
|
||||
$OriginalProgressPreference = $ProgressPreference
|
||||
try {
|
||||
$VerbosePreference = 'SilentlyContinue'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
Invoke-WebRequest -Uri $Source -OutFile $Destination -ErrorAction Stop
|
||||
$ProgressPreference = $OriginalProgressPreference
|
||||
$VerbosePreference = $OriginalVerbosePreference
|
||||
WriteLog "Successfully transferred $Source to $Destination via HTTP fallback."
|
||||
return
|
||||
}
|
||||
catch {
|
||||
$lastError = $_
|
||||
WriteLog "HTTP fallback attempt $httpAttempt of $remainingAttempts failed to download $Source. Error: $($lastError.Exception.Message)."
|
||||
Start-Sleep -Seconds (1 * $httpAttempt)
|
||||
}
|
||||
finally {
|
||||
if (Get-Variable -Name 'OriginalProgressPreference' -ErrorAction SilentlyContinue) {
|
||||
$ProgressPreference = $OriginalProgressPreference
|
||||
}
|
||||
if (Get-Variable -Name 'OriginalVerbosePreference' -ErrorAction SilentlyContinue) {
|
||||
$VerbosePreference = $OriginalVerbosePreference
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WriteLog "Failed to download $Source after $Retries attempts. Last Error: $($lastError.Exception.Message)"
|
||||
throw $lastError
|
||||
}
|
||||
@@ -194,4 +283,22 @@ function Set-Progress {
|
||||
WriteLog "[PROGRESS] $Percentage | $Message"
|
||||
}
|
||||
|
||||
function ConvertTo-SafeName {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name
|
||||
)
|
||||
# Replace invalid Windows filename characters (<>:"/\|?* and control chars) with a dash
|
||||
$sanitized = $Name -replace '[<>:\"/\\|?*\x00-\x1F]', '-'
|
||||
# Collapse multiple consecutive dashes
|
||||
$sanitized = $sanitized -replace '-{2,}', '-'
|
||||
# Trim leading/trailing spaces, periods, and dashes
|
||||
$sanitized = $sanitized.Trim(' ', '.', '-')
|
||||
if ([string]::IsNullOrWhiteSpace($sanitized)) {
|
||||
$sanitized = 'Unnamed'
|
||||
}
|
||||
return $sanitized
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function *
|
||||
@@ -0,0 +1,300 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Common Dell driver helpers (catalog index, model listing, latest package selection).
|
||||
#>
|
||||
|
||||
function Convert-DellVendorVersion {
|
||||
param([Parameter(Mandatory=$true)][string]$VendorVersion)
|
||||
$segments = $VendorVersion.Split('.') | ForEach-Object {
|
||||
if ($_ -match '^\d+$') { [int]$_ } else { 0 }
|
||||
}
|
||||
return ,$segments
|
||||
}
|
||||
|
||||
function Compare-DellVendorVersion {
|
||||
param(
|
||||
[int[]]$Left,
|
||||
[int[]]$Right
|
||||
)
|
||||
$len = [Math]::Max($Left.Length,$Right.Length)
|
||||
for ($i=0; $i -lt $len; $i++) {
|
||||
$l = if ($i -lt $Left.Length) { $Left[$i] } else { 0 }
|
||||
$r = if ($i -lt $Right.Length) { $Right[$i] } else { 0 }
|
||||
if ($l -gt $r) { return 1 }
|
||||
if ($l -lt $r) { return -1 }
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function Get-DellCatalogIndex {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$DriversFolder
|
||||
)
|
||||
|
||||
$dellFolder = Join-Path $DriversFolder 'Dell'
|
||||
if (-not (Test-Path $dellFolder)) { New-Item -Path $dellFolder -ItemType Directory -Force | Out-Null }
|
||||
$cabPath = Join-Path $dellFolder 'CatalogIndexPC.cab'
|
||||
$xmlPath = Join-Path $dellFolder 'CatalogIndexPC.xml'
|
||||
$url = 'https://downloads.dell.com/catalog/CatalogIndexPC.cab'
|
||||
|
||||
$need = $true
|
||||
if (Test-Path $xmlPath) {
|
||||
$ageDays = ((Get-Date) - (Get-Item $xmlPath).CreationTime).TotalDays
|
||||
if ($ageDays -lt 7) { $need = $false }
|
||||
}
|
||||
|
||||
if ($need) {
|
||||
if (Test-Path $cabPath) { Remove-Item $cabPath -Force -ErrorAction SilentlyContinue }
|
||||
if (Test-Path $xmlPath) { Remove-Item $xmlPath -Force -ErrorAction SilentlyContinue }
|
||||
Start-BitsTransferWithRetry -Source $url -Destination $cabPath
|
||||
Invoke-Process -FilePath Expand.exe -ArgumentList """$cabPath"" ""$xmlPath""" | Out-Null
|
||||
Remove-Item $cabPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if (-not (Test-Path $xmlPath)) { throw "Dell CatalogIndexPC XML missing: $xmlPath" }
|
||||
return $xmlPath
|
||||
}
|
||||
|
||||
function Get-DellClientModels {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$CatalogIndexXmlPath
|
||||
)
|
||||
|
||||
$settings = New-Object System.Xml.XmlReaderSettings
|
||||
$settings.IgnoreWhitespace = $true
|
||||
$settings.IgnoreComments = $true
|
||||
$reader = [System.Xml.XmlReader]::Create($CatalogIndexXmlPath,$settings)
|
||||
|
||||
$models = [System.Collections.Generic.List[pscustomobject]]::new()
|
||||
try {
|
||||
while ($reader.Read()) {
|
||||
if ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq 'GroupManifest') {
|
||||
# Read subtree to pick out brand/model/systemID + path
|
||||
$sub = $reader.ReadSubtree()
|
||||
$doc = New-Object System.Xml.XmlDocument
|
||||
$doc.Load($sub)
|
||||
$sub.Dispose()
|
||||
|
||||
# Use local-name() to ignore namespaces
|
||||
$brandNode = $doc.SelectSingleNode("//*[local-name()='SupportedSystems']/*[local-name()='Brand']")
|
||||
if (-not $brandNode) { continue }
|
||||
$brandDisplay = ($brandNode.SelectSingleNode("*[local-name()='Display']")?.InnerText).Trim()
|
||||
$modelNode = $brandNode.SelectSingleNode("*[local-name()='Model']")
|
||||
if (-not $modelNode) { continue }
|
||||
$modelNumber = ($modelNode.SelectSingleNode("*[local-name()='Display']")?.InnerText).Trim()
|
||||
$systemId = $modelNode.GetAttribute('systemID')
|
||||
$manifestInfo = $doc.SelectSingleNode("//*[local-name()='ManifestInformation']")
|
||||
if (-not $manifestInfo) { continue }
|
||||
$pathAttr = $manifestInfo.GetAttribute('path')
|
||||
if (-not $pathAttr) { continue }
|
||||
$cabUrl = 'https://downloads.dell.com/' + $pathAttr
|
||||
# Normalize model display using GroupManifest Display CDATA if available (strip 'PDK Catalog for')
|
||||
$gmDisplayNode = $doc.SelectSingleNode("/*[local-name()='GroupManifest']/*[local-name()='Display']")
|
||||
$modelFull = $null
|
||||
if ($gmDisplayNode -and $gmDisplayNode.InnerText) {
|
||||
$rawDisplay = $gmDisplayNode.InnerText.Trim()
|
||||
$modelFull = ($rawDisplay -replace '^\s*PDK Catalog for\s+','').Trim()
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($modelFull)) {
|
||||
# Fallback: assemble from brand/model nodes (legacy heuristic)
|
||||
$prefixedModelNumber = $modelNumber
|
||||
if ($modelNumber -and $brandDisplay) {
|
||||
if ($modelNumber.StartsWith($brandDisplay,[System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$prefixedModelNumber = $modelNumber
|
||||
}
|
||||
else {
|
||||
$prefixedModelNumber = "$brandDisplay $modelNumber"
|
||||
}
|
||||
}
|
||||
elseif ($brandDisplay -and -not $modelNumber) {
|
||||
$prefixedModelNumber = $brandDisplay
|
||||
}
|
||||
$modelFull = $prefixedModelNumber
|
||||
}
|
||||
$modelDisplay = "$modelFull ($systemId)"
|
||||
$models.Add([pscustomobject]@{
|
||||
Brand = $brandDisplay
|
||||
ModelNumber = $modelNumber
|
||||
SystemId = $systemId
|
||||
CabRelativePath = $pathAttr
|
||||
CabUrl = $cabUrl
|
||||
ModelDisplay = $modelDisplay
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$reader.Dispose()
|
||||
}
|
||||
return $models
|
||||
}
|
||||
|
||||
function Get-DellLatestDriverPackages {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$ModelXmlPath,
|
||||
[Parameter(Mandatory=$true)][string]$WindowsArch,
|
||||
[Parameter(Mandatory=$true)][int]$WindowsRelease
|
||||
)
|
||||
|
||||
if (-not (Test-Path $ModelXmlPath)) { throw "Model XML not found: $ModelXmlPath" }
|
||||
|
||||
$xml = [xml](Get-Content -Path $ModelXmlPath -Raw)
|
||||
|
||||
# Collect all SoftwareComponent nodes
|
||||
$components = $xml.SelectNodes("//*[local-name()='SoftwareComponent']")
|
||||
if (-not $components) { return @() }
|
||||
|
||||
$rawPackages = [System.Collections.Generic.List[pscustomobject]]::new()
|
||||
|
||||
foreach ($comp in $components) {
|
||||
$ctype = $comp.SelectSingleNode("*[local-name()='ComponentType']")
|
||||
if (-not $ctype) { continue }
|
||||
if ($ctype.GetAttribute('value') -ne 'DRVR') { continue }
|
||||
|
||||
# OS filtering (arch only – release filtering intentionally minimal for now)
|
||||
$osNodes = @($comp.SelectNodes("*[local-name()='SupportedOperatingSystems']/*[local-name()='OperatingSystem']"))
|
||||
if (-not $osNodes) { continue }
|
||||
$validOS = $osNodes | Where-Object { $_.GetAttribute('osArch') -eq $WindowsArch } | Select-Object -First 1
|
||||
if (-not $validOS) { continue }
|
||||
|
||||
$path = $comp.GetAttribute('path')
|
||||
if (-not $path) { continue }
|
||||
|
||||
$downloadUrl = "https://downloads.dell.com/$path"
|
||||
$fileName = [IO.Path]::GetFileName($path)
|
||||
$vendorVersion = $comp.GetAttribute('vendorVersion')
|
||||
$versionArr = if ($vendorVersion) { Convert-DellVendorVersion $vendorVersion } else { @(0) }
|
||||
$dateTimeAttr = $comp.GetAttribute('dateTime')
|
||||
$dt = Get-Date
|
||||
if ($dateTimeAttr) {
|
||||
try { $dt = [DateTime]::Parse($dateTimeAttr) } catch { }
|
||||
}
|
||||
|
||||
$categoryNode = $comp.SelectSingleNode("*[local-name()='Category']/*[local-name()='Display']")
|
||||
$category = if ($categoryNode) { $categoryNode.InnerText.Trim() } else { 'Uncategorized' }
|
||||
|
||||
# Collect componentIDs (SupportedDevices + SupportedDCHDevices)
|
||||
$compIds = [System.Collections.Generic.List[string]]::new()
|
||||
$devNodes = @($comp.SelectNodes(".//*[local-name()='Device']"))
|
||||
foreach ($dn in $devNodes) {
|
||||
$id = $dn.GetAttribute('componentID')
|
||||
if ($id) { [void]$compIds.Add($id) }
|
||||
}
|
||||
if ($compIds.Count -eq 0) { continue }
|
||||
|
||||
# Build a deterministic sortable key: zero-pad each numeric segment to 6 digits
|
||||
$versionSortable = ($versionArr | ForEach-Object { $_.ToString('D6') }) -join '-'
|
||||
|
||||
# Capture a human‑readable driver name (preserve spaces like HP/Lenovo; remove only illegal path chars and extra whitespace)
|
||||
$displayNode = $comp.SelectSingleNode("*[local-name()='Name']/*[local-name()='Display']")
|
||||
$nameRaw = if ($displayNode) { $displayNode.InnerText.Trim() } else { $fileName }
|
||||
# Remove characters not suitable for display (and disallowed in file names) but keep spaces
|
||||
$nameDisplay = $nameRaw -replace '[\\\/:\*\?\"\<\>\|]', ' ' -replace '[,]', '-'
|
||||
# Collapse multiple spaces to single
|
||||
$nameDisplay = ($nameDisplay -replace '\s+', ' ').Trim()
|
||||
|
||||
$rawPackages.Add([pscustomobject]@{
|
||||
Path = $path
|
||||
DownloadUrl = $downloadUrl
|
||||
FileName = $fileName
|
||||
Name = $nameDisplay
|
||||
Category = $category
|
||||
VendorVersion = $vendorVersion
|
||||
VersionArray = $versionArr
|
||||
VersionSortable = $versionSortable
|
||||
DateTime = $dt
|
||||
ComponentIds = $compIds
|
||||
})
|
||||
}
|
||||
|
||||
if ($rawPackages.Count -eq 0) { return @() }
|
||||
|
||||
# Sort newest first by VersionSortable (lexicographic works due to zero padding) then DateTime
|
||||
$sorted = $rawPackages | Sort-Object -Property @{ Expression = { $_.VersionSortable }; Descending = $true }, @{ Expression = { $_.DateTime }; Descending = $true }
|
||||
|
||||
$chosen = [System.Collections.Generic.List[pscustomobject]]::new()
|
||||
$assignedIds = [System.Collections.Generic.HashSet[string]]::new()
|
||||
|
||||
foreach ($pkg in $sorted) {
|
||||
$hasOverlap = $false
|
||||
foreach ($cid in $pkg.ComponentIds) {
|
||||
if ($assignedIds.Contains($cid)) { $hasOverlap = $true; break }
|
||||
}
|
||||
if ($hasOverlap) {
|
||||
WriteLog "Get-DellLatestDriverPackages: Skipping superseded package $($pkg.FileName) (shared componentID with newer package)."
|
||||
continue
|
||||
}
|
||||
|
||||
foreach ($cid in $pkg.ComponentIds) { [void]$assignedIds.Add($cid) }
|
||||
|
||||
$chosen.Add([pscustomobject]@{
|
||||
Path = $pkg.Path
|
||||
DownloadUrl = $pkg.DownloadUrl
|
||||
DriverFileName = $pkg.FileName
|
||||
Name = $pkg.Name
|
||||
Category = $pkg.Category
|
||||
VendorVersion = $pkg.VendorVersion
|
||||
DateTime = $pkg.DateTime
|
||||
ComponentIds = $pkg.ComponentIds
|
||||
})
|
||||
}
|
||||
|
||||
if ($chosen.Count -eq 0) {
|
||||
WriteLog "Get-DellLatestDriverPackages: No qualifying driver packages after supersedence."
|
||||
return @()
|
||||
}
|
||||
|
||||
WriteLog ("Get-DellLatestDriverPackages: Selected {0} package(s) after supersedence." -f $chosen.Count)
|
||||
return $chosen
|
||||
}
|
||||
|
||||
# Resolve a Dell per‑model CabUrl when missing by inspecting CatalogIndexPC
|
||||
function Resolve-DellCabUrlFromModel {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$DriversFolder,
|
||||
[Parameter()][string]$ModelDisplay,
|
||||
[Parameter()][string]$SystemId
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($SystemId) -and -not [string]::IsNullOrWhiteSpace($ModelDisplay)) {
|
||||
# Try to parse the trailing (XXXX) token (SystemId)
|
||||
if ($ModelDisplay -match '\(([0-9A-Fa-f]{4})\)\s*$') {
|
||||
$SystemId = $matches[1].ToUpperInvariant()
|
||||
}
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($SystemId)) {
|
||||
WriteLog "Resolve-DellCabUrlFromModel: No SystemId could be determined from '$ModelDisplay'."
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
$indexXml = Get-DellCatalogIndex -DriversFolder $DriversFolder
|
||||
# Reuse existing model parsing to avoid duplicating streaming logic
|
||||
$allModels = Get-DellClientModels -CatalogIndexXmlPath $indexXml
|
||||
$match = $allModels | Where-Object { $_.SystemId -eq $SystemId } | Select-Object -First 1
|
||||
if ($null -eq $match) {
|
||||
WriteLog "Resolve-DellCabUrlFromModel: SystemId '$SystemId' not found in CatalogIndexPC.xml."
|
||||
return $null
|
||||
}
|
||||
WriteLog "Resolve-DellCabUrlFromModel: Resolved CabUrl for '$($match.ModelDisplay)' -> $($match.CabUrl)"
|
||||
return [pscustomobject]@{
|
||||
Brand = $match.Brand
|
||||
ModelNumber = $match.ModelNumber
|
||||
SystemId = $match.SystemId
|
||||
CabRelativePath = $match.CabRelativePath
|
||||
CabUrl = $match.CabUrl
|
||||
ModelDisplay = $match.ModelDisplay
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Resolve-DellCabUrlFromModel: Failure resolving CabUrl for '$ModelDisplay' / SystemId '$SystemId' : $($_.Exception.Message)"
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Convert-DellVendorVersion,Compare-DellVendorVersion,Get-DellCatalogIndex,Get-DellClientModels,Get-DellLatestDriverPackages,Resolve-DellCabUrlFromModel
|
||||
@@ -22,7 +22,10 @@ function Compress-DriverFolderToWim {
|
||||
[string]$WimName, # Optional, defaults to folder name
|
||||
|
||||
[Parameter()]
|
||||
[string]$WimDescription # Optional, defaults to folder name
|
||||
[string]$WimDescription, # Optional, defaults to folder name
|
||||
|
||||
[Parameter()]
|
||||
[bool]$PreserveSource = $false # When $true, do not delete source folder; create marker for deferred cleanup
|
||||
)
|
||||
|
||||
WriteLog "Starting compression of folder '$SourceFolderPath' to '$DestinationWimPath'."
|
||||
@@ -66,14 +69,29 @@ function Compress-DriverFolderToWim {
|
||||
WriteLog "Successfully compressed '$SourceFolderPath' to '$DestinationWimPath' using dism.exe."
|
||||
|
||||
# Remove the source folder after successful compression
|
||||
WriteLog "Removing source driver folder: $SourceFolderPath"
|
||||
try {
|
||||
Remove-Item -Path $SourceFolderPath -Recurse -Force -ErrorAction Stop
|
||||
WriteLog "Successfully removed source folder '$SourceFolderPath'."
|
||||
if ($PreserveSource) {
|
||||
WriteLog "Preserving source driver folder for deferred WinPE driver harvesting: $SourceFolderPath"
|
||||
try {
|
||||
$markerFile = Join-Path -Path $SourceFolderPath -ChildPath '__PreservedForPEDrivers.txt'
|
||||
if (-not (Test-Path -Path $markerFile -PathType Leaf)) {
|
||||
New-Item -Path $markerFile -ItemType File -Force | Out-Null
|
||||
WriteLog "Created preservation marker file: $markerFile"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Warning: Failed to create preservation marker in $SourceFolderPath. Error: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Warning: Failed to remove source folder '$SourceFolderPath'. Error: $($_.Exception.Message)"
|
||||
# Do not fail the whole operation, just log a warning.
|
||||
else {
|
||||
WriteLog "Removing source driver folder: $SourceFolderPath"
|
||||
try {
|
||||
Remove-Item -Path $SourceFolderPath -Recurse -Force -ErrorAction Stop
|
||||
WriteLog "Successfully removed source folder '$SourceFolderPath'."
|
||||
}
|
||||
catch {
|
||||
WriteLog "Warning: Failed to remove source folder '$SourceFolderPath'. Error: $($_.Exception.Message)"
|
||||
# Do not fail the whole operation, just log a warning.
|
||||
}
|
||||
}
|
||||
|
||||
return $true # Indicate success
|
||||
@@ -137,31 +155,185 @@ function Update-DriverMappingJson {
|
||||
$updatedCount = 0
|
||||
$addedCount = 0
|
||||
|
||||
$hpSystemIdCache = @{}
|
||||
$normalizeHpName = {
|
||||
param([string]$text)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($text)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return ([regex]::Replace($text.ToLowerInvariant(), '[^a-z0-9]', ''))
|
||||
}
|
||||
$getHpSystemId = {
|
||||
param([string]$modelName)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($modelName)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($hpSystemIdCache.ContainsKey($modelName)) {
|
||||
return $hpSystemIdCache[$modelName]
|
||||
}
|
||||
|
||||
$hpFolder = Join-Path -Path $DriversFolder -ChildPath 'HP'
|
||||
if (-not (Test-Path -Path $hpFolder -PathType Container)) {
|
||||
$hpSystemIdCache[$modelName] = $null
|
||||
return $null
|
||||
}
|
||||
|
||||
$platformListXml = Join-Path -Path $hpFolder -ChildPath 'PlatformList.xml'
|
||||
$platformListCab = Join-Path -Path $hpFolder -ChildPath 'platformList.cab'
|
||||
if (-not (Test-Path -Path $platformListXml -PathType Leaf)) {
|
||||
try {
|
||||
WriteLog "Attempting to refresh HP PlatformList.xml for SystemID lookup."
|
||||
Start-BitsTransferWithRetry -Source 'https://hpia.hpcloud.hp.com/ref/platformList.cab' -Destination $platformListCab -ErrorAction Stop
|
||||
if (Test-Path -Path $platformListXml) { Remove-Item -Path $platformListXml -Force -ErrorAction SilentlyContinue }
|
||||
Invoke-Process -FilePath "expand.exe" -ArgumentList @("`"$platformListCab`"", "`"$platformListXml`"") -ErrorAction Stop | Out-Null
|
||||
if (Test-Path -Path $platformListCab) { Remove-Item -Path $platformListCab -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to refresh HP PlatformList.xml: $($_.Exception.Message)"
|
||||
$hpSystemIdCache[$modelName] = $null
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
[xml]$platformListContent = Get-Content -Path $platformListXml -Raw -Encoding UTF8 -ErrorAction Stop
|
||||
|
||||
$targetName = $modelName.Trim()
|
||||
$normalizedTarget = & $normalizeHpName $targetName
|
||||
|
||||
$modelMatch = $platformListContent.ImagePal.Platform | Where-Object {
|
||||
[string]::Equals($_.ProductName.'#text'.Trim(), $targetName, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
} | Select-Object -First 1
|
||||
|
||||
if (-not $modelMatch -and $normalizedTarget) {
|
||||
$modelMatch = $platformListContent.ImagePal.Platform | Where-Object {
|
||||
$candidateName = $_.ProductName.'#text'
|
||||
$normalizedCandidate = & $normalizeHpName $candidateName
|
||||
$normalizedCandidate -eq $normalizedTarget
|
||||
} | Select-Object -First 1
|
||||
}
|
||||
|
||||
if (-not $modelMatch -and $normalizedTarget) {
|
||||
$modelMatch = $platformListContent.ImagePal.Platform | Where-Object {
|
||||
$candidateName = $_.ProductName.'#text'
|
||||
$normalizedCandidate = & $normalizeHpName $candidateName
|
||||
($normalizedCandidate -like "*$normalizedTarget*") -or ($normalizedTarget -like "*$normalizedCandidate*")
|
||||
} | Select-Object -First 1
|
||||
}
|
||||
|
||||
if ($modelMatch -and -not [string]::IsNullOrWhiteSpace($modelMatch.SystemID)) {
|
||||
$resolvedId = $modelMatch.SystemID.Trim().ToUpperInvariant()
|
||||
$hpSystemIdCache[$modelName] = $resolvedId
|
||||
return $resolvedId
|
||||
}
|
||||
else {
|
||||
WriteLog "HP SystemId lookup: no match found in PlatformList.xml for model '$modelName'."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to parse HP PlatformList.xml for model '$modelName': $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
$hpSystemIdCache[$modelName] = $null
|
||||
return $null
|
||||
}
|
||||
|
||||
foreach ($driver in $DownloadedDrivers) {
|
||||
# Skip if any required property is missing or null
|
||||
if (-not $driver.PSObject.Properties['Make'] -or -not $driver.PSObject.Properties['Model'] -or -not $driver.PSObject.Properties['DriverPath'] -or [string]::IsNullOrWhiteSpace($driver.DriverPath)) {
|
||||
WriteLog "Skipping driver entry due to missing or empty Make, Model, or DriverPath. Details: $(($driver | ConvertTo-Json -Compress -Depth 3))"
|
||||
continue
|
||||
}
|
||||
|
||||
# Find existing entry
|
||||
$systemIdValue = $null
|
||||
$machineTypeValue = $null
|
||||
|
||||
if ($driver.PSObject.Properties['SystemId'] -and -not [string]::IsNullOrWhiteSpace($driver.SystemId)) {
|
||||
$systemIdValue = $driver.SystemId.Trim().ToUpperInvariant()
|
||||
}
|
||||
if ($driver.PSObject.Properties['MachineType'] -and -not [string]::IsNullOrWhiteSpace($driver.MachineType)) {
|
||||
$machineTypeValue = $driver.MachineType.Trim()
|
||||
}
|
||||
|
||||
switch ($driver.Make) {
|
||||
'Dell' {
|
||||
if (-not $systemIdValue -and $driver.Model -match '\(([^)]+)\)\s*$') {
|
||||
$systemIdValue = $matches[1].Trim().ToUpperInvariant()
|
||||
}
|
||||
}
|
||||
'HP' {
|
||||
if (-not $systemIdValue) {
|
||||
$systemIdValue = & $getHpSystemId $driver.Model
|
||||
}
|
||||
}
|
||||
'Lenovo' {
|
||||
if (-not $machineTypeValue -and $driver.Model -match '\(([^)]+)\)\s*$') {
|
||||
$machineTypeValue = $matches[1].Trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$existingEntry = $mappingList | Where-Object { $_.Manufacturer -eq $driver.Make -and $_.Model -eq $driver.Model } | Select-Object -First 1
|
||||
|
||||
if ($null -ne $existingEntry) {
|
||||
# Update existing entry if the path is different
|
||||
$entryUpdated = $false
|
||||
if ($existingEntry.DriverPath -ne $driver.DriverPath) {
|
||||
WriteLog "Updating driver path for '$($driver.Make) - $($driver.Model)' from '$($existingEntry.DriverPath)' to '$($driver.DriverPath)'."
|
||||
$existingEntry.DriverPath = $driver.DriverPath
|
||||
$entryUpdated = $true
|
||||
}
|
||||
|
||||
if ($driver.Make -in @('HP', 'Dell') -and -not [string]::IsNullOrWhiteSpace($systemIdValue)) {
|
||||
if ($existingEntry.PSObject.Properties['SystemId']) {
|
||||
if ($existingEntry.SystemId -ne $systemIdValue) {
|
||||
WriteLog "Updating SystemId for '$($driver.Make) - $($driver.Model)' to '$systemIdValue'."
|
||||
$existingEntry.SystemId = $systemIdValue
|
||||
$entryUpdated = $true
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Adding SystemId '$systemIdValue' for '$($driver.Make) - $($driver.Model)'."
|
||||
$existingEntry | Add-Member -NotePropertyName SystemId -NotePropertyValue $systemIdValue
|
||||
$entryUpdated = $true
|
||||
}
|
||||
}
|
||||
|
||||
if ($driver.Make -eq 'Lenovo' -and -not [string]::IsNullOrWhiteSpace($machineTypeValue)) {
|
||||
if ($existingEntry.PSObject.Properties['MachineType']) {
|
||||
if ($existingEntry.MachineType -ne $machineTypeValue) {
|
||||
WriteLog "Updating MachineType for '$($driver.Make) - $($driver.Model)' to '$machineTypeValue'."
|
||||
$existingEntry.MachineType = $machineTypeValue
|
||||
$entryUpdated = $true
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Adding MachineType '$machineTypeValue' for '$($driver.Make) - $($driver.Model)'."
|
||||
$existingEntry | Add-Member -NotePropertyName MachineType -NotePropertyValue $machineTypeValue
|
||||
$entryUpdated = $true
|
||||
}
|
||||
}
|
||||
|
||||
if ($entryUpdated) {
|
||||
$updatedCount++
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Add new entry
|
||||
$newEntry = [PSCustomObject]@{
|
||||
Manufacturer = $driver.Make
|
||||
Model = $driver.Model
|
||||
DriverPath = $driver.DriverPath
|
||||
}
|
||||
|
||||
if ($driver.Make -in @('HP', 'Dell') -and -not [string]::IsNullOrWhiteSpace($systemIdValue)) {
|
||||
$newEntry | Add-Member -NotePropertyName SystemId -NotePropertyValue $systemIdValue
|
||||
}
|
||||
if ($driver.Make -eq 'Lenovo' -and -not [string]::IsNullOrWhiteSpace($machineTypeValue)) {
|
||||
$newEntry | Add-Member -NotePropertyName MachineType -NotePropertyValue $machineTypeValue
|
||||
}
|
||||
|
||||
$mappingList.Add($newEntry)
|
||||
WriteLog "Adding new mapping for '$($driver.Make) - $($driver.Model)' with path '$($driver.DriverPath)'."
|
||||
$addedCount++
|
||||
@@ -274,111 +446,328 @@ function Get-LenovoPSREFToken {
|
||||
if your alternative works is to see if you can retrieve 100e, 300w, 500w, etc. These don't show up in catalogv2.xml, but they do in PSREF.
|
||||
#>
|
||||
|
||||
# Path to Edge
|
||||
$edgeExe = "$Env:ProgramFiles (x86)\Microsoft\Edge\Application\msedge.exe"
|
||||
$token = $null
|
||||
$socket = $null
|
||||
$edgeProcess = $null
|
||||
$tempProfile = $null
|
||||
$port = $null
|
||||
|
||||
# Any free port works. 9222 is common.
|
||||
$port = 9222
|
||||
$uri = 'https://psref.lenovo.com'
|
||||
|
||||
# Headless run with remote debugging.
|
||||
$flags = "--headless=new --disable-gpu --remote-debugging-port=$port $uri"
|
||||
$edge = Start-Process -FilePath $edgeExe -ArgumentList $flags -PassThru
|
||||
Writelog "Edge process started with PID: $($edge.Id)."
|
||||
|
||||
# Wait a short moment so the target appears.
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
# Find the first page target.
|
||||
$targets = Invoke-RestMethod "http://localhost:$port/json"
|
||||
$wsUrl = ($targets | Where-Object type -eq 'page')[0].webSocketDebuggerUrl
|
||||
|
||||
# Connect to that WebSocket.
|
||||
$socket = [System.Net.WebSockets.ClientWebSocket]::new()
|
||||
$socket.ConnectAsync($wsUrl, [Threading.CancellationToken]::None).Wait()
|
||||
|
||||
# Helper to send a DevTools command.
|
||||
function Send-DevToolsCommand {
|
||||
param([int]$id, [string]$method, [hashtable]$params = @{})
|
||||
$cmd = @{ id = $id; method = $method; params = $params } |
|
||||
ConvertTo-Json -Compress
|
||||
$data = [Text.Encoding]::UTF8.GetBytes($cmd)
|
||||
$socket.SendAsync([ArraySegment[byte]]$data, 'Text', $true,
|
||||
[Threading.CancellationToken]::None).Wait()
|
||||
function Get-FreeLocalTcpPort {
|
||||
$listener = $null
|
||||
try {
|
||||
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
|
||||
$listener.Start()
|
||||
$endpoint = [System.Net.IPEndPoint]$listener.LocalEndpoint
|
||||
return $endpoint.Port
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $listener) {
|
||||
$listener.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Ask the page to return localStorage['asut'].
|
||||
Send-DevToolsCommand -id 1 -method 'Runtime.evaluate' -params @{
|
||||
expression = "localStorage.getItem('asut')"
|
||||
function Get-EdgeDevToolsPageTarget {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][int]$Port,
|
||||
[int]$MaxAttempts = 20,
|
||||
[int]$DelayMilliseconds = 500,
|
||||
[string]$UrlContains
|
||||
)
|
||||
|
||||
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
|
||||
try {
|
||||
$targets = Invoke-RestMethod -Uri "http://localhost:$Port/json" -ErrorAction Stop
|
||||
if ($null -ne $targets) {
|
||||
if ($targets -isnot [System.Array]) { $targets = @($targets) }
|
||||
$pageTargets = $targets | Where-Object { $_.type -eq 'page' }
|
||||
if (-not [string]::IsNullOrWhiteSpace($UrlContains)) {
|
||||
$pageTargets = $pageTargets | Where-Object {
|
||||
-not [string]::IsNullOrWhiteSpace($_.url) -and $_.url -like "*$UrlContains*"
|
||||
}
|
||||
}
|
||||
|
||||
$target = $pageTargets | Select-Object -First 1
|
||||
if ($null -ne $target) {
|
||||
return $target
|
||||
}
|
||||
|
||||
WriteLog "DevTools endpoint on port $Port returned targets but no page matched the criteria (attempt $attempt of $MaxAttempts)."
|
||||
}
|
||||
else {
|
||||
WriteLog "DevTools endpoint on port $Port returned no targets (attempt $attempt of $MaxAttempts)."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "DevTools endpoint on port $Port not ready (attempt $attempt of $MaxAttempts). Error: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds $DelayMilliseconds
|
||||
}
|
||||
|
||||
throw "Edge DevTools endpoint on port $Port did not expose a matching page target after $MaxAttempts attempts."
|
||||
}
|
||||
|
||||
# Receive frames until the whole message arrives.
|
||||
$ms = New-Object System.IO.MemoryStream
|
||||
$buf = New-Object byte[] 8192
|
||||
do {
|
||||
$seg = [ArraySegment[byte]]::new($buf)
|
||||
$res = $socket.ReceiveAsync($seg,
|
||||
[Threading.CancellationToken]::None).Result
|
||||
$ms.Write($buf, 0, $res.Count)
|
||||
} until ($res.EndOfMessage)
|
||||
|
||||
$ms.Position = 0
|
||||
$json = ([System.IO.StreamReader]::new($ms, [Text.Encoding]::UTF8)).ReadToEnd() |
|
||||
ConvertFrom-Json
|
||||
|
||||
$token = $json.result.result.value
|
||||
# Concatenate the token value with X-PSREF-USER-TOKEN=
|
||||
$token = "X-PSREF-USER-TOKEN=$token"
|
||||
WriteLog "Retrieved Lenovo PSREF token: $token"
|
||||
|
||||
# Clean up.
|
||||
$socket.Dispose()
|
||||
|
||||
if ($null -ne $socket) {
|
||||
$socket.Dispose()
|
||||
}
|
||||
|
||||
# Find the PID listening on the debugging port for reliable termination.
|
||||
$listeningPid = $null
|
||||
try {
|
||||
# Find the process listening on the specific port. The regex now looks for the local address and port, followed by anything, then LISTENING.
|
||||
# Dots are escaped for literal matching.
|
||||
$netstatOutput = netstat -ano -p TCP | Where-Object { $_ -match "127\.0\.0\.1:$port.*LISTENING" }
|
||||
if ($netstatOutput) {
|
||||
# The last number in the line is the PID
|
||||
$listeningPid = ($netstatOutput -split '\s+')[-1]
|
||||
WriteLog "Found Edge process PID $listeningPid listening on port $port. This is the process we will terminate."
|
||||
$ffuDevelopmentRoot = Split-Path -Path $PSScriptRoot -Parent
|
||||
WriteLog "Derived FFUDevelopmentPath from module path: $ffuDevelopmentRoot"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ffuDevelopmentRoot)) {
|
||||
throw "FFUDevelopmentPath could not be resolved. Unable to create Edge profile."
|
||||
}
|
||||
else {
|
||||
WriteLog "Could not find any process listening on port $port."
|
||||
|
||||
if (-not (Test-Path -Path $ffuDevelopmentRoot -PathType Container)) {
|
||||
throw "Resolved FFUDevelopmentPath '$ffuDevelopmentRoot' does not exist."
|
||||
}
|
||||
|
||||
$tempProfile = Join-Path -Path $ffuDevelopmentRoot -ChildPath ("edge-psref-" + [guid]::NewGuid())
|
||||
WriteLog "Creating temporary Edge profile at $tempProfile."
|
||||
New-Item -ItemType Directory -Path $tempProfile -Force | Out-Null
|
||||
|
||||
$edgeExe = "$Env:ProgramFiles (x86)\Microsoft\Edge\Application\msedge.exe"
|
||||
$uri = 'https://psref.lenovo.com'
|
||||
$port = Get-FreeLocalTcpPort
|
||||
WriteLog "Using Edge DevTools port $port for Lenovo PSREF token retrieval."
|
||||
|
||||
$flags = "--headless=new --disable-gpu --remote-debugging-port=$port $uri --user-data-dir=`"$tempProfile`""
|
||||
$edgeProcess = Start-Process -FilePath $edgeExe -ArgumentList $flags -PassThru
|
||||
WriteLog "Edge process started with PID: $($edgeProcess.Id)."
|
||||
|
||||
$pageTarget = Get-EdgeDevToolsPageTarget -Port $port -MaxAttempts 40 -DelayMilliseconds 500 -UrlContains 'psref.lenovo.com'
|
||||
if (-not [string]::IsNullOrWhiteSpace($pageTarget.url)) {
|
||||
WriteLog "Selected DevTools target URL: $($pageTarget.url)"
|
||||
}
|
||||
|
||||
$wsUrl = $pageTarget.webSocketDebuggerUrl
|
||||
if ([string]::IsNullOrWhiteSpace($wsUrl)) {
|
||||
throw "Edge DevTools page target on port $port did not provide a WebSocket URL."
|
||||
}
|
||||
|
||||
$socket = [System.Net.WebSockets.ClientWebSocket]::new()
|
||||
$socket.ConnectAsync($wsUrl, [Threading.CancellationToken]::None).Wait()
|
||||
|
||||
function Send-DevToolsCommand {
|
||||
param([int]$id, [string]$method, [hashtable]$params = @{})
|
||||
$cmd = @{ id = $id; method = $method; params = $params } | ConvertTo-Json -Compress
|
||||
$data = [Text.Encoding]::UTF8.GetBytes($cmd)
|
||||
$socket.SendAsync([ArraySegment[byte]]$data, 'Text', $true, [Threading.CancellationToken]::None).Wait()
|
||||
}
|
||||
|
||||
$buffer = New-Object byte[] 8192
|
||||
|
||||
function Invoke-DevToolsValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][int]$CommandId,
|
||||
[Parameter(Mandatory = $true)][string]$Expression,
|
||||
[int]$MaxPolls = 25
|
||||
)
|
||||
|
||||
Send-DevToolsCommand -id $CommandId -method 'Runtime.evaluate' -params @{
|
||||
expression = $Expression
|
||||
returnByValue = $true
|
||||
awaitPromise = $true
|
||||
}
|
||||
|
||||
for ($poll = 1; $poll -le $MaxPolls; $poll++) {
|
||||
$localStream = $null
|
||||
try {
|
||||
$localStream = New-Object System.IO.MemoryStream
|
||||
do {
|
||||
$segment = [ArraySegment[byte]]::new($buffer)
|
||||
$result = $socket.ReceiveAsync($segment, [Threading.CancellationToken]::None).Result
|
||||
$localStream.Write($buffer, 0, $result.Count)
|
||||
} until ($result.EndOfMessage)
|
||||
|
||||
$jsonBytes = $localStream.ToArray()
|
||||
$jsonText = [Text.Encoding]::UTF8.GetString($jsonBytes)
|
||||
$previewPayload = $jsonText
|
||||
if (-not [string]::IsNullOrEmpty($previewPayload) -and $previewPayload.Length -gt 500) {
|
||||
$previewPayload = $previewPayload.Substring(0, 500) + '...'
|
||||
}
|
||||
WriteLog "DevTools eval payload (cmd $CommandId, poll $poll): $previewPayload"
|
||||
|
||||
$message = $null
|
||||
try {
|
||||
$message = $jsonText | ConvertFrom-Json
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to parse DevTools eval payload for command id $CommandId (poll $poll): $($_.Exception.Message)"
|
||||
continue
|
||||
}
|
||||
|
||||
if ($message.PSObject.Properties['id'] -and $message.id -eq $CommandId) {
|
||||
if ($message.PSObject.Properties['error']) {
|
||||
$errorMessage = $message.error.message
|
||||
throw "Edge DevTools reported an error for expression '$Expression': $errorMessage"
|
||||
}
|
||||
|
||||
if ($message.PSObject.Properties['result'] -and $message.result.PSObject.Properties['result']) {
|
||||
$innerResult = $message.result.result
|
||||
return [PSCustomObject]@{
|
||||
Value = $innerResult.value
|
||||
Type = $innerResult.type
|
||||
Subtype = $innerResult.subtype
|
||||
}
|
||||
}
|
||||
|
||||
$serializedMessage = $message | ConvertTo-Json -Compress -Depth 5
|
||||
WriteLog "DevTools response for command id $CommandId lacked result data. Message: $serializedMessage"
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($message.PSObject.Properties['method']) {
|
||||
WriteLog "Received DevTools event '$($message.method)' while waiting for command id $CommandId."
|
||||
}
|
||||
else {
|
||||
WriteLog "Received DevTools message without id or method while waiting for command id $CommandId."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $localStream) {
|
||||
$localStream.Dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw "No DevTools response received for command id $CommandId after $MaxPolls polls."
|
||||
}
|
||||
|
||||
WriteLog "Waiting for PSREF page to initialize local storage context."
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
$commandCounter = 1000
|
||||
$rawToken = $null
|
||||
$maxTokenAttempts = 12
|
||||
for ($attempt = 1; $attempt -le $maxTokenAttempts -and [string]::IsNullOrWhiteSpace($rawToken); $attempt++) {
|
||||
$commandCounter++
|
||||
$tokenResponse = Invoke-DevToolsValue -CommandId $commandCounter -Expression "window.localStorage?.getItem('asut')" -MaxPolls 25
|
||||
if ($null -ne $tokenResponse -and -not [string]::IsNullOrWhiteSpace($tokenResponse.Value)) {
|
||||
$rawToken = $tokenResponse.Value
|
||||
WriteLog "DevTools response for command id $commandCounter returned token length $($rawToken.Length)."
|
||||
break
|
||||
}
|
||||
|
||||
WriteLog "Lenovo PSREF token not yet available (attempt $attempt of $maxTokenAttempts)."
|
||||
|
||||
$commandCounter++
|
||||
$keysResponse = Invoke-DevToolsValue -CommandId $commandCounter -Expression "JSON.stringify(Object.keys(window.localStorage || {}))" -MaxPolls 10
|
||||
if ($null -ne $keysResponse -and -not [string]::IsNullOrWhiteSpace($keysResponse.Value)) {
|
||||
WriteLog "Current localStorage keys: $($keysResponse.Value)"
|
||||
}
|
||||
|
||||
$commandCounter++
|
||||
$cookieResponse = Invoke-DevToolsValue -CommandId $commandCounter -Expression "document.cookie" -MaxPolls 10
|
||||
if ($null -ne $cookieResponse -and -not [string]::IsNullOrWhiteSpace($cookieResponse.Value)) {
|
||||
WriteLog "document.cookie contents: $($cookieResponse.Value)"
|
||||
$cookieEntry = ($cookieResponse.Value -split ';') | ForEach-Object { $_.Trim() } | Where-Object { $_ -like 'asut=*' } | Select-Object -First 1
|
||||
if ($cookieEntry) {
|
||||
$rawToken = $cookieEntry.Substring($cookieEntry.IndexOf('=') + 1)
|
||||
WriteLog "Extracted Lenovo PSREF token from cookies with length $($rawToken.Length)."
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds 750
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($rawToken)) {
|
||||
throw "Received empty Lenovo PSREF token from Edge DevTools after $maxTokenAttempts attempts."
|
||||
}
|
||||
|
||||
$token = "X-PSREF-USER-TOKEN=$rawToken"
|
||||
WriteLog "Retrieved Lenovo PSREF token: $token"
|
||||
}
|
||||
catch {
|
||||
WriteLog "Could not run netstat to find listening PID. Error: $($_.Exception.Message)"
|
||||
WriteLog "Failed to retrieve Lenovo PSREF token. Error: $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
|
||||
# Determine the correct PID to kill. Prioritize the one found via netstat.
|
||||
$pidToKill = $null
|
||||
if ($listeningPid) {
|
||||
$pidToKill = $listeningPid
|
||||
}
|
||||
elseif ($null -ne $edgeProcess -and -not $edgeProcess.HasExited) {
|
||||
$pidToKill = $edgeProcess.Id
|
||||
WriteLog "Could not find listening process via netstat. Falling back to initial Edge process PID $($pidToKill) for termination."
|
||||
}
|
||||
|
||||
if ($pidToKill) {
|
||||
WriteLog "Attempting to terminate Edge process tree with PID: $pidToKill"
|
||||
try {
|
||||
taskkill /PID $pidToKill /T /F | Out-Null
|
||||
WriteLog "Successfully issued termination command for Edge process tree with PID: $pidToKill."
|
||||
finally {
|
||||
if ($null -ne $socket) {
|
||||
try {
|
||||
$socket.Dispose()
|
||||
WriteLog "Edge DevTools WebSocket disposed."
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error disposing Edge DevTools WebSocket: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to terminate Edge process tree with PID: $pidToKill. It may have already closed. Error: $($_.Exception.Message)"
|
||||
|
||||
$listeningPid = $null
|
||||
if ($null -ne $port) {
|
||||
try {
|
||||
$netstatOutput = netstat -ano -p TCP | Where-Object { $_ -match "127\.0\.0\.1:$port.*LISTENING" }
|
||||
if ($netstatOutput) {
|
||||
$listeningPid = ($netstatOutput -split '\s+')[-1]
|
||||
WriteLog "Found Edge process PID $listeningPid listening on port $port."
|
||||
}
|
||||
else {
|
||||
WriteLog "No process reported as listening on port $port."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Could not run netstat to find listening PID for port $port. Error: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
$pidToKill = $null
|
||||
if ($null -ne $listeningPid) {
|
||||
$pidToKill = $listeningPid
|
||||
}
|
||||
elseif ($null -ne $edgeProcess -and -not $edgeProcess.HasExited) {
|
||||
$pidToKill = $edgeProcess.Id
|
||||
WriteLog "Falling back to initial Edge process PID $pidToKill for termination."
|
||||
}
|
||||
|
||||
if ($null -ne $pidToKill) {
|
||||
try {
|
||||
taskkill /PID $pidToKill /T /F | Out-Null
|
||||
WriteLog "Issued termination command for Edge process tree with PID: $pidToKill."
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to terminate Edge process tree with PID: $pidToKill. Error: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "No active Edge process found to terminate."
|
||||
}
|
||||
|
||||
if ($null -ne $edgeProcess) {
|
||||
try {
|
||||
$edgeProcess.WaitForExit(3000) | Out-Null
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error while waiting for Edge process PID $($edgeProcess.Id) to exit: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds 250
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($tempProfile) -and (Test-Path -Path $tempProfile -PathType Container)) {
|
||||
$maxRemoveAttempts = 5
|
||||
$originalProgressPreference = $ProgressPreference
|
||||
try {
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
for ($removeAttempt = 1; $removeAttempt -le $maxRemoveAttempts; $removeAttempt++) {
|
||||
try {
|
||||
Remove-Item -Path $tempProfile -Recurse -Force -ErrorAction Stop
|
||||
WriteLog "Removed temporary Edge profile at $tempProfile."
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($removeAttempt -eq $maxRemoveAttempts) {
|
||||
WriteLog "Failed to remove temporary Edge profile at $tempProfile after $maxRemoveAttempts attempts. Error: $($_.Exception.Message)"
|
||||
}
|
||||
else {
|
||||
WriteLog "Temporary Edge profile still locked (attempt $removeAttempt of $maxRemoveAttempts). Retrying..."
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$ProgressPreference = $originalProgressPreference
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "No active Edge process found to terminate."
|
||||
}
|
||||
|
||||
return $token
|
||||
|
||||
@@ -156,13 +156,15 @@ function Invoke-ParallelProcessing {
|
||||
# Execute the appropriate background task based on $localTaskType
|
||||
switch ($localTaskType) {
|
||||
'WingetDownload' {
|
||||
# Pass the progress queue to the task function
|
||||
# Pass the progress queue and SkipWin32Json to the task function
|
||||
$wingetTaskArgs = @{
|
||||
ApplicationItemData = $currentItem
|
||||
AppListJsonPath = $localJobArgs['AppListJsonPath']
|
||||
AppsPath = $localJobArgs['AppsPath']
|
||||
OrchestrationPath = $localJobArgs['OrchestrationPath']
|
||||
ProgressQueue = $localProgressQueue
|
||||
WindowsArch = $localJobArgs['WindowsArch']
|
||||
SkipWin32Json = [bool]$localJobArgs['SkipWin32Json']
|
||||
}
|
||||
$taskResult = Start-WingetAppDownloadTask @wingetTaskArgs
|
||||
if ($null -ne $taskResult) {
|
||||
@@ -208,7 +210,8 @@ function Invoke-ParallelProcessing {
|
||||
-Headers $localJobArgs['Headers'] `
|
||||
-UserAgent $localJobArgs['UserAgent'] `
|
||||
-ProgressQueue $localProgressQueue `
|
||||
-CompressToWim $localJobArgs['CompressToWim']
|
||||
-CompressToWim $localJobArgs['CompressToWim'] `
|
||||
-PreserveSourceOnCompress $localJobArgs['PreserveSourceOnCompress']
|
||||
}
|
||||
'Dell' {
|
||||
$taskResult = Save-DellDriversTask -DriverItemData $currentItem `
|
||||
@@ -216,7 +219,8 @@ function Invoke-ParallelProcessing {
|
||||
-WindowsArch $localJobArgs['WindowsArch'] `
|
||||
-WindowsRelease $localJobArgs['WindowsRelease'] `
|
||||
-ProgressQueue $localProgressQueue `
|
||||
-CompressToWim $localJobArgs['CompressToWim']
|
||||
-CompressToWim $localJobArgs['CompressToWim'] `
|
||||
-PreserveSourceOnCompress $localJobArgs['PreserveSourceOnCompress']
|
||||
}
|
||||
'HP' {
|
||||
$taskResult = Save-HPDriversTask -DriverItemData $currentItem `
|
||||
@@ -225,7 +229,8 @@ function Invoke-ParallelProcessing {
|
||||
-WindowsRelease $localJobArgs['WindowsRelease'] `
|
||||
-WindowsVersion $localJobArgs['WindowsVersion'] `
|
||||
-ProgressQueue $localProgressQueue `
|
||||
-CompressToWim $localJobArgs['CompressToWim']
|
||||
-CompressToWim $localJobArgs['CompressToWim'] `
|
||||
-PreserveSourceOnCompress $localJobArgs['PreserveSourceOnCompress']
|
||||
}
|
||||
'Lenovo' {
|
||||
$taskResult = Save-LenovoDriversTask -DriverItemData $currentItem `
|
||||
@@ -234,7 +239,8 @@ function Invoke-ParallelProcessing {
|
||||
-Headers $localJobArgs['Headers'] `
|
||||
-UserAgent $localJobArgs['UserAgent'] `
|
||||
-ProgressQueue $localProgressQueue `
|
||||
-CompressToWim $localJobArgs['CompressToWim']
|
||||
-CompressToWim $localJobArgs['CompressToWim'] `
|
||||
-PreserveSourceOnCompress $localJobArgs['PreserveSourceOnCompress']
|
||||
}
|
||||
default {
|
||||
$unsupportedMakeMessage = "Error: Unsupported Make '$make' for driver download."
|
||||
@@ -264,7 +270,7 @@ function Invoke-ParallelProcessing {
|
||||
else {
|
||||
# Fallback for any task that *still* doesn't return 'Success'. This is now the exceptional case.
|
||||
WriteLog "Warning: Task for '$taskSpecificIdentifier' did not return a 'Success' property. Inferring from status: '$($taskResult.Status)'"
|
||||
if ($taskResult.Status -like 'Completed*' -or $taskResult.Status -like 'Already downloaded*') {
|
||||
if ($taskResult.Status -like 'Completed*' -or $taskResult.Status -like 'Already downloaded*' -or $taskResult.Status -like 'Compression successful*') {
|
||||
$resultCode = 0 # Treat as success
|
||||
}
|
||||
else {
|
||||
|
||||
@@ -20,9 +20,11 @@ function Get-Application {
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$AppsPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ApplicationArch,
|
||||
[string]$WindowsArch,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OrchestrationPath
|
||||
[string]$OrchestrationPath,
|
||||
[switch]$SkipWin32Json
|
||||
)
|
||||
|
||||
# Block Company Portal from winget source
|
||||
@@ -48,6 +50,29 @@ function Get-Application {
|
||||
# Check if the folder is not empty.
|
||||
if (Get-ChildItem -Path $appBaseFolderPathForCheck -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1) {
|
||||
WriteLog "Application '$AppName' appears to be already downloaded as content exists in '$appBaseFolderPathForCheck'. Skipping download."
|
||||
|
||||
# Add silent install command(s) only if not skipping JSON generation (build-time scenario)
|
||||
$appIsWin32Existing = ($Source -eq 'winget' -or ($Source -eq 'msstore' -and $AppId.StartsWith('XP')))
|
||||
if ($appIsWin32Existing -and -not $SkipWin32Json) {
|
||||
$win32BasePath = Join-Path -Path "$AppsPath\Win32" -ChildPath $AppName
|
||||
if (Test-Path -Path $win32BasePath -PathType Container) {
|
||||
$archFolders = Get-ChildItem -Path $win32BasePath -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -in @('x86', 'x64', 'arm64') }
|
||||
if ($archFolders) {
|
||||
foreach ($archFolder in $archFolders) {
|
||||
WriteLog "Adding silent install command for pre-downloaded $AppName ($($archFolder.Name)) to $OrchestrationPath\WinGetWin32Apps.json"
|
||||
Add-Win32SilentInstallCommand -AppFolder $AppName -AppFolderPath $archFolder.FullName -OrchestrationPath $OrchestrationPath -SubFolder $archFolder.Name | Out-Null
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Adding silent install command for pre-downloaded $AppName to $OrchestrationPath\WinGetWin32Apps.json"
|
||||
Add-Win32SilentInstallCommand -AppFolder $AppName -AppFolderPath $win32BasePath -OrchestrationPath $OrchestrationPath | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($appIsWin32Existing -and $SkipWin32Json) {
|
||||
WriteLog "Skipping WinGetWin32Apps.json regeneration for pre-downloaded $AppName (UI mode)."
|
||||
}
|
||||
|
||||
return 0 # Success, already present
|
||||
}
|
||||
}
|
||||
@@ -64,8 +89,8 @@ function Get-Application {
|
||||
return 1 # Return error code
|
||||
}
|
||||
|
||||
# Determine architectures to download
|
||||
$architecturesToDownload = if ($WindowsArch -eq 'x86 x64') { @('x86', 'x64') } else { @($WindowsArch) }
|
||||
# Determine architectures to download (ApplicationArch controls download set; WindowsArch (optional) used later for pruning store installers)
|
||||
$architecturesToDownload = if ($ApplicationArch -eq 'x86 x64') { @('x86', 'x64') } else { @($ApplicationArch) }
|
||||
$overallResult = 0
|
||||
|
||||
# For msstore, we don't specify architecture, so we only need to loop once.
|
||||
@@ -83,11 +108,13 @@ function Get-Application {
|
||||
|
||||
# Determine app type and folder path
|
||||
$appIsWin32 = ($Source -eq 'msstore' -and $AppId.StartsWith("XP"))
|
||||
$sanitizedAppName = ConvertTo-SafeName -Name $AppName
|
||||
if ($sanitizedAppName -ne $AppName) { WriteLog "Sanitized app name: '$AppName' -> '$sanitizedAppName'" }
|
||||
if ($Source -eq 'winget' -or $appIsWin32) {
|
||||
$appBaseFolderPath = Join-Path -Path "$AppsPath\Win32" -ChildPath $AppName
|
||||
$appBaseFolderPath = Join-Path -Path "$AppsPath\Win32" -ChildPath $sanitizedAppName
|
||||
}
|
||||
else {
|
||||
$appBaseFolderPath = Join-Path -Path "$AppsPath\MSStore" -ChildPath $AppName
|
||||
$appBaseFolderPath = Join-Path -Path "$AppsPath\MSStore" -ChildPath $sanitizedAppName
|
||||
}
|
||||
|
||||
# If downloading multiple archs for a Win32 app, create a subfolder
|
||||
@@ -196,8 +223,14 @@ function Get-Application {
|
||||
}
|
||||
# If app is in Win32 folder, add the silent install command to the WinGetWin32Apps.json file
|
||||
elseif ($appFolderPath -match 'Win32') {
|
||||
WriteLog "$AppName is a Win32 app. Adding silent install command to $OrchestrationPath\WinGetWin32Apps.json"
|
||||
$result = Add-Win32SilentInstallCommand -AppFolder $AppName -AppFolderPath $appFolderPath -OrchestrationPath $OrchestrationPath -SubFolder $subFolderForCommand
|
||||
if (-not $SkipWin32Json) {
|
||||
WriteLog "$AppName is a Win32 app. Adding silent install command to $OrchestrationPath\WinGetWin32Apps.json"
|
||||
$result = Add-Win32SilentInstallCommand -AppFolder $AppName -AppFolderPath $appFolderPath -OrchestrationPath $OrchestrationPath -SubFolder $subFolderForCommand
|
||||
}
|
||||
else {
|
||||
WriteLog "$AppName is a Win32 app. Skipping WinGetWin32Apps.json generation (UI mode)."
|
||||
$result = 0
|
||||
}
|
||||
}
|
||||
else {
|
||||
# For any other case, set result to 0 (success)
|
||||
@@ -222,32 +255,428 @@ function Get-Application {
|
||||
}
|
||||
}
|
||||
|
||||
# Clean up multiple versions (keep only the latest)
|
||||
WriteLog "$AppName has completed downloading. Identifying the latest version of $AppName."
|
||||
# Clean up multiple versions honoring WindowsArch (pruning target; keep only one installer)
|
||||
WriteLog "$AppName has completed downloading. Evaluating installer set for pruning."
|
||||
$packages = Get-ChildItem -Path "$appFolderPath\*" -Exclude "Dependencies\*", "*.xml", "*.yaml" -File -ErrorAction Stop
|
||||
|
||||
# Find latest version based on signature date
|
||||
$latestPackage = $packages | Sort-Object { (Get-AuthenticodeSignature $_.FullName).SignerCertificate.NotBefore } -Descending | Select-Object -First 1
|
||||
|
||||
# Remove older versions
|
||||
WriteLog "Latest version of $AppName has been identified as $latestPackage. Removing old versions of $AppName that may have downloaded."
|
||||
foreach ($package in $packages) {
|
||||
if ($package.FullName -ne $latestPackage.FullName) {
|
||||
try {
|
||||
WriteLog "Removing $($package.FullName)"
|
||||
Remove-Item -Path $package.FullName -Force
|
||||
if ($packages.Count -gt 1 -and $WindowsArch) {
|
||||
WriteLog "WindowsArch pruning target provided: $WindowsArch"
|
||||
# Detect universal bundles (contain x86,x64,arm64 in name)
|
||||
$universalCandidates = $packages | Where-Object {
|
||||
$base = $_.BaseName
|
||||
# Split base name into tokens to avoid partial matches (e.g. arm inside arm64)
|
||||
$tokens = ($base -split '[\.\-_]') | ForEach-Object { $_.ToLower() }
|
||||
# Architecture tokens we recognize
|
||||
$archTokens = @('x86', 'x64', 'arm', 'arm64')
|
||||
# Distinct matched architecture tokens
|
||||
$matched = $tokens | Where-Object { $_ -in $archTokens } | Select-Object -Unique
|
||||
if ($matched.Count -ge 2) {
|
||||
WriteLog "Multi-architecture bundle detected: $base (tokens: $($matched -join ', '))"
|
||||
$true
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to delete: $($package.FullName) - $_"
|
||||
throw $_
|
||||
else {
|
||||
$false
|
||||
}
|
||||
}
|
||||
if ($universalCandidates) {
|
||||
WriteLog "Universal bundle candidate(s) detected: $($universalCandidates.Name -join ', ')"
|
||||
$candidateSet = $universalCandidates
|
||||
}
|
||||
else {
|
||||
$archToken = switch -Regex ($WindowsArch.ToLower()) {
|
||||
'^x64$' { 'x64' ; break }
|
||||
'^x86$' { 'x86' ; break }
|
||||
'^arm64$' { 'arm64' ; break }
|
||||
default { $WindowsArch.ToLower() }
|
||||
}
|
||||
$archMatches = $packages | Where-Object { $_.BaseName -match "(?i)$archToken" }
|
||||
if ($archMatches) {
|
||||
WriteLog "Architecture-specific candidates matching '$archToken': $($archMatches.Name -join ', ')"
|
||||
$candidateSet = $archMatches
|
||||
}
|
||||
else {
|
||||
WriteLog "No installer filename matched '$archToken'. Falling back to all installers."
|
||||
$candidateSet = $packages
|
||||
}
|
||||
}
|
||||
# From candidate set, choose latest by signature date
|
||||
$latestPackage = $candidateSet | Sort-Object { (Get-AuthenticodeSignature $_.FullName).SignerCertificate.NotBefore } -Descending | Select-Object -First 1
|
||||
WriteLog "Retaining installer: $($latestPackage.Name)"
|
||||
foreach ($package in $packages) {
|
||||
if ($package.FullName -ne $latestPackage.FullName) {
|
||||
try {
|
||||
WriteLog "Removing $($package.FullName)"
|
||||
Remove-Item -Path $package.FullName -Force
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to delete: $($package.FullName) - $_"
|
||||
throw $_
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($packages.Count -gt 1) {
|
||||
WriteLog "Multiple installers present but no WindowsArch pruning target supplied. Using original latest-version logic."
|
||||
$latestPackage = $packages | Sort-Object { (Get-AuthenticodeSignature $_.FullName).SignerCertificate.NotBefore } -Descending | Select-Object -First 1
|
||||
WriteLog "Retaining latest by signature date: $($latestPackage.Name)"
|
||||
foreach ($package in $packages) {
|
||||
if ($package.FullName -ne $latestPackage.FullName) {
|
||||
try {
|
||||
WriteLog "Removing $($package.FullName)"
|
||||
Remove-Item -Path $package.FullName -Force
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to delete: $($package.FullName) - $_"
|
||||
throw $_
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Single installer present; no pruning required."
|
||||
}
|
||||
}
|
||||
} # End foreach ($arch in $architecturesToDownload)
|
||||
|
||||
return $overallResult
|
||||
}
|
||||
# Function to handle downloading a winget application in parallel
|
||||
# This function is called by Invoke-ParallelProcessing for each app
|
||||
function Start-WingetAppDownloadTask {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[PSCustomObject]$ApplicationItemData,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$AppListJsonPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$AppsPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OrchestrationPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue,
|
||||
[string]$WindowsArch,
|
||||
[switch]$SkipWin32Json
|
||||
)
|
||||
|
||||
$appName = $ApplicationItemData.Name
|
||||
$appId = $ApplicationItemData.Id
|
||||
$source = $ApplicationItemData.Source
|
||||
$status = "Checking..."
|
||||
$resultCode = -1
|
||||
$sanitizedAppName = ConvertTo-SafeName -Name $appName
|
||||
|
||||
# Initial status update
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
|
||||
WriteLog "Starting download task for $($appName) with ID $($appId) from source $($source)."
|
||||
|
||||
try {
|
||||
# Define paths
|
||||
$userAppListPath = Join-Path -Path $AppsPath -ChildPath "UserAppList.json"
|
||||
$appFound = $false
|
||||
|
||||
# 1. Check UserAppList.json and content
|
||||
if (Test-Path -Path $userAppListPath) {
|
||||
try {
|
||||
$userAppListContent = Get-Content -Path $userAppListPath -Raw | ConvertFrom-Json
|
||||
$userAppEntry = $userAppListContent | Where-Object { $_.Name -eq $appName }
|
||||
|
||||
if ($userAppEntry) {
|
||||
$appFolder = Join-Path -Path "$AppsPath\Win32" -ChildPath $sanitizedAppName
|
||||
if (Test-Path -Path $appFolder -PathType Container) {
|
||||
$folderSize = (Get-ChildItem -Path $appFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($folderSize -gt 1MB) {
|
||||
$appFound = $true
|
||||
$status = "Not Downloaded: App in $userAppListPath and found in $appFolder"
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
WriteLog "Found '$appName' in $userAppListPath and content exists in '$appFolder'."
|
||||
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 0 }
|
||||
}
|
||||
else {
|
||||
$appFound = $true
|
||||
$status = "App in '$userAppListPath' but content missing/small in '$appFolder'. Copy content or remove from UserAppList.json."
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
WriteLog $status
|
||||
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
|
||||
}
|
||||
}
|
||||
else {
|
||||
$appFound = $true
|
||||
$status = "App in '$userAppListPath' but content folder '$appFolder' not found. Copy content or remove from UserAppList.json."
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
WriteLog $status
|
||||
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Warning: Could not read or parse '$userAppListPath'. Error: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# 2. Check existing downloaded Win32 content (folder-based)
|
||||
if (-not $appFound -and $source -eq 'winget') {
|
||||
$appFolder = Join-Path -Path "$AppsPath\Win32" -ChildPath $sanitizedAppName
|
||||
if (Test-Path -Path $appFolder -PathType Container) {
|
||||
$contentFound = $false
|
||||
if ($ApplicationItemData.Architecture -eq 'x86 x64') {
|
||||
$x86Folder = Join-Path -Path $appFolder -ChildPath "x86"
|
||||
$x64Folder = Join-Path -Path $appFolder -ChildPath "x64"
|
||||
if ((Test-Path -Path $x86Folder -PathType Container) -and (Test-Path -Path $x64Folder -PathType Container)) {
|
||||
$x86Size = (Get-ChildItem -Path $x86Folder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
$x64Size = (Get-ChildItem -Path $x64Folder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($x86Size -gt 1MB -and $x64Size -gt 1MB) {
|
||||
$contentFound = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
$folderSize = (Get-ChildItem -Path $appFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($folderSize -gt 1MB) {
|
||||
$contentFound = $true
|
||||
}
|
||||
}
|
||||
if ($contentFound) {
|
||||
$appFound = $true
|
||||
$status = "Not Downloaded: Existing content found in $appFolder"
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
WriteLog "Found existing content for '$appName' in '$appFolder'. Skipping download to prevent duplicate entry."
|
||||
|
||||
# Regenerate WinGetWin32Apps.json for CLI builds when content already exists
|
||||
# UI mode pre-downloads should not generate this file (SkipWin32Json)
|
||||
if (-not $SkipWin32Json) {
|
||||
$archFolders = Get-ChildItem -Path $appFolder -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -in @('x86', 'x64', 'arm64') }
|
||||
if ($archFolders) {
|
||||
foreach ($archFolder in $archFolders) {
|
||||
WriteLog "Adding silent install command for pre-downloaded $sanitizedAppName ($($archFolder.Name)) to $OrchestrationPath\WinGetWin32Apps.json"
|
||||
Add-Win32SilentInstallCommand -AppFolder $sanitizedAppName -AppFolderPath $archFolder.FullName -OrchestrationPath $OrchestrationPath -SubFolder $archFolder.Name | Out-Null
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Adding silent install command for pre-downloaded $sanitizedAppName to $OrchestrationPath\WinGetWin32Apps.json"
|
||||
Add-Win32SilentInstallCommand -AppFolder $sanitizedAppName -AppFolderPath $appFolder -OrchestrationPath $OrchestrationPath | Out-Null
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Skipping WinGetWin32Apps.json regeneration for pre-downloaded $sanitizedAppName (UI mode)."
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Check MSStore folder
|
||||
if (-not $appFound -and (Test-Path -Path "$AppsPath\MSStore" -PathType Container)) {
|
||||
$appFolder = Join-Path -Path "$AppsPath\MSStore" -ChildPath $sanitizedAppName
|
||||
if (Test-Path -Path $appFolder -PathType Container) {
|
||||
$folderSize = (Get-ChildItem -Path $appFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($folderSize -gt 1MB) {
|
||||
$appFound = $true
|
||||
$status = "Already downloaded (MSStore)"
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
WriteLog "Found '$appName' content in '$appFolder'."
|
||||
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 3. If not found locally, add to AppList.json and download
|
||||
if (-not $appFound) {
|
||||
# Add to AppList.json with mutex lock for thread safety
|
||||
$appListContent = $null
|
||||
$appListDir = Split-Path -Path $AppListJsonPath -Parent
|
||||
if (-not (Test-Path -Path $appListDir -PathType Container)) {
|
||||
New-Item -Path $appListDir -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
if (Test-Path -Path $AppListJsonPath) {
|
||||
try {
|
||||
$appListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
|
||||
if (-not $appListContent.PSObject.Properties['apps']) {
|
||||
$appListContent = @{ apps = @() }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Warning: Could not read or parse '$AppListJsonPath'. Creating new structure. Error: $($_.Exception.Message)"
|
||||
$appListContent = @{ apps = @() }
|
||||
}
|
||||
}
|
||||
else {
|
||||
$appListContent = @{ apps = @() }
|
||||
}
|
||||
|
||||
$appExistsInAppList = $false
|
||||
if ($appListContent.apps) {
|
||||
foreach ($app in $appListContent.apps) {
|
||||
if ($app.id -eq $appId) {
|
||||
$appExistsInAppList = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $appExistsInAppList) {
|
||||
$newApp = @{ name = $sanitizedAppName; id = $appId; source = $source }
|
||||
if (-not ($appListContent.apps -is [array])) { $appListContent.apps = @() }
|
||||
$appListContent.apps += $newApp
|
||||
try {
|
||||
# Use a mutex lock to prevent race conditions when writing to the same file
|
||||
$lockName = "AppListJsonLock"
|
||||
$lock = New-Object System.Threading.Mutex($false, $lockName)
|
||||
try {
|
||||
$lock.WaitOne() | Out-Null
|
||||
# Re-read content inside lock to ensure latest version
|
||||
if (Test-Path -Path $AppListJsonPath) {
|
||||
$currentAppListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
|
||||
if (-not ($currentAppListContent.apps | Where-Object { $_.id -eq $appId })) {
|
||||
$currentAppListContent.apps += $newApp
|
||||
$currentAppListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
|
||||
WriteLog "Added '$appName' to '$AppListJsonPath'."
|
||||
}
|
||||
else {
|
||||
WriteLog "'$appName' already exists in '$AppListJsonPath' (checked inside lock)."
|
||||
}
|
||||
}
|
||||
else {
|
||||
# File doesn't exist, write the initial content
|
||||
$appListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
|
||||
WriteLog "Created '$AppListJsonPath' and added '$appName'."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$lock.ReleaseMutex()
|
||||
$lock.Dispose()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error saving '$AppListJsonPath'. Error: $($_.Exception.Message)"
|
||||
$status = "Failed to save AppList.json: $($_.Exception.Message)"
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "'$appName' already exists in '$AppListJsonPath'."
|
||||
}
|
||||
|
||||
# Proceed with download
|
||||
$status = "Downloading..."
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
|
||||
# Ensure necessary folders exist
|
||||
WriteLog "Orchestration Path: $($OrchestrationPath)"
|
||||
if (-not (Test-Path -Path $OrchestrationPath -PathType Container)) {
|
||||
New-Item -Path $OrchestrationPath -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
$win32Folder = Join-Path -Path $AppsPath -ChildPath "Win32"
|
||||
if ($source -eq "winget" -and -not (Test-Path -Path $win32Folder -PathType Container)) {
|
||||
New-Item -Path $win32Folder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
$storeAppsFolder = Join-Path -Path $AppsPath -ChildPath "MSStore"
|
||||
if ($source -eq "msstore" -and -not (Test-Path -Path $storeAppsFolder -PathType Container)) {
|
||||
New-Item -Path $storeAppsFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
try {
|
||||
# Call Get-Application to perform the actual download
|
||||
# Pass SkipWin32Json based on caller context (UI mode skips, CLI mode creates)
|
||||
$getAppParams = @{
|
||||
AppName = $appName
|
||||
AppId = $appId
|
||||
Source = $source
|
||||
AppsPath = $AppsPath
|
||||
ApplicationArch = $ApplicationItemData.Architecture
|
||||
WindowsArch = $WindowsArch
|
||||
OrchestrationPath = $OrchestrationPath
|
||||
ErrorAction = 'Stop'
|
||||
}
|
||||
if ($SkipWin32Json) {
|
||||
$getAppParams['SkipWin32Json'] = $true
|
||||
}
|
||||
$resultCode = Get-Application @getAppParams
|
||||
|
||||
# Determine status based on result code
|
||||
switch ($resultCode) {
|
||||
0 { $status = "Downloaded successfully" }
|
||||
1 { $status = "Error: No app installers were found" }
|
||||
2 { $status = "Silent install switch could not be found. Did not download." }
|
||||
3 { $status = "Error: Publisher does not support download" }
|
||||
4 { $status = "Skipped: Use 'msstore' source instead." }
|
||||
default { $status = "Downloaded with status: $resultCode" }
|
||||
}
|
||||
|
||||
# Remove app from AppList.json if silent install switch could not be found (resultCode 2)
|
||||
if ($resultCode -eq 2) {
|
||||
try {
|
||||
if (Test-Path -Path $AppListJsonPath) {
|
||||
$appListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
|
||||
if ($appListContent.apps) {
|
||||
$filteredApps = @($appListContent.apps | Where-Object { $_.id -ne $appId })
|
||||
$appListContent.apps = $filteredApps
|
||||
$appListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
|
||||
WriteLog "Removed '$appName' ($appId) from '$AppListJsonPath' due to missing silent install switch."
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to remove '$appName' from '$AppListJsonPath': $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$status = $_.Exception.Message
|
||||
WriteLog "Download error for $($appName): $($_.Exception.Message)"
|
||||
$resultCode = 1
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
|
||||
# Remove app from AppList.json if publisher does not support download
|
||||
if ($_.Exception.Message -match "does not support downloads by the publisher") {
|
||||
try {
|
||||
if (Test-Path -Path $AppListJsonPath) {
|
||||
$appListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
|
||||
if ($appListContent.apps) {
|
||||
$filteredApps = @($appListContent.apps | Where-Object { $_.id -ne $appId })
|
||||
$appListContent.apps = $filteredApps
|
||||
$appListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
|
||||
WriteLog "Removed '$appName' ($appId) from '$AppListJsonPath' due to publisher download restriction."
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to remove '$appName' from '$AppListJsonPath': $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$status = $_.Exception.Message
|
||||
WriteLog "Unexpected error in Start-WingetAppDownloadTask for $($appName): $($_.Exception.Message)"
|
||||
$resultCode = 1
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
}
|
||||
finally {
|
||||
# Ensure status is not empty before returning
|
||||
if ([string]::IsNullOrEmpty($status)) {
|
||||
$status = "Unknown failure"
|
||||
WriteLog "Status was empty for $appName ($appId), setting to default error."
|
||||
if ($resultCode -ne 0 -and $resultCode -ne 1 -and $resultCode -ne 2) {
|
||||
$resultCode = -1
|
||||
}
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
}
|
||||
elseif ($resultCode -ne 0) {
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
}
|
||||
else {
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
}
|
||||
}
|
||||
|
||||
# Return the final status and result code
|
||||
return @{ Id = $appId; Status = $status; ResultCode = $resultCode }
|
||||
}
|
||||
|
||||
function Get-Apps {
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
@@ -258,28 +687,31 @@ function Get-Apps {
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$WindowsArch,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OrchestrationPath
|
||||
[string]$OrchestrationPath,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$LogFilePath,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$ThrottleLimit = 5
|
||||
)
|
||||
|
||||
# Load and validate app list
|
||||
$apps = Get-Content -Path $AppList -Raw | ConvertFrom-Json
|
||||
if (-not $apps) {
|
||||
if (-not $apps -or -not $apps.apps -or $apps.apps.Count -eq 0) {
|
||||
WriteLog "No apps were specified in AppList.json file."
|
||||
return
|
||||
}
|
||||
|
||||
# Process WinGet apps
|
||||
# Log app list summary
|
||||
$wingetApps = $apps.apps | Where-Object { $_.source -eq "winget" }
|
||||
if ($wingetApps) {
|
||||
WriteLog 'Winget apps to be installed:'
|
||||
$wingetApps | ForEach-Object { WriteLog $_.Name }
|
||||
}
|
||||
|
||||
# Process Store apps
|
||||
$StoreApps = $apps.apps | Where-Object { $_.source -eq "msstore" }
|
||||
if ($StoreApps) {
|
||||
$storeApps = $apps.apps | Where-Object { $_.source -eq "msstore" }
|
||||
if ($storeApps) {
|
||||
WriteLog 'Store apps to be installed:'
|
||||
$StoreApps | ForEach-Object { WriteLog $_.Name }
|
||||
$storeApps | ForEach-Object { WriteLog $_.Name }
|
||||
}
|
||||
|
||||
# Ensure WinGet is available
|
||||
@@ -289,43 +721,120 @@ function Get-Apps {
|
||||
$win32Folder = Join-Path -Path $AppsPath -ChildPath "Win32"
|
||||
$storeAppsFolder = Join-Path -Path $AppsPath -ChildPath "MSStore"
|
||||
|
||||
# Process WinGet apps
|
||||
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."
|
||||
}
|
||||
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
|
||||
}
|
||||
if (-not (Test-Path -Path $storeAppsFolder -PathType Container)) {
|
||||
WriteLog "Creating folder for MSStore apps: $storeAppsFolder"
|
||||
New-Item -Path $storeAppsFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
foreach ($wingetApp in $wingetApps) {
|
||||
try {
|
||||
$appArch = if ($wingetApp.PSObject.Properties['architecture']) { $wingetApp.architecture } else { $WindowsArch }
|
||||
Get-Application -AppName $wingetApp.Name -AppId $wingetApp.Id -Source 'winget' -AppsPath $AppsPath -WindowsArch $appArch -OrchestrationPath $OrchestrationPath
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error occurred while processing $($wingetApp.Name): $_"
|
||||
throw $_
|
||||
}
|
||||
# Transform apps into the format expected by Invoke-ParallelProcessing
|
||||
$itemsToProcess = $apps.apps | ForEach-Object {
|
||||
$appArch = if ($_.PSObject.Properties['architecture']) { $_.architecture } else { $WindowsArch }
|
||||
[PSCustomObject]@{
|
||||
Name = $_.name
|
||||
Id = $_.id
|
||||
Source = $_.source
|
||||
Architecture = $appArch
|
||||
}
|
||||
}
|
||||
|
||||
# Process Store apps
|
||||
if ($StoreApps) {
|
||||
if (-not (Test-Path -Path $storeAppsFolder -PathType Container)) {
|
||||
New-Item -Path $storeAppsFolder -ItemType Directory -Force | Out-Null
|
||||
WriteLog "Starting parallel download of $($itemsToProcess.Count) applications with ThrottleLimit: $ThrottleLimit"
|
||||
|
||||
# Build task arguments for Invoke-ParallelProcessing
|
||||
# CLI builds should create WinGetWin32Apps.json, so SkipWin32Json is false
|
||||
$taskArguments = @{
|
||||
AppsPath = $AppsPath
|
||||
AppListJsonPath = $AppList
|
||||
OrchestrationPath = $OrchestrationPath
|
||||
WindowsArch = $WindowsArch
|
||||
SkipWin32Json = $false
|
||||
}
|
||||
|
||||
# Invoke parallel processing in non-UI mode (no WindowObject or ListViewControl)
|
||||
Invoke-ParallelProcessing -ItemsToProcess $itemsToProcess `
|
||||
-IdentifierProperty 'Id' `
|
||||
-StatusProperty 'DownloadStatus' `
|
||||
-TaskType 'WingetDownload' `
|
||||
-TaskArguments $taskArguments `
|
||||
-CompletedStatusText "Completed" `
|
||||
-ErrorStatusPrefix "Error: " `
|
||||
-MainThreadLogPath $LogFilePath `
|
||||
-ThrottleLimit $ThrottleLimit
|
||||
|
||||
WriteLog "Parallel download of applications completed."
|
||||
|
||||
# Post-processing: Override CommandLine / Arguments from AppList.json if provided
|
||||
# Users may supply custom silent install commands or arguments. These optional
|
||||
# properties (CommandLine, Arguments) in AppList.json replace the auto-generated
|
||||
# values in WinGetWin32Apps.json. Keyed by Name.
|
||||
try {
|
||||
$overrideMap = @{}
|
||||
foreach ($app in $apps.apps) {
|
||||
if ($app.source -in @('winget', 'msstore')) {
|
||||
$hasCmd = ($app.PSObject.Properties['CommandLine'] -and -not [string]::IsNullOrWhiteSpace($app.CommandLine))
|
||||
$hasArgs = ($app.PSObject.Properties['Arguments'] -and -not [string]::IsNullOrWhiteSpace($app.Arguments))
|
||||
$hasAdd = ($app.PSObject.Properties['AdditionalExitCodes'] -and -not [string]::IsNullOrWhiteSpace($app.AdditionalExitCodes))
|
||||
$hasIgnore = ($app.PSObject.Properties['IgnoreNonZeroExitCodes'])
|
||||
if ($hasCmd -or $hasArgs -or $hasAdd -or $hasIgnore) {
|
||||
$overrideMap[$app.name] = @{
|
||||
CommandLine = if ($hasCmd) { $app.CommandLine } else { $null }
|
||||
Arguments = if ($hasArgs) { $app.Arguments } else { $null }
|
||||
AdditionalExitCodes = if ($hasAdd) { $app.AdditionalExitCodes } else { $null }
|
||||
IgnoreNonZeroExitCodes = if ($hasIgnore) { [bool]$app.IgnoreNonZeroExitCodes } else { $null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($storeApp in $StoreApps) {
|
||||
try {
|
||||
$appArch = if ($storeApp.PSObject.Properties['architecture']) { $storeApp.architecture } else { $WindowsArch }
|
||||
Get-Application -AppName $storeApp.Name -AppId $storeApp.Id -Source 'msstore' -AppsPath $AppsPath -WindowsArch $appArch -OrchestrationPath $OrchestrationPath
|
||||
if ($overrideMap.Count -gt 0) {
|
||||
$winGetWin32Path = Join-Path -Path $OrchestrationPath -ChildPath 'WinGetWin32Apps.json'
|
||||
if (Test-Path -Path $winGetWin32Path) {
|
||||
[array]$appsDataUpdated = Get-Content -Path $winGetWin32Path -Raw | ConvertFrom-Json
|
||||
$changed = $false
|
||||
foreach ($entry in $appsDataUpdated) {
|
||||
if ($overrideMap.ContainsKey($entry.Name)) {
|
||||
$ov = $overrideMap[$entry.Name]
|
||||
if ($ov.CommandLine) {
|
||||
WriteLog "Override (AppList.json) CommandLine for $($entry.Name)"
|
||||
$entry.CommandLine = $ov.CommandLine
|
||||
$changed = $true
|
||||
}
|
||||
if ($ov.Arguments) {
|
||||
WriteLog "Override (AppList.json) Arguments for $($entry.Name)"
|
||||
$entry.Arguments = $ov.Arguments
|
||||
$changed = $true
|
||||
}
|
||||
if ($ov.ContainsKey('AdditionalExitCodes') -and $null -ne $ov.AdditionalExitCodes) {
|
||||
WriteLog "Override (AppList.json) AdditionalExitCodes for $($entry.Name)"
|
||||
$entry | Add-Member -NotePropertyName AdditionalExitCodes -NotePropertyValue $ov.AdditionalExitCodes -Force
|
||||
$changed = $true
|
||||
}
|
||||
if ($ov.ContainsKey('IgnoreNonZeroExitCodes') -and $null -ne $ov.IgnoreNonZeroExitCodes) {
|
||||
WriteLog "Override (AppList.json) IgnoreNonZeroExitCodes for $($entry.Name)"
|
||||
$entry | Add-Member -NotePropertyName IgnoreNonZeroExitCodes -NotePropertyValue ([bool]$ov.IgnoreNonZeroExitCodes) -Force
|
||||
$changed = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($changed) {
|
||||
$appsDataUpdated | ConvertTo-Json -Depth 10 | Set-Content -Path $winGetWin32Path
|
||||
WriteLog "Applied AppList.json command overrides to WinGetWin32Apps.json"
|
||||
}
|
||||
else {
|
||||
WriteLog "No matching apps required command overrides."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error occurred while processing $($storeApp.Name): $_"
|
||||
throw $_
|
||||
else {
|
||||
WriteLog "WinGetWin32Apps.json not found; no overrides applied."
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to apply AppList.json command overrides: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
function Install-WinGet {
|
||||
param (
|
||||
@@ -409,33 +918,144 @@ function Add-Win32SilentInstallCommand {
|
||||
[string]$SubFolder
|
||||
)
|
||||
$appName = $AppFolder
|
||||
$installerPath = Get-ChildItem -Path "$appFolderPath\*" -Include "*.exe", "*.msi" -File -ErrorAction Stop
|
||||
if (-not $installerPath) {
|
||||
|
||||
# Discover installer candidates (top-level files as before)
|
||||
$installerCandidates = Get-ChildItem -Path "$appFolderPath\*" -Include "*.exe", "*.msi" -File -ErrorAction SilentlyContinue
|
||||
if (-not $installerCandidates) {
|
||||
WriteLog "No win32 app installers were found. Skipping the inclusion of $AppFolder"
|
||||
Remove-Item -Path $AppFolderPath -Recurse -Force
|
||||
return 1
|
||||
}
|
||||
|
||||
# Read the exported WinGet YAML
|
||||
$yamlFile = Get-ChildItem -Path "$appFolderPath\*" -Include "*.yaml" -File -ErrorAction Stop
|
||||
$yamlContent = Get-Content -Path $yamlFile -Raw
|
||||
$silentInstallSwitch = [regex]::Match($yamlContent, 'Silent:\s*(.+)').Groups[1].Value.Replace("'", "").Trim()
|
||||
$yamlText = Get-Content -Path $yamlFile -Raw
|
||||
|
||||
# Attempt to resolve the correct installer from YAML NestedInstallerFiles within the matching Architecture block
|
||||
$desiredArch = if (-not [string]::IsNullOrEmpty($SubFolder)) { $SubFolder } else { $null }
|
||||
$relativeFromYaml = $null
|
||||
$blockSilent = $null
|
||||
|
||||
$regexOptions = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
|
||||
$pattern = '-\s+Architecture:\s*(?<arch>\S+)[\s\S]*?NestedInstallerFiles:\s*-\s*RelativeFilePath:\s*(?<path>.+?)\r?\n'
|
||||
$yamlMatches = [regex]::Matches($yamlText, $pattern, $regexOptions)
|
||||
|
||||
$selectedMatch = $null
|
||||
if ($yamlMatches.Count -gt 0) {
|
||||
if ($desiredArch) {
|
||||
foreach ($m in $yamlMatches) {
|
||||
if ($m.Groups['arch'].Value -ieq $desiredArch) {
|
||||
$selectedMatch = $m
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $selectedMatch) {
|
||||
$selectedMatch = $yamlMatches[0]
|
||||
}
|
||||
|
||||
$pathValue = $selectedMatch.Groups['path'].Value.Trim()
|
||||
$pathValue = $pathValue.Trim("'").Trim('"')
|
||||
$relativeFromYaml = $pathValue
|
||||
|
||||
# Extract a Silent switch from within the same installer block if present
|
||||
$startIndex = $selectedMatch.Index
|
||||
$nextIndex = -1
|
||||
for ($i = 0; $i -lt $yamlMatches.Count; $i++) {
|
||||
if ($yamlMatches[$i].Index -gt $startIndex) {
|
||||
$nextIndex = $yamlMatches[$i].Index
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($nextIndex -gt -1) {
|
||||
$blockText = $yamlText.Substring($startIndex, $nextIndex - $startIndex)
|
||||
}
|
||||
else {
|
||||
$blockText = $yamlText.Substring($startIndex)
|
||||
}
|
||||
$blockSilentMatch = [regex]::Match($blockText, 'InstallerSwitches:[\s\S]*?Silent:\s*(.+?)\r?\n', $regexOptions)
|
||||
if ($blockSilentMatch.Success) {
|
||||
$blockSilent = $blockSilentMatch.Groups[1].Value.Trim().Trim("'").Trim('"')
|
||||
}
|
||||
}
|
||||
|
||||
# Resolve Silent switch (prefer block-level, fallback to first Silent in file)
|
||||
$silentInstallSwitch = $blockSilent
|
||||
if ([string]::IsNullOrEmpty($silentInstallSwitch)) {
|
||||
$globalSilentMatch = [regex]::Match($yamlText, 'Silent:\s*(.+)', $regexOptions)
|
||||
$silentInstallSwitch = $globalSilentMatch.Groups[1].Value.Trim().Trim("'").Trim('"')
|
||||
}
|
||||
if (-not $silentInstallSwitch) {
|
||||
WriteLog "Silent install switch for $appName could not be found. Skipping the inclusion of $appName."
|
||||
Remove-Item -Path $appFolderPath -Recurse -Force
|
||||
return 2
|
||||
}
|
||||
$installer = Split-Path -Path $installerPath -Leaf
|
||||
|
||||
# Choose final installer path and extension
|
||||
$resolvedRelativePath = $null
|
||||
$installerExt = $null
|
||||
|
||||
if ($installerCandidates.Count -eq 1 -and -not $relativeFromYaml) {
|
||||
# Single installer – keep current behavior
|
||||
$resolvedRelativePath = $installerCandidates[0].Name
|
||||
$installerExt = $installerCandidates[0].Extension
|
||||
WriteLog "Single installer detected ($resolvedRelativePath). Using current behavior."
|
||||
}
|
||||
else {
|
||||
if ($relativeFromYaml) {
|
||||
$normalizedPath = ($relativeFromYaml -replace '/', '\')
|
||||
$resolvedRelativePath = $normalizedPath
|
||||
$installerExt = [System.IO.Path]::GetExtension($normalizedPath)
|
||||
if ([string]::IsNullOrEmpty($installerExt)) {
|
||||
$leafName = [System.IO.Path]::GetFileName($normalizedPath)
|
||||
$matchedCandidate = $installerCandidates | Where-Object { $_.Name -ieq $leafName } | Select-Object -First 1
|
||||
if ($matchedCandidate) {
|
||||
$installerExt = $matchedCandidate.Extension
|
||||
}
|
||||
}
|
||||
WriteLog "Multiple installers found. Selected by YAML NestedInstallerFiles: $resolvedRelativePath"
|
||||
}
|
||||
if (-not $resolvedRelativePath) {
|
||||
# Fallbacks when YAML lacks NestedInstallerFiles or couldn't be matched
|
||||
$msis = $installerCandidates | Where-Object { $_.Extension -ieq ".msi" }
|
||||
if ($msis.Count -eq 1) {
|
||||
$resolvedRelativePath = $msis[0].Name
|
||||
$installerExt = ".msi"
|
||||
WriteLog "Multiple installers found. YAML not used. Falling back to single MSI: $resolvedRelativePath"
|
||||
}
|
||||
else {
|
||||
$exes = $installerCandidates | Where-Object { $_.Extension -ieq ".exe" }
|
||||
if ($exes.Count -eq 1) {
|
||||
$resolvedRelativePath = $exes[0].Name
|
||||
$installerExt = ".exe"
|
||||
WriteLog "Multiple installers found. YAML not used. Falling back to single EXE: $resolvedRelativePath"
|
||||
}
|
||||
else {
|
||||
$first = $installerCandidates | Select-Object -First 1
|
||||
$resolvedRelativePath = $first.Name
|
||||
$installerExt = $first.Extension
|
||||
WriteLog "Multiple installers found and ambiguous. Selecting the first candidate: $resolvedRelativePath"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$basePath = "D:\win32\$AppFolder"
|
||||
if (-not [string]::IsNullOrEmpty($SubFolder)) {
|
||||
$basePath = "$basePath\$SubFolder"
|
||||
}
|
||||
|
||||
if ($installerPath.Extension -eq ".exe") {
|
||||
$silentInstallCommand = "$basePath\$installer"
|
||||
# Build final command/arguments
|
||||
if ($installerExt -ieq ".exe") {
|
||||
$silentInstallCommand = "$basePath\$resolvedRelativePath"
|
||||
}
|
||||
elseif ($installerPath.Extension -eq ".msi") {
|
||||
elseif ($installerExt -ieq ".msi") {
|
||||
$silentInstallCommand = "msiexec"
|
||||
$silentInstallSwitch = "/i `"$basePath\$installer`" $silentInstallSwitch"
|
||||
$silentInstallSwitch = "/i `"$basePath\$resolvedRelativePath`" $silentInstallSwitch"
|
||||
}
|
||||
else {
|
||||
# Default path usage if extension could not be inferred
|
||||
$silentInstallCommand = "$basePath\$resolvedRelativePath"
|
||||
}
|
||||
|
||||
# Path to the JSON file
|
||||
@@ -477,4 +1097,4 @@ function Add-Win32SilentInstallCommand {
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# Export functions needed by both BuildFFUVM and the UI Core module
|
||||
Export-ModuleMember -Function Get-Application, Get-Apps, Confirm-WinGetInstallation, Add-Win32SilentInstallCommand, Install-Winget
|
||||
Export-ModuleMember -Function Get-Application, Get-Apps, Start-WingetAppDownloadTask, Confirm-WinGetInstallation, Add-Win32SilentInstallCommand, Install-Winget
|
||||
@@ -67,8 +67,10 @@ Description = 'Common functions shared between FFU Builder UI and the BuildFFUVM
|
||||
|
||||
# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess
|
||||
NestedModules = @('FFU.Common.Drivers.psm1',
|
||||
'FFU.Common.Drivers.Dell.psm1',
|
||||
'FFU.Common.Winget.psm1',
|
||||
'FFU.Common.Parallel.psm1')
|
||||
'FFU.Common.Parallel.psm1',
|
||||
'FFU.Common.Cleanup.psm1')
|
||||
|
||||
# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
|
||||
FunctionsToExport = '*'
|
||||
|
||||
@@ -5,6 +5,85 @@
|
||||
This module contains all the functions that power the "Applications" tab in the BuildFFUVM_UI. It handles user interactions for managing custom application lists (BYO Apps), such as adding, removing, reordering, and saving/loading the list from a JSON file (UserAppList.json). It also includes the logic for copying the application source files to the designated staging directory in parallel. Additionally, it manages the UI for creating and removing key-value pairs for the AppsScriptVariables.json file, which allows for custom parameterization of user-provided scripts.
|
||||
#>
|
||||
|
||||
# Function to update the enabled state of BYO Apps action buttons based on selection
|
||||
function Update-BYOAppsActionButtonsState {
|
||||
param(
|
||||
[psobject]$State
|
||||
)
|
||||
$listView = $State.Controls.lstApplications
|
||||
$removeButton = $State.Controls.btnRemoveSelectedBYOApps
|
||||
$editButton = $State.Controls.btnEditApplication
|
||||
|
||||
if ($listView -and $removeButton -and $editButton) {
|
||||
# Count selected items
|
||||
$selectedItems = @($listView.Items | Where-Object { $_.IsSelected })
|
||||
$selectedCount = $selectedItems.Count
|
||||
|
||||
# Enable the remove button if any item is selected
|
||||
$removeButton.IsEnabled = ($selectedCount -gt 0)
|
||||
|
||||
# Enable the edit button only if exactly one item is selected
|
||||
$editButton.IsEnabled = ($selectedCount -eq 1)
|
||||
}
|
||||
}
|
||||
|
||||
# Function to remove all selected BYO applications
|
||||
function Remove-SelectedBYOApplications {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
$listView = $State.Controls.lstApplications
|
||||
$itemsToRemove = @($listView.Items | Where-Object { $_.IsSelected })
|
||||
|
||||
if ($itemsToRemove.Count -eq 0) {
|
||||
# This should not happen if the button is correctly disabled, but as a safeguard:
|
||||
[System.Windows.MessageBox]::Show("No applications are selected for removal.", "Remove Applications", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
|
||||
return
|
||||
}
|
||||
|
||||
# Check if the item being edited is among those being removed
|
||||
if ($null -ne $State.Data.editingBYOApplication -and $itemsToRemove.Contains($State.Data.editingBYOApplication)) {
|
||||
# Reset the edit state
|
||||
$State.Data.editingBYOApplication = $null
|
||||
$State.Controls.btnAddApplication.Content = "Add Application"
|
||||
# Clear the form fields
|
||||
$State.Controls.txtAppName.Clear()
|
||||
$State.Controls.txtAppCommandLine.Clear()
|
||||
$State.Controls.txtAppArguments.Clear()
|
||||
$State.Controls.txtAppSource.Clear()
|
||||
$State.Controls.txtAppAdditionalExitCodes.Clear()
|
||||
$State.Controls.chkIgnoreExitCodes.IsChecked = $false
|
||||
}
|
||||
|
||||
foreach ($item in $itemsToRemove) {
|
||||
$listView.Items.Remove($item)
|
||||
}
|
||||
|
||||
# Re-calculate priorities for the remaining items
|
||||
Update-ListViewPriorities -ListView $listView
|
||||
|
||||
# Update button states (Copy and Remove)
|
||||
Update-CopyButtonState -State $State
|
||||
Update-BYOAppsActionButtonsState -State $State
|
||||
|
||||
# Update the header checkbox state
|
||||
$headerChk = $State.Controls.chkSelectAllBYOApps
|
||||
if ($null -ne $headerChk) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $listView -HeaderCheckBox $headerChk
|
||||
}
|
||||
|
||||
# Ask user if they want to save the changes
|
||||
$result = [System.Windows.MessageBox]::Show("The selected applications have been removed from the list. Do you want to save these changes to UserAppList.json now?", "Save Changes", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question)
|
||||
|
||||
if ($result -eq 'Yes') {
|
||||
$userAppListPath = Join-Path -Path $State.Controls.txtApplicationPath.Text -ChildPath 'UserAppList.json'
|
||||
Save-BYOApplicationList -Path $userAppListPath -State $State
|
||||
}
|
||||
}
|
||||
|
||||
# Function to update the enabled state of the Copy Apps button
|
||||
function Update-CopyButtonState {
|
||||
param(
|
||||
@@ -40,6 +119,7 @@ function Remove-Application {
|
||||
Update-ListViewPriorities -ListView $listView
|
||||
# Update the Copy Apps button state
|
||||
Update-CopyButtonState -State $State
|
||||
Update-BYOAppsActionButtonsState -State $State
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,29 +135,109 @@ function Add-BYOApplication {
|
||||
$commandLine = $State.Controls.txtAppCommandLine.Text
|
||||
$arguments = $State.Controls.txtAppArguments.Text
|
||||
$source = $State.Controls.txtAppSource.Text
|
||||
$additionalExitCodes = $State.Controls.txtAppAdditionalExitCodes.Text
|
||||
$ignoreNonZeroExitCodes = $State.Controls.chkIgnoreExitCodes.IsChecked
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($name) -or [string]::IsNullOrWhiteSpace($commandLine)) {
|
||||
[System.Windows.MessageBox]::Show("Please fill in all fields (Name and Command Line)", "Missing Information", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
|
||||
return
|
||||
}
|
||||
$listView = $State.Controls.lstApplications
|
||||
# Check for duplicate names
|
||||
$existingApp = $listView.Items | Where-Object { $_.Name -eq $name }
|
||||
if ($existingApp) {
|
||||
[System.Windows.MessageBox]::Show("An application with the name '$name' already exists.", "Duplicate Name", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
|
||||
return
|
||||
|
||||
# Check if we are in edit mode
|
||||
if ($null -ne $State.Data.editingBYOApplication) {
|
||||
$itemToUpdate = $State.Data.editingBYOApplication
|
||||
|
||||
# Check for duplicate names, excluding the item being edited
|
||||
$existingApp = $listView.Items | Where-Object { $_.Name -eq $name -and $_ -ne $itemToUpdate }
|
||||
if ($existingApp) {
|
||||
[System.Windows.MessageBox]::Show("An application with the name '$name' already exists.", "Duplicate Name", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
|
||||
return
|
||||
}
|
||||
|
||||
# Update the properties of the existing object
|
||||
$itemToUpdate.Name = $name
|
||||
$itemToUpdate.CommandLine = $commandLine
|
||||
$itemToUpdate.Arguments = $arguments
|
||||
$itemToUpdate.Source = $source
|
||||
$itemToUpdate.AdditionalExitCodes = $additionalExitCodes
|
||||
$itemToUpdate.IgnoreNonZeroExitCodes = $ignoreNonZeroExitCodes
|
||||
$itemToUpdate.IgnoreExitCodes = if ($ignoreNonZeroExitCodes) { "Yes" } else { "No" }
|
||||
|
||||
# Refresh the ListView to show the changes
|
||||
$listView.Items.Refresh()
|
||||
|
||||
# Reset state
|
||||
$State.Data.editingBYOApplication = $null
|
||||
$State.Controls.btnAddApplication.Content = "Add Application"
|
||||
}
|
||||
$priority = 1
|
||||
if ($listView.Items.Count -gt 0) {
|
||||
$priority = ($listView.Items | Measure-Object -Property Priority -Maximum).Maximum + 1
|
||||
else {
|
||||
# This is a new application
|
||||
# Check for duplicate names
|
||||
$existingApp = $listView.Items | Where-Object { $_.Name -eq $name }
|
||||
if ($existingApp) {
|
||||
[System.Windows.MessageBox]::Show("An application with the name '$name' already exists.", "Duplicate Name", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
|
||||
return
|
||||
}
|
||||
$priority = 1
|
||||
if ($listView.Items.Count -gt 0) {
|
||||
$priority = ($listView.Items | Measure-Object -Property Priority -Maximum).Maximum + 1
|
||||
}
|
||||
$application = [PSCustomObject]@{
|
||||
IsSelected = $false
|
||||
Priority = $priority
|
||||
Name = $name
|
||||
CommandLine = $commandLine
|
||||
Arguments = $arguments
|
||||
Source = $source
|
||||
AdditionalExitCodes = $additionalExitCodes
|
||||
IgnoreNonZeroExitCodes = $ignoreNonZeroExitCodes
|
||||
IgnoreExitCodes = if ($ignoreNonZeroExitCodes) { "Yes" } else { "No" }
|
||||
CopyStatus = ""
|
||||
}
|
||||
$listView.Items.Add($application)
|
||||
}
|
||||
$application = [PSCustomObject]@{ Priority = $priority; Name = $name; CommandLine = $commandLine; Arguments = $arguments; Source = $source; CopyStatus = "" }
|
||||
$listView.Items.Add($application)
|
||||
|
||||
# Clear form and update button states for both add and update operations
|
||||
$State.Controls.txtAppName.Text = ""
|
||||
$State.Controls.txtAppCommandLine.Text = ""
|
||||
$State.Controls.txtAppArguments.Text = ""
|
||||
$State.Controls.txtAppSource.Text = ""
|
||||
$State.Controls.txtAppAdditionalExitCodes.Text = ""
|
||||
$State.Controls.chkIgnoreExitCodes.IsChecked = $false
|
||||
Update-CopyButtonState -State $State
|
||||
Update-BYOAppsActionButtonsState -State $State
|
||||
}
|
||||
|
||||
# Function to populate the form for editing a BYO application
|
||||
function Start-EditBYOApplication {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
$listView = $State.Controls.lstApplications
|
||||
$itemToEdit = @($listView.Items | Where-Object { $_.IsSelected }) | Select-Object -First 1
|
||||
|
||||
if ($null -eq $itemToEdit) {
|
||||
[System.Windows.MessageBox]::Show("No application selected or multiple applications selected.", "Edit Error", "OK", "Warning")
|
||||
return
|
||||
}
|
||||
|
||||
# Store the item being edited in the state
|
||||
$State.Data.editingBYOApplication = $itemToEdit
|
||||
|
||||
# Populate the form fields
|
||||
$State.Controls.txtAppName.Text = $itemToEdit.Name
|
||||
$State.Controls.txtAppCommandLine.Text = $itemToEdit.CommandLine
|
||||
$State.Controls.txtAppArguments.Text = $itemToEdit.Arguments
|
||||
$State.Controls.txtAppSource.Text = $itemToEdit.Source
|
||||
$State.Controls.txtAppAdditionalExitCodes.Text = $itemToEdit.AdditionalExitCodes
|
||||
$State.Controls.chkIgnoreExitCodes.IsChecked = $itemToEdit.IgnoreNonZeroExitCodes
|
||||
|
||||
# Change the Add button to Update
|
||||
$State.Controls.btnAddApplication.Content = "Update App"
|
||||
}
|
||||
|
||||
# Function to add a new Apps Script Variable from the UI
|
||||
@@ -160,8 +320,10 @@ function Save-BYOApplicationList {
|
||||
|
||||
try {
|
||||
# Ensure items are sorted by current priority before saving
|
||||
# Exclude CopyStatus when saving and ensure Priority is an integer
|
||||
$applications = $listView.Items | Sort-Object Priority | Select-Object @{N = 'Priority'; E = { [int]$_.Priority } }, Name, CommandLine, Arguments, Source
|
||||
# Exclude UI-only properties (CopyStatus, IgnoreExitCodes) and ensure Priority is an integer
|
||||
$propertiesToSave = 'Priority', 'Name', 'CommandLine', 'Arguments', 'Source', 'AdditionalExitCodes', 'IgnoreNonZeroExitCodes'
|
||||
$applications = $listView.Items | Sort-Object Priority | Select-Object @{N = 'Priority'; E = { [int]$_.Priority } }, Name, CommandLine, Arguments, Source, AdditionalExitCodes, IgnoreNonZeroExitCodes
|
||||
|
||||
$applications | ConvertTo-Json -Depth 5 | Set-Content -Path $Path -Force -Encoding UTF8
|
||||
[System.Windows.MessageBox]::Show("Applications saved successfully to `"$Path`".", "Save Applications", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
|
||||
}
|
||||
@@ -193,14 +355,18 @@ function Import-BYOApplicationList {
|
||||
# Add items and sort by priority from the file
|
||||
$sortedApps = $applications | Sort-Object Priority
|
||||
foreach ($app in $sortedApps) {
|
||||
# Ensure all properties exist, add CopyStatus
|
||||
$ignoreNonZero = if ($app.PSObject.Properties['IgnoreNonZeroExitCodes']) { $app.IgnoreNonZeroExitCodes } else { $false }
|
||||
$appObject = [PSCustomObject]@{
|
||||
Priority = $app.Priority # Keep original priority for now
|
||||
Name = $app.Name
|
||||
CommandLine = $app.CommandLine
|
||||
Arguments = if ($app.PSObject.Properties['Arguments']) { $app.Arguments } else { "" } # Handle missing Arguments
|
||||
Source = $app.Source
|
||||
CopyStatus = "" # Initialize CopyStatus
|
||||
IsSelected = $false
|
||||
Priority = $app.Priority
|
||||
Name = $app.Name
|
||||
CommandLine = $app.CommandLine
|
||||
Arguments = if ($app.PSObject.Properties['Arguments']) { $app.Arguments } else { "" }
|
||||
Source = $app.Source
|
||||
AdditionalExitCodes = if ($app.PSObject.Properties['AdditionalExitCodes']) { $app.AdditionalExitCodes } else { "" }
|
||||
IgnoreNonZeroExitCodes = $ignoreNonZero
|
||||
IgnoreExitCodes = if ($ignoreNonZero) { "Yes" } else { "No" }
|
||||
CopyStatus = ""
|
||||
}
|
||||
$listView.Items.Add($appObject)
|
||||
}
|
||||
@@ -209,7 +375,7 @@ function Import-BYOApplicationList {
|
||||
Update-ListViewPriorities -ListView $listView
|
||||
# Update the Copy Apps button state
|
||||
Update-CopyButtonState -State $State
|
||||
|
||||
Update-BYOAppsActionButtonsState -State $State
|
||||
[System.Windows.MessageBox]::Show("Applications imported successfully from `"$Path`".", "Import Applications", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
|
||||
}
|
||||
catch {
|
||||
@@ -230,8 +396,8 @@ function Invoke-CopyBYOApps {
|
||||
|
||||
try {
|
||||
# Ensure items are sorted by current priority before saving
|
||||
# Exclude CopyStatus when saving and ensure Priority is an integer
|
||||
$applications = $listView.Items | Sort-Object Priority | Select-Object @{N = 'Priority'; E = { [int]$_.Priority } }, Name, CommandLine, Arguments, Source
|
||||
# Exclude CopyStatus when saving and ensure Priority is an integer; include AdditionalExitCodes and IgnoreNonZeroExitCodes for parity with Save-BYOApplicationList
|
||||
$applications = $listView.Items | Sort-Object Priority | Select-Object @{N = 'Priority'; E = { [int]$_.Priority } }, Name, CommandLine, Arguments, Source, AdditionalExitCodes, IgnoreNonZeroExitCodes
|
||||
$applications | ConvertTo-Json -Depth 5 | Set-Content -Path $userAppListPath -Force -Encoding UTF8
|
||||
WriteLog "Successfully updated UserAppList.json with all applications from the UI."
|
||||
}
|
||||
|
||||
@@ -34,10 +34,13 @@ function Get-UIConfig {
|
||||
CopyDrivers = $State.Controls.chkCopyDrivers.IsChecked
|
||||
CopyOfficeConfigXML = $State.Controls.chkCopyOfficeConfigXML.IsChecked
|
||||
CopyPEDrivers = $State.Controls.chkCopyPEDrivers.IsChecked
|
||||
UseDriversAsPEDrivers = $State.Controls.chkUseDriversAsPEDrivers.IsChecked
|
||||
CopyPPKG = $State.Controls.chkCopyPPKG.IsChecked
|
||||
CopyUnattend = $State.Controls.chkCopyUnattend.IsChecked
|
||||
CopyAdditionalFFUFiles = $State.Controls.chkCopyAdditionalFFUFiles.IsChecked
|
||||
CreateCaptureMedia = $State.Controls.chkCreateCaptureMedia.IsChecked
|
||||
CreateDeploymentMedia = $State.Controls.chkCreateDeploymentMedia.IsChecked
|
||||
InjectUnattend = $State.Controls.chkInjectUnattend.IsChecked
|
||||
CustomFFUNameTemplate = $State.Controls.txtCustomFFUNameTemplate.Text
|
||||
Disksize = [int64]$State.Controls.txtDiskSize.Text * 1GB
|
||||
DownloadDrivers = $State.Controls.chkDownloadDrivers.IsChecked
|
||||
@@ -70,7 +73,7 @@ function Get-UIConfig {
|
||||
OfficeConfigXMLFile = $State.Controls.txtOfficeConfigXMLFilePath.Text
|
||||
OfficePath = $State.Controls.txtOfficePath.Text
|
||||
Optimize = $State.Controls.chkOptimize.IsChecked
|
||||
OptionalFeatures = $State.Controls.txtOptionalFeatures.Text
|
||||
OptionalFeatures = (($State.Controls.featureCheckBoxes.GetEnumerator() | Where-Object { $_.Value.IsChecked } | ForEach-Object { $_.Key } | Sort-Object) -join ';')
|
||||
OrchestrationPath = "$($State.Controls.txtApplicationPath.Text)\Orchestration"
|
||||
PEDriversFolder = $State.Controls.txtPEDriversFolder.Text
|
||||
Processors = [int]$State.Controls.txtProcessors.Text
|
||||
@@ -93,6 +96,8 @@ function Get-UIConfig {
|
||||
USBDriveList = @{}
|
||||
Username = $State.Controls.txtUsername.Text
|
||||
Threads = [int]$State.Controls.txtThreads.Text
|
||||
BitsPriority = $State.Controls.cmbBitsPriority.SelectedItem
|
||||
MaxUSBDrives = [int]$State.Controls.txtMaxUSBDrives.Text
|
||||
Verbose = $State.Controls.chkVerbose.IsChecked
|
||||
VMHostIPAddress = $State.Controls.txtVMHostIPAddress.Text
|
||||
VMLocation = $State.Controls.txtVMLocation.Text
|
||||
@@ -109,8 +114,19 @@ function Get-UIConfig {
|
||||
WindowsVersion = $State.Controls.cmbWindowsVersion.SelectedItem
|
||||
}
|
||||
|
||||
# Save selected USB drives using UniqueId for reliable identification
|
||||
$State.Controls.lstUSBDrives.Items | Where-Object { $_.IsSelected } | ForEach-Object {
|
||||
$config.USBDriveList[$_.Model] = $_.SerialNumber
|
||||
$config.USBDriveList[$_.Model] = $_.UniqueId
|
||||
}
|
||||
|
||||
# Additional FFU file selections
|
||||
$config.AdditionalFFUFiles = @()
|
||||
if ($State.Controls.chkCopyAdditionalFFUFiles.IsChecked) {
|
||||
$config.AdditionalFFUFiles = @(
|
||||
$State.Controls.lstAdditionalFFUs.Items |
|
||||
Where-Object { $_.IsSelected } |
|
||||
ForEach-Object { $_.FullName }
|
||||
)
|
||||
}
|
||||
|
||||
return $config
|
||||
@@ -229,6 +245,55 @@ function Set-UIValue {
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ConfigDriverBaseName {
|
||||
param(
|
||||
[string]$RawName
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($RawName)) {
|
||||
return $RawName
|
||||
}
|
||||
|
||||
if ($RawName -match '^(.*?)\s*\((.+)\)\s*$') {
|
||||
return $matches[1].Trim()
|
||||
}
|
||||
|
||||
return $RawName.Trim()
|
||||
}
|
||||
|
||||
function Get-ConfigDriverDisplayName {
|
||||
param(
|
||||
[string]$Make,
|
||||
[string]$StoredName,
|
||||
[string]$ProductName,
|
||||
[string]$SystemId,
|
||||
[string]$MachineType
|
||||
)
|
||||
|
||||
$baseName = if (-not [string]::IsNullOrWhiteSpace($ProductName)) { $ProductName } else { Get-ConfigDriverBaseName -RawName $StoredName }
|
||||
|
||||
switch ($Make) {
|
||||
'Dell' {
|
||||
if ([string]::IsNullOrWhiteSpace($baseName)) { $baseName = $StoredName }
|
||||
if ([string]::IsNullOrWhiteSpace($SystemId)) { return $baseName }
|
||||
return "{0} ({1})" -f $baseName.Trim(), $SystemId.Trim()
|
||||
}
|
||||
'HP' {
|
||||
if ([string]::IsNullOrWhiteSpace($baseName)) { $baseName = $StoredName }
|
||||
if ([string]::IsNullOrWhiteSpace($SystemId)) { return $baseName }
|
||||
return "{0} ({1})" -f $baseName.Trim(), $SystemId.Trim()
|
||||
}
|
||||
'Lenovo' {
|
||||
if ([string]::IsNullOrWhiteSpace($baseName)) { $baseName = $StoredName }
|
||||
if ([string]::IsNullOrWhiteSpace($MachineType)) { return $baseName }
|
||||
return "{0} ({1})" -f $baseName.Trim(), $MachineType.Trim()
|
||||
}
|
||||
default {
|
||||
return $StoredName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-LoadConfiguration {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
@@ -240,19 +305,39 @@ function Invoke-LoadConfiguration {
|
||||
WriteLog "Load configuration cancelled by user."
|
||||
return
|
||||
}
|
||||
|
||||
WriteLog "Loading configuration from: $filePath"
|
||||
$configContent = Get-Content -Path $filePath -Raw | ConvertFrom-Json
|
||||
|
||||
$raw = $null
|
||||
try {
|
||||
$raw = Get-Content -Path $filePath -Raw -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
WriteLog "LoadConfig Error: Failed reading file $filePath : $($_.Exception.Message)"
|
||||
[System.Windows.MessageBox]::Show("Failed to read the configuration file.`n$($_.Exception.Message)", "Load Error", "OK", "Error")
|
||||
return
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($raw)) {
|
||||
WriteLog "LoadConfig Error: File $filePath is empty."
|
||||
[System.Windows.MessageBox]::Show("The selected configuration file is empty.", "Load Error", "OK", "Error")
|
||||
return
|
||||
}
|
||||
$configContent = $null
|
||||
try {
|
||||
$configContent = $raw | ConvertFrom-Json -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
WriteLog "LoadConfig Error: JSON parse failure for $filePath : $($_.Exception.Message)"
|
||||
[System.Windows.MessageBox]::Show("Failed to parse the configuration file (invalid JSON).`n$($_.Exception.Message)", "Load Error", "OK", "Error")
|
||||
return
|
||||
}
|
||||
if ($null -eq $configContent) {
|
||||
WriteLog "LoadConfig Error: configContent is null after parsing $filePath. File might be empty or malformed."
|
||||
[System.Windows.MessageBox]::Show("Failed to parse the configuration file. It might be empty or not valid JSON.", "Load Error", "OK", "Error")
|
||||
WriteLog "LoadConfig Error: Parsed config object is null after $filePath."
|
||||
[System.Windows.MessageBox]::Show("Parsed configuration object was null.", "Load Error", "OK", "Error")
|
||||
return
|
||||
}
|
||||
WriteLog "LoadConfig: Successfully parsed config file. Top-level keys: $($configContent.PSObject.Properties.Name -join ', ')"
|
||||
|
||||
# Apply the configuration to the UI
|
||||
Update-UIFromConfig -ConfigContent $configContent -State $State
|
||||
$State.Data.lastConfigFilePath = $filePath
|
||||
Import-ConfigSupplementalAssets -ConfigContent $configContent -State $State -ShowWarnings:$true
|
||||
}
|
||||
catch {
|
||||
WriteLog "LoadConfig FATAL Error: $($_.Exception.ToString())"
|
||||
@@ -260,6 +345,58 @@ function Invoke-LoadConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
function Select-VMSwitchFromConfig {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$State,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$ConfigContent
|
||||
)
|
||||
|
||||
# Select VM switch based on configuration; fall back to 'Other' with custom name.
|
||||
$combo = $State.Controls.cmbVMSwitchName
|
||||
if ($null -eq $combo) {
|
||||
WriteLog "LoadConfig Error: 'cmbVMSwitchName' control not found."
|
||||
return
|
||||
}
|
||||
|
||||
$configSwitch = $ConfigContent.VMSwitchName
|
||||
if ($null -eq $configSwitch -or [string]::IsNullOrWhiteSpace($configSwitch)) {
|
||||
WriteLog "LoadConfig Info: VMSwitchName in config was empty or null. Leaving selection unchanged."
|
||||
return
|
||||
}
|
||||
|
||||
$itemFound = $false
|
||||
foreach ($item in $combo.Items) {
|
||||
if ($null -ne $item -and $item.ToString().Equals($configSwitch, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
$itemFound = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ($itemFound) {
|
||||
$combo.SelectedItem = ($combo.Items | Where-Object { $_.ToString().Equals($configSwitch, [System.StringComparison]::OrdinalIgnoreCase) } | Select-Object -First 1)
|
||||
$State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
|
||||
WriteLog "LoadConfig: Selected existing VM switch '$configSwitch'."
|
||||
}
|
||||
else {
|
||||
# Ensure 'Other' exists
|
||||
$otherExists = $false
|
||||
foreach ($item in $combo.Items) {
|
||||
if ($null -ne $item -and $item.ToString() -eq 'Other') { $otherExists = $true; break }
|
||||
}
|
||||
if (-not $otherExists) { $combo.Items.Add('Other') | Out-Null }
|
||||
|
||||
# Select 'Other' and populate custom name
|
||||
$combo.SelectedItem = 'Other'
|
||||
$State.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
|
||||
$State.Controls.txtCustomVMSwitchName.Text = $configSwitch
|
||||
$State.Data.customVMSwitchName = $configSwitch
|
||||
$State.Data.customVMHostIP = $ConfigContent.VMHostIPAddress
|
||||
WriteLog "LoadConfig: VMSwitchName '$configSwitch' not found. Selected 'Other' and populated custom VM Switch Name textbox."
|
||||
}
|
||||
}
|
||||
|
||||
function Update-UIFromConfig {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
@@ -277,6 +414,8 @@ function Update-UIFromConfig {
|
||||
Set-UIValue -ControlName 'txtShareName' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ShareName' -State $State
|
||||
Set-UIValue -ControlName 'txtUsername' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Username' -State $State
|
||||
Set-UIValue -ControlName 'txtThreads' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Threads' -State $State
|
||||
Set-UIValue -ControlName 'cmbBitsPriority' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'BitsPriority' -State $State
|
||||
Set-UIValue -ControlName 'txtMaxUSBDrives' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'MaxUSBDrives' -State $State
|
||||
Set-UIValue -ControlName 'chkBuildUSBDriveEnable' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'BuildUSBDrive' -State $State
|
||||
Set-UIValue -ControlName 'chkCompactOS' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CompactOS' -State $State
|
||||
Set-UIValue -ControlName 'chkUpdateADK' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'UpdateADK' -State $State
|
||||
@@ -284,8 +423,10 @@ function Update-UIFromConfig {
|
||||
Set-UIValue -ControlName 'chkAllowVHDXCaching' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'AllowVHDXCaching' -State $State
|
||||
Set-UIValue -ControlName 'chkAllowExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'AllowExternalHardDiskMedia' -State $State
|
||||
Set-UIValue -ControlName 'chkPromptExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'PromptExternalHardDiskMedia' -State $State
|
||||
Set-UIValue -ControlName 'chkCopyAdditionalFFUFiles' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyAdditionalFFUFiles' -State $State
|
||||
Set-UIValue -ControlName 'chkCreateCaptureMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CreateCaptureMedia' -State $State
|
||||
Set-UIValue -ControlName 'chkCreateDeploymentMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CreateDeploymentMedia' -State $State
|
||||
Set-UIValue -ControlName 'chkInjectUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'InjectUnattend' -State $State
|
||||
Set-UIValue -ControlName 'chkVerbose' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'Verbose' -State $State
|
||||
|
||||
# USB Drive Modification group (Build Tab)
|
||||
@@ -303,7 +444,7 @@ function Update-UIFromConfig {
|
||||
Set-UIValue -ControlName 'chkRemoveUpdates' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveUpdates' -State $State
|
||||
|
||||
# Hyper-V Settings
|
||||
Set-UIValue -ControlName 'cmbVMSwitchName' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'VMSwitchName' -State $State
|
||||
Select-VMSwitchFromConfig -State $State -ConfigContent $ConfigContent
|
||||
Set-UIValue -ControlName 'txtVMHostIPAddress' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'VMHostIPAddress' -State $State
|
||||
Set-UIValue -ControlName 'txtDiskSize' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Disksize' -TransformValue { param($val) $val / 1GB } -State $State
|
||||
Set-UIValue -ControlName 'txtMemory' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Memory' -TransformValue { param($val) $val / 1GB } -State $State
|
||||
@@ -363,10 +504,9 @@ function Update-UIFromConfig {
|
||||
Set-UIValue -ControlName 'cmbWindowsSKU' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'WindowsSKU' -State $State
|
||||
Set-UIValue -ControlName 'cmbMediaType' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'MediaType' -State $State
|
||||
Set-UIValue -ControlName 'txtProductKey' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ProductKey' -State $State
|
||||
Set-UIValue -ControlName 'txtOptionalFeatures' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'OptionalFeatures' -State $State
|
||||
|
||||
# Update Optional Features checkboxes based on the loaded text
|
||||
$loadedFeaturesString = $State.Controls.txtOptionalFeatures.Text
|
||||
# Update Optional Features checkboxes
|
||||
$loadedFeaturesString = $ConfigContent.OptionalFeatures
|
||||
if (-not [string]::IsNullOrWhiteSpace($loadedFeaturesString)) {
|
||||
$loadedFeaturesArray = $loadedFeaturesString.Split(';')
|
||||
WriteLog "LoadConfig: Updating Optional Features checkboxes. Loaded features: $($loadedFeaturesArray -join ', ')"
|
||||
@@ -405,6 +545,7 @@ function Update-UIFromConfig {
|
||||
Set-UIValue -ControlName 'txtPEDriversFolder' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'PEDriversFolder' -State $State
|
||||
Set-UIValue -ControlName 'txtDriversJsonPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DriversJsonPath' -State $State
|
||||
Set-UIValue -ControlName 'chkCopyPEDrivers' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyPEDrivers' -State $State
|
||||
Set-UIValue -ControlName 'chkUseDriversAsPEDrivers' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'UseDriversAsPEDrivers' -State $State
|
||||
Set-UIValue -ControlName 'chkCompressDriversToWIM' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CompressDownloadedDriversToWim' -State $State
|
||||
|
||||
# Updates tab
|
||||
@@ -529,8 +670,9 @@ function Update-UIFromConfig {
|
||||
}
|
||||
}
|
||||
|
||||
if ($propertyExists -and ($propertyValue -eq $item.SerialNumber)) {
|
||||
WriteLog "LoadConfig: Selecting USB Drive Model '$($item.Model)' with Serial '$($item.SerialNumber)'."
|
||||
# Match USB drives by UniqueId instead of SerialNumber
|
||||
if ($propertyExists -and ($propertyValue -eq $item.UniqueId)) {
|
||||
WriteLog "LoadConfig: Selecting USB Drive Model '$($item.Model)' with UniqueId '$($item.UniqueId)'."
|
||||
$item.IsSelected = $true
|
||||
}
|
||||
else {
|
||||
@@ -577,8 +719,47 @@ function Update-UIFromConfig {
|
||||
else {
|
||||
WriteLog "LoadConfig: Condition to auto-check 'Select Specific USB Drives' was NOT met."
|
||||
}
|
||||
WriteLog "LoadConfig: Configuration loading process finished."
|
||||
}
|
||||
# Populate additional FFU list and apply selections
|
||||
try {
|
||||
if ($State.Controls.chkCopyAdditionalFFUFiles.IsChecked) {
|
||||
$State.Controls.additionalFFUPanel.Visibility = 'Visible'
|
||||
if ($State.Controls.btnRefreshAdditionalFFUs) {
|
||||
$State.Controls.btnRefreshAdditionalFFUs.RaiseEvent([System.Windows.RoutedEventArgs]::new([System.Windows.Controls.Button]::ClickEvent))
|
||||
}
|
||||
$selectedFiles = @()
|
||||
$addFFUKeyExists = $false
|
||||
if ($ConfigContent -is [System.Management.Automation.PSCustomObject] -and $null -ne $ConfigContent.PSObject.Properties) {
|
||||
if (($ConfigContent.PSObject.Properties.Match('AdditionalFFUFiles')).Count -gt 0) {
|
||||
$addFFUKeyExists = $true
|
||||
}
|
||||
}
|
||||
if ($addFFUKeyExists -and $null -ne $ConfigContent.AdditionalFFUFiles) {
|
||||
$selectedFiles = @($ConfigContent.AdditionalFFUFiles)
|
||||
}
|
||||
if ($selectedFiles.Count -gt 0) {
|
||||
foreach ($item in $State.Controls.lstAdditionalFFUs.Items) {
|
||||
if ($selectedFiles -contains $item.FullName) {
|
||||
$item.IsSelected = $true
|
||||
}
|
||||
}
|
||||
$State.Controls.lstAdditionalFFUs.Items.Refresh()
|
||||
$headerChk = $State.Controls.chkSelectAllAdditionalFFUs
|
||||
if ($null -ne $headerChk) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $State.Controls.lstAdditionalFFUs -HeaderCheckBox $headerChk
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
$State.Controls.additionalFFUPanel.Visibility = 'Collapsed'
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "LoadConfig: Error applying Additional FFU selections: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
Update-BitsPrioritySetting -State $State
|
||||
WriteLog "LoadConfig: Configuration loading process finished."
|
||||
}
|
||||
|
||||
function Invoke-SaveConfiguration {
|
||||
param(
|
||||
@@ -600,7 +781,10 @@ function Invoke-SaveConfiguration {
|
||||
-DefaultExt ".json"
|
||||
|
||||
if ($savePath) {
|
||||
$config | ConvertTo-Json -Depth 10 | Set-Content -Path $savePath -Encoding UTF8
|
||||
# Sort top-level keys alphabetically for consistent output
|
||||
$sortedConfig = [ordered]@{}
|
||||
foreach ($k in ($config.Keys | Sort-Object)) { $sortedConfig[$k] = $config[$k] }
|
||||
$sortedConfig | ConvertTo-Json -Depth 10 | Set-Content -Path $savePath -Encoding UTF8
|
||||
[System.Windows.MessageBox]::Show("Configuration file saved to:`n$savePath", "Success", "OK", "Information")
|
||||
}
|
||||
}
|
||||
@@ -609,4 +793,434 @@ function Invoke-SaveConfiguration {
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-RestoreDefaults {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$State
|
||||
)
|
||||
try {
|
||||
$rootPath = $State.FFUDevelopmentPath
|
||||
|
||||
# Normalize potential array values to single strings
|
||||
function Normalize-PathScalar {
|
||||
param([object]$value)
|
||||
if ($null -eq $value) { return $null }
|
||||
if ($value -is [System.Array]) {
|
||||
foreach ($v in $value) {
|
||||
if (-not [string]::IsNullOrWhiteSpace([string]$v)) {
|
||||
return [string]$v
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
return [string]$value
|
||||
}
|
||||
|
||||
$appsPath = Join-Path $rootPath 'Apps'
|
||||
$driversRaw = Normalize-PathScalar -value $State.Controls.txtDriversFolder.Text
|
||||
if ([string]::IsNullOrWhiteSpace($driversRaw)) {
|
||||
$driversPath = Join-Path $rootPath 'Drivers'
|
||||
}
|
||||
else {
|
||||
$driversPath = $driversRaw
|
||||
}
|
||||
$ffuCaptureRaw = Normalize-PathScalar -value $State.Controls.txtFFUCaptureLocation.Text
|
||||
$ffuCapturePath = if ([string]::IsNullOrWhiteSpace($ffuCaptureRaw)) { Join-Path $rootPath 'FFU' } else { $ffuCaptureRaw }
|
||||
|
||||
$captureISOPath = Join-Path $rootPath 'WinPECaptureFFUFiles\WinPE-Capture.iso'
|
||||
$deployISOPath = Join-Path $rootPath 'WinPEDeployFFUFiles\WinPE-Deploy.iso'
|
||||
$appsISOPath = Join-Path $rootPath 'Apps.iso'
|
||||
|
||||
$msg = "Restore Defaults will:`n`n- Delete generated config and app/driver list JSON files`n- Remove ISO files (Capture, Deploy, Apps) if present`n- Remove Apps/Update/downloaded artifacts`n- Remove driver folder contents (not the folder)`n- Remove FFU files in the capture folder`n`nSample/template files and VM/VHDX cache are NOT removed.`n`nProceed?"
|
||||
$result = [System.Windows.MessageBox]::Show($msg, "Confirm Restore Defaults", "YesNo", "Warning")
|
||||
if ($result -ne [System.Windows.MessageBoxResult]::Yes) {
|
||||
WriteLog "RestoreDefaults: User cancelled."
|
||||
return
|
||||
}
|
||||
|
||||
WriteLog "RestoreDefaults: Starting environment reset."
|
||||
WriteLog "RestoreDefaults: Paths -> Apps=$appsPath Drivers=$driversPath FFUCapture=$ffuCapturePath"
|
||||
|
||||
# Remove JSON artifact files if present
|
||||
$artifactFiles = @(
|
||||
(Join-Path $rootPath 'config\FFUConfig.json'),
|
||||
(Join-Path $appsPath 'AppList.json'),
|
||||
(Join-Path $driversPath 'Drivers.json'),
|
||||
(Join-Path $appsPath 'UserAppList.json')
|
||||
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
|
||||
|
||||
foreach ($file in $artifactFiles) {
|
||||
if ((-not [string]::IsNullOrWhiteSpace($file)) -and (Test-Path -LiteralPath $file)) {
|
||||
try {
|
||||
WriteLog "RestoreDefaults: Removing $file"
|
||||
Remove-Item -LiteralPath $file -Force -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
WriteLog "RestoreDefaults: Failed removing $file : $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Force all cleanup flags true
|
||||
Invoke-FFUPostBuildCleanup `
|
||||
-RootPath $rootPath `
|
||||
-AppsPath $appsPath `
|
||||
-DriversPath $driversPath `
|
||||
-FFUCapturePath $ffuCapturePath `
|
||||
-CaptureISOPath $captureISOPath `
|
||||
-DeployISOPath $deployISOPath `
|
||||
-AppsISOPath $appsISOPath `
|
||||
-KBPath (Join-Path $rootPath 'KB') `
|
||||
-RemoveCaptureISO:$true `
|
||||
-RemoveDeployISO:$true `
|
||||
-RemoveAppsISO:$true `
|
||||
-RemoveDrivers:$true `
|
||||
-RemoveFFU:$true `
|
||||
-RemoveApps:$true `
|
||||
-RemoveUpdates:$true
|
||||
|
||||
# Clear UI lists / state
|
||||
if ($null -ne $State.Data.allDriverModels) { $State.Data.allDriverModels.Clear() }
|
||||
if ($null -ne $State.Controls.lstDriverModels) { $State.Controls.lstDriverModels.Items.Refresh() }
|
||||
if ($null -ne $State.Controls.lstApplications) {
|
||||
try {
|
||||
if ($State.Controls.lstApplications.ItemsSource) { $State.Controls.lstApplications.ItemsSource = $null }
|
||||
$State.Controls.lstApplications.Items.Clear()
|
||||
} catch {}
|
||||
}
|
||||
if ($null -ne $State.Controls.lstWingetResults) {
|
||||
try {
|
||||
if ($State.Controls.lstWingetResults.ItemsSource) { $State.Controls.lstWingetResults.ItemsSource = $null }
|
||||
$State.Controls.lstWingetResults.Items.Clear()
|
||||
} catch {}
|
||||
}
|
||||
if ($null -ne $State.Controls.lstAppsScriptVariables) {
|
||||
try {
|
||||
if ($State.Controls.lstAppsScriptVariables.ItemsSource) { $State.Controls.lstAppsScriptVariables.ItemsSource = $null }
|
||||
$State.Controls.lstAppsScriptVariables.Items.Clear()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
$State.Data.lastConfigFilePath = $null
|
||||
|
||||
Initialize-UIDefaults -State $State
|
||||
|
||||
WriteLog "RestoreDefaults: Completed."
|
||||
[System.Windows.MessageBox]::Show("Environment restored to defaults.", "Restore Defaults", "OK", "Information")
|
||||
}
|
||||
catch {
|
||||
WriteLog "RestoreDefaults: Failed with $($_.Exception.Message)"
|
||||
[System.Windows.MessageBox]::Show("Restore Defaults failed:`n$($_.Exception.Message)", "Error", "OK", "Error")
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-AutoLoadPreviousEnvironment {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$State
|
||||
)
|
||||
try {
|
||||
$ffuDevRoot = $State.FFUDevelopmentPath
|
||||
if ([string]::IsNullOrWhiteSpace($ffuDevRoot)) {
|
||||
WriteLog "AutoLoad: FFUDevelopmentPath not set; skipping."
|
||||
return
|
||||
}
|
||||
$configPath = Join-Path $ffuDevRoot "config\FFUConfig.json"
|
||||
if (-not (Test-Path -LiteralPath $configPath)) {
|
||||
WriteLog "AutoLoad: No existing FFUConfig.json found at $configPath."
|
||||
return
|
||||
}
|
||||
WriteLog "AutoLoad: Found config file at $configPath. Parsing..."
|
||||
$raw = Get-Content -Path $configPath -Raw -ErrorAction SilentlyContinue
|
||||
if ([string]::IsNullOrWhiteSpace($raw)) {
|
||||
WriteLog "AutoLoad: Config file empty; aborting."
|
||||
return
|
||||
}
|
||||
$configContent = $null
|
||||
try {
|
||||
$configContent = $raw | ConvertFrom-Json -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
WriteLog "AutoLoad: JSON parse failed: $($_.Exception.Message)"
|
||||
return
|
||||
}
|
||||
if ($null -eq $configContent) {
|
||||
WriteLog "AutoLoad: Parsed object null; aborting."
|
||||
return
|
||||
}
|
||||
WriteLog "AutoLoad: Applying core configuration."
|
||||
Update-UIFromConfig -ConfigContent $configContent -State $State
|
||||
$State.Data.lastConfigFilePath = $configPath
|
||||
Import-ConfigSupplementalAssets -ConfigContent $configContent -State $State -ShowWarnings:$false
|
||||
WriteLog "AutoLoad: Completed supplemental import with warnings disabled."
|
||||
}
|
||||
catch {
|
||||
WriteLog "AutoLoad: Unexpected failure: $($_.Exception.ToString())"
|
||||
}
|
||||
}
|
||||
|
||||
function Import-ConfigSupplementalAssets {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$ConfigContent,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[psobject]$State,
|
||||
[Parameter()]
|
||||
[bool]$ShowWarnings = $false
|
||||
)
|
||||
WriteLog "SupplementalImport: Starting import of helper assets."
|
||||
$loadedWinget = $false
|
||||
$loadedBYO = $false
|
||||
$loadedDrivers = $false
|
||||
$missing = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
# Winget AppList
|
||||
$appListPath = $null
|
||||
if ($ConfigContent.PSObject.Properties.Match('AppListPath').Count -gt 0) {
|
||||
$appListPath = $ConfigContent.AppListPath
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($appListPath)) {
|
||||
if (Test-Path -LiteralPath $appListPath) {
|
||||
WriteLog "SupplementalImport: Loading Winget AppList from $appListPath"
|
||||
try {
|
||||
$importedAppsData = Get-Content -Path $appListPath -Raw | ConvertFrom-Json -ErrorAction Stop
|
||||
if ($null -ne $importedAppsData -and $null -ne $importedAppsData.apps) {
|
||||
$defaultArch = $State.Controls.cmbWindowsArch.SelectedItem
|
||||
$appsBuffer = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($appInfo in $importedAppsData.apps) {
|
||||
$arch = if ($appInfo.source -eq 'msstore') { 'NA' } else {
|
||||
if ($appInfo.PSObject.Properties['architecture']) { $appInfo.architecture } else { $defaultArch }
|
||||
}
|
||||
$appsBuffer.Add([PSCustomObject]@{
|
||||
IsSelected = $true
|
||||
Name = $appInfo.name
|
||||
Id = $appInfo.id
|
||||
Version = ""
|
||||
Source = $appInfo.source
|
||||
Architecture = $arch
|
||||
AdditionalExitCodes = if ($appInfo.PSObject.Properties['AdditionalExitCodes']) { $appInfo.AdditionalExitCodes } else { "" }
|
||||
IgnoreNonZeroExitCodes = if ($appInfo.PSObject.Properties['IgnoreNonZeroExitCodes']) { [bool]$appInfo.IgnoreNonZeroExitCodes } else { $false }
|
||||
DownloadStatus = ""
|
||||
})
|
||||
}
|
||||
$State.Controls.lstWingetResults.ItemsSource = $appsBuffer.ToArray()
|
||||
$loadedWinget = $true
|
||||
if ($null -ne $State.Controls.wingetSearchPanel) {
|
||||
$State.Controls.wingetSearchPanel.Visibility = 'Visible'
|
||||
}
|
||||
if ($null -ne $State.Controls.chkSelectAllWingetResults -and (Get-Command -Name Update-SelectAllHeaderCheckBoxState -ErrorAction SilentlyContinue)) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $State.Controls.lstWingetResults -HeaderCheckBox $State.Controls.chkSelectAllWingetResults
|
||||
}
|
||||
WriteLog "SupplementalImport: Winget list loaded with $($appsBuffer.Count) entries."
|
||||
}
|
||||
else {
|
||||
WriteLog "SupplementalImport: Winget AppList missing 'apps' array."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "SupplementalImport: Failed loading Winget AppList ($appListPath): $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "SupplementalImport: Winget AppList file missing: $appListPath"
|
||||
$missing.Add("Winget AppList (AppListPath): $appListPath")
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "SupplementalImport: AppListPath not defined in config."
|
||||
}
|
||||
|
||||
# UserAppList (BYO)
|
||||
$userAppListPath = $null
|
||||
if ($ConfigContent.PSObject.Properties.Match('UserAppListPath').Count -gt 0) {
|
||||
$userAppListPath = $ConfigContent.UserAppListPath
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($userAppListPath)) {
|
||||
if (Test-Path -LiteralPath $userAppListPath) {
|
||||
WriteLog "SupplementalImport: Loading UserAppList from $userAppListPath"
|
||||
try {
|
||||
$applications = Get-Content -Path $userAppListPath -Raw | ConvertFrom-Json -ErrorAction Stop
|
||||
if ($applications) {
|
||||
$listView = $State.Controls.lstApplications
|
||||
$listView.Items.Clear()
|
||||
$sortedApps = $applications | Sort-Object Priority
|
||||
foreach ($app in $sortedApps) {
|
||||
$ignoreNonZero = if ($app.PSObject.Properties['IgnoreNonZeroExitCodes']) { $app.IgnoreNonZeroExitCodes } else { $false }
|
||||
$listView.Items.Add([PSCustomObject]@{
|
||||
IsSelected = $false
|
||||
Priority = $app.Priority
|
||||
Name = $app.Name
|
||||
CommandLine = $app.CommandLine
|
||||
Arguments = if ($app.PSObject.Properties['Arguments']) { $app.Arguments } else { "" }
|
||||
Source = $app.Source
|
||||
AdditionalExitCodes = if ($app.PSObject.Properties['AdditionalExitCodes']) { $app.AdditionalExitCodes } else { "" }
|
||||
IgnoreNonZeroExitCodes = $ignoreNonZero
|
||||
IgnoreExitCodes = if ($ignoreNonZero) { "Yes" } else { "No" }
|
||||
CopyStatus = ""
|
||||
})
|
||||
}
|
||||
if (Get-Command -Name Update-ListViewPriorities -ErrorAction SilentlyContinue) {
|
||||
Update-ListViewPriorities -ListView $listView
|
||||
}
|
||||
if (Get-Command -Name Update-CopyButtonState -ErrorAction SilentlyContinue) {
|
||||
Update-CopyButtonState -State $State
|
||||
}
|
||||
if (Get-Command -Name Update-BYOAppsActionButtonsState -ErrorAction SilentlyContinue) {
|
||||
Update-BYOAppsActionButtonsState -State $State
|
||||
}
|
||||
$loadedBYO = $true
|
||||
WriteLog "SupplementalImport: UserAppList loaded with $($listView.Items.Count) entries."
|
||||
}
|
||||
else {
|
||||
WriteLog "SupplementalImport: UserAppList JSON empty."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "SupplementalImport: Failed loading UserAppList ($userAppListPath): $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "SupplementalImport: UserAppList file missing: $userAppListPath"
|
||||
$missing.Add("UserAppList (UserAppListPath): $userAppListPath")
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "SupplementalImport: UserAppListPath not defined in config."
|
||||
}
|
||||
|
||||
# Drivers JSON
|
||||
$driversJsonPath = $null
|
||||
if ($ConfigContent.PSObject.Properties.Match('DriversJsonPath').Count -gt 0) {
|
||||
$driversJsonPath = $ConfigContent.DriversJsonPath
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($driversJsonPath)) {
|
||||
if (Test-Path -LiteralPath $driversJsonPath) {
|
||||
WriteLog "SupplementalImport: Loading Drivers JSON from $driversJsonPath"
|
||||
try {
|
||||
$rawDrivers = Get-Content -Path $driversJsonPath -Raw | ConvertFrom-Json -ErrorAction Stop
|
||||
if ($rawDrivers -and $rawDrivers.PSObject.Properties.Count -gt 0) {
|
||||
$State.Data.allDriverModels.Clear()
|
||||
foreach ($makeProp in $rawDrivers.PSObject.Properties) {
|
||||
$makeName = $makeProp.Name
|
||||
$makeObject = $makeProp.Value
|
||||
if ($null -eq $makeObject -or -not ($makeObject.PSObject.Properties['Models'])) { continue }
|
||||
$models = $makeObject.Models
|
||||
if ($models -and ($models -is [System.Collections.IEnumerable])) {
|
||||
foreach ($modelEntry in $models) {
|
||||
if ($null -eq $modelEntry -or -not ($modelEntry.PSObject.Properties['Name'])) { continue }
|
||||
$modelName = $modelEntry.Name
|
||||
if ([string]::IsNullOrWhiteSpace($modelName)) { continue }
|
||||
$downloadStatus = if ($modelEntry.PSObject.Properties['DownloadStatus']) { $modelEntry.DownloadStatus } else { "" }
|
||||
$linkValue = if ($modelEntry.PSObject.Properties['Link']) { $modelEntry.Link } else { $null }
|
||||
$productName = if ($modelEntry.PSObject.Properties['ProductName']) { $modelEntry.ProductName } else { $null }
|
||||
$machineType = if ($modelEntry.PSObject.Properties['MachineType']) { $modelEntry.MachineType } else { $null }
|
||||
$systemId = if ($modelEntry.PSObject.Properties['SystemId']) { $modelEntry.SystemId } else { $null }
|
||||
$idValue = if ($modelEntry.PSObject.Properties['Id']) { $modelEntry.Id } else { $null }
|
||||
if ($null -eq $idValue -and -not [string]::IsNullOrWhiteSpace($systemId)) { $idValue = $systemId }
|
||||
if ($null -eq $idValue -and -not [string]::IsNullOrWhiteSpace($machineType)) { $idValue = $machineType }
|
||||
$displayModel = Get-ConfigDriverDisplayName -Make $makeName -StoredName $modelName -ProductName $productName -SystemId $systemId -MachineType $machineType
|
||||
if ([string]::IsNullOrWhiteSpace($displayModel)) {
|
||||
$displayModel = $modelName
|
||||
}
|
||||
$driverObj = [PSCustomObject]@{
|
||||
IsSelected = $true
|
||||
Make = $makeName
|
||||
Model = $displayModel
|
||||
DownloadStatus = $downloadStatus
|
||||
Link = $linkValue
|
||||
ProductName = $productName
|
||||
MachineType = $machineType
|
||||
SystemId = $systemId
|
||||
Id = $idValue
|
||||
}
|
||||
$State.Data.allDriverModels.Add($driverObj)
|
||||
}
|
||||
}
|
||||
}
|
||||
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
|
||||
if (Get-Command -Name Update-SelectAllHeaderCheckBoxState -ErrorAction SilentlyContinue) {
|
||||
$headerChk = $State.Controls.chkSelectAllDriverModels
|
||||
if ($null -ne $headerChk) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $State.Controls.lstDriverModels -HeaderCheckBox $headerChk
|
||||
}
|
||||
}
|
||||
if ($State.Data.allDriverModels.Count -gt 0) {
|
||||
if ($null -ne $State.Controls.spModelFilterSection) { $State.Controls.spModelFilterSection.Visibility = 'Visible' }
|
||||
if ($null -ne $State.Controls.lstDriverModels) { $State.Controls.lstDriverModels.Visibility = 'Visible' }
|
||||
if ($null -ne $State.Controls.spDriverActionButtons) { $State.Controls.spDriverActionButtons.Visibility = 'Visible' }
|
||||
try {
|
||||
if ($State.Controls.cmbMake.SelectedIndex -lt 0 -and $State.Data.allDriverModels.Count -gt 0) {
|
||||
$firstMake = ($State.Data.allDriverModels | Select-Object -First 1).Make
|
||||
if (-not [string]::IsNullOrWhiteSpace($firstMake)) {
|
||||
$makeItem = $State.Controls.cmbMake.Items | Where-Object { $_ -eq $firstMake } | Select-Object -First 1
|
||||
if ($makeItem) { $State.Controls.cmbMake.SelectedItem = $makeItem }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "SupplementalImport: Non-fatal error selecting first Make: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
$loadedDrivers = $true
|
||||
WriteLog "SupplementalImport: Loaded $($State.Data.allDriverModels.Count) driver models."
|
||||
}
|
||||
else {
|
||||
WriteLog "SupplementalImport: Drivers JSON empty or structure unexpected."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "SupplementalImport: Failed loading Drivers JSON ($driversJsonPath): $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "SupplementalImport: Drivers JSON file missing: $driversJsonPath"
|
||||
$missing.Add("Drivers (DriversJsonPath): $driversJsonPath")
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "SupplementalImport: DriversJsonPath not defined in config."
|
||||
}
|
||||
|
||||
if ($loadedWinget -or $loadedBYO) {
|
||||
$State.Controls.chkInstallApps.IsChecked = $true
|
||||
}
|
||||
if ($loadedWinget) {
|
||||
$State.Controls.chkInstallWingetApps.IsChecked = $true
|
||||
}
|
||||
if ($loadedBYO) {
|
||||
$State.Controls.chkBringYourOwnApps.IsChecked = $true
|
||||
}
|
||||
if ($loadedDrivers) {
|
||||
$State.Controls.chkDownloadDrivers.IsChecked = $true
|
||||
}
|
||||
|
||||
if (Get-Command -Name Update-ApplicationPanelVisibility -ErrorAction SilentlyContinue) {
|
||||
Update-ApplicationPanelVisibility -State $State -TriggeringControlName 'SupplementalImport'
|
||||
}
|
||||
if (Get-Command -Name Update-DriverDownloadPanelVisibility -ErrorAction SilentlyContinue) {
|
||||
Update-DriverDownloadPanelVisibility -State $State
|
||||
}
|
||||
if (Get-Command -Name Update-DriverCheckboxStates -ErrorAction SilentlyContinue) {
|
||||
Update-DriverCheckboxStates -State $State
|
||||
}
|
||||
if (Get-Command -Name Update-OfficePanelVisibility -ErrorAction SilentlyContinue) {
|
||||
Update-OfficePanelVisibility -State $State
|
||||
}
|
||||
if (Get-Command -Name Update-CopyButtonState -ErrorAction SilentlyContinue) {
|
||||
Update-CopyButtonState -State $State
|
||||
}
|
||||
|
||||
# Updated message to clarify successful load and that missing helper files are optional if not yet created.
|
||||
if ($ShowWarnings -and $missing.Count -gt 0) {
|
||||
$msg = "Configuration file loaded successfully.`n`n" +
|
||||
"Optional helper file(s) referenced in the configuration were not found:`n" +
|
||||
($missing | ForEach-Object { "- $_" } | Out-String) +
|
||||
"`nThese files are optional. They won't exist until you create Winget (AppList.json), User (UserAppList.json), or Driver (Drivers.json) manifests. You can create them later or ignore this message."
|
||||
[System.Windows.MessageBox]::Show($msg.TrimEnd(), "Configuration Loaded - Optional Files Missing", "OK", "Information") | Out-Null
|
||||
}
|
||||
|
||||
WriteLog ("SupplementalImport: Complete. Winget={0} BYO={1} Drivers={2} Missing={3}" -f $loadedWinget, $loadedBYO, $loadedDrivers, $missing.Count)
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function *
|
||||
@@ -12,147 +12,98 @@ function Get-DellDriversModelList {
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$WindowsRelease,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DriversFolder, # Base drivers folder (e.g., C:\FFUDevelopment\Drivers)
|
||||
[string]$DriversFolder,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Make # Should be 'Dell'
|
||||
[string]$Make
|
||||
)
|
||||
|
||||
# Define Dell specific drivers folder and catalog file names
|
||||
# Client pathway (<=11) uses CatalogIndexPC to build full Brand Model (SystemID) strings.
|
||||
if ($WindowsRelease -le 11) {
|
||||
$dellModels = Get-DellClientModels -CatalogIndexXmlPath (Get-DellCatalogIndex -DriversFolder $DriversFolder)
|
||||
$final = [System.Collections.Generic.List[pscustomobject]]::new()
|
||||
foreach ($m in $dellModels) {
|
||||
$final.Add([pscustomobject]@{
|
||||
Make = $Make
|
||||
Model = $m.ModelDisplay
|
||||
Brand = $m.Brand
|
||||
ModelNumber = $m.ModelNumber
|
||||
SystemId = $m.SystemId
|
||||
CabRelativePath = $m.CabRelativePath
|
||||
CabUrl = $m.CabUrl
|
||||
})
|
||||
}
|
||||
return $final
|
||||
}
|
||||
|
||||
# Server pathway (unchanged – still uses Catalog.cab)
|
||||
$dellDriversFolder = Join-Path -Path $DriversFolder -ChildPath "Dell"
|
||||
$catalogBaseName = if ($WindowsRelease -le 11) { "CatalogPC" } else { "Catalog" }
|
||||
$catalogBaseName = "Catalog"
|
||||
$dellCabFile = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).cab"
|
||||
$dellCatalogXML = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).xml"
|
||||
$catalogUrl = if ($WindowsRelease -le 11) { "http://downloads.dell.com/catalog/CatalogPC.cab" } else { "https://downloads.dell.com/catalog/Catalog.cab" }
|
||||
$catalogUrl = "https://downloads.dell.com/catalog/Catalog.cab"
|
||||
|
||||
$uniqueModelNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
$reader = $null
|
||||
if (-not (Test-Path -Path $dellDriversFolder)) {
|
||||
New-Item -Path $dellDriversFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
$download = $true
|
||||
if (Test-Path -Path $dellCatalogXML) {
|
||||
if (((Get-Date) - (Get-Item $dellCatalogXML).CreationTime).TotalDays -lt 7) {
|
||||
$download = $false
|
||||
}
|
||||
}
|
||||
|
||||
if ($download) {
|
||||
if (Test-Path $dellCabFile) { Remove-Item $dellCabFile -Force -ErrorAction SilentlyContinue }
|
||||
if (Test-Path $dellCatalogXML) { Remove-Item $dellCatalogXML -Force -ErrorAction SilentlyContinue }
|
||||
Start-BitsTransferWithRetry -Source $catalogUrl -Destination $dellCabFile
|
||||
Invoke-Process -FilePath Expand.exe -ArgumentList """$dellCabFile"" ""$dellCatalogXML""" | Out-Null
|
||||
Remove-Item $dellCabFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if (-not (Test-Path $dellCatalogXML)) { throw "Dell server catalog XML missing: $dellCatalogXML" }
|
||||
|
||||
$settings = New-Object System.Xml.XmlReaderSettings
|
||||
$settings.IgnoreWhitespace = $true
|
||||
$settings.IgnoreComments = $true
|
||||
$reader = [System.Xml.XmlReader]::Create($dellCatalogXML, $settings)
|
||||
$inDriver = $false
|
||||
$inModel = $false
|
||||
$depthModel = -1
|
||||
$modelsHash = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
try {
|
||||
# Check if the Dell catalog XML exists and is recent
|
||||
$downloadCatalog = $true
|
||||
if (Test-Path -Path $dellCatalogXML -PathType Leaf) {
|
||||
WriteLog "Dell Catalog XML found: $dellCatalogXML"
|
||||
$dellCatalogCreationTime = (Get-Item $dellCatalogXML).CreationTime
|
||||
WriteLog "Dell Catalog XML Creation time: $dellCatalogCreationTime"
|
||||
# Check if the XML file is less than 7 days old
|
||||
if (((Get-Date) - $dellCatalogCreationTime).TotalDays -lt 7) {
|
||||
WriteLog "Using existing Dell Catalog XML (less than 7 days old): $dellCatalogXML"
|
||||
$downloadCatalog = $false
|
||||
}
|
||||
else {
|
||||
WriteLog "Existing Dell Catalog XML is older than 7 days: $dellCatalogXML"
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Dell Catalog XML not found: $dellCatalogXML"
|
||||
}
|
||||
|
||||
if ($downloadCatalog) {
|
||||
WriteLog "Attempting to download and extract Dell Catalog for Get-DellDriversModelList..."
|
||||
# Ensure Dell drivers folder exists
|
||||
if (-not (Test-Path -Path $dellDriversFolder -PathType Container)) {
|
||||
WriteLog "Creating Dell drivers folder: $dellDriversFolder"
|
||||
New-Item -Path $dellDriversFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
# Check URL accessibility
|
||||
try {
|
||||
$request = [System.Net.WebRequest]::Create($catalogUrl)
|
||||
$request.Method = 'HEAD'; $response = $request.GetResponse(); $response.Close()
|
||||
}
|
||||
catch { throw "Dell Catalog URL '$catalogUrl' not accessible: $($_.Exception.Message)" }
|
||||
|
||||
# Remove existing files before download if they exist
|
||||
if (Test-Path -Path $dellCabFile) { Remove-Item -Path $dellCabFile -Force -ErrorAction SilentlyContinue }
|
||||
if (Test-Path -Path $dellCatalogXML) { Remove-Item -Path $dellCatalogXML -Force -ErrorAction SilentlyContinue }
|
||||
|
||||
WriteLog "Downloading Dell Catalog cab file: $catalogUrl to $dellCabFile"
|
||||
Start-BitsTransferWithRetry -Source $catalogUrl -Destination $dellCabFile
|
||||
WriteLog "Dell Catalog cab file downloaded to $dellCabFile"
|
||||
|
||||
WriteLog "Extracting Dell Catalog cab file '$dellCabFile' to '$dellCatalogXML'"
|
||||
Invoke-Process -FilePath "Expand.exe" -ArgumentList """$dellCabFile"" ""$dellCatalogXML""" | Out-Null
|
||||
WriteLog "Dell Catalog cab file extracted to $dellCatalogXML"
|
||||
|
||||
# Delete the CAB file after extraction
|
||||
WriteLog "Deleting Dell Catalog CAB file: $dellCabFile"
|
||||
Remove-Item -Path $dellCabFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Ensure the XML file exists before trying to read it
|
||||
if (-not (Test-Path -Path $dellCatalogXML -PathType Leaf)) {
|
||||
throw "Dell Catalog XML file '$dellCatalogXML' not found after download/check attempt."
|
||||
}
|
||||
|
||||
# Use XmlReader for streaming from the XML file
|
||||
$settings = New-Object System.Xml.XmlReaderSettings
|
||||
$settings.IgnoreWhitespace = $true
|
||||
$settings.IgnoreComments = $true
|
||||
# $settings.DtdProcessing = [System.Xml.DtdProcessing]::Ignore # Optional
|
||||
|
||||
$reader = [System.Xml.XmlReader]::Create($dellCatalogXML, $settings)
|
||||
WriteLog "Starting XML stream parsing for Dell models from '$dellCatalogXML'..."
|
||||
|
||||
$isDriverComponent = $false
|
||||
$isModelElement = $false
|
||||
$modelDepth = -1 # Track depth to handle nested elements if needed
|
||||
|
||||
# Read through the XML stream node by node
|
||||
while ($reader.Read()) {
|
||||
switch ($reader.NodeType) {
|
||||
([System.Xml.XmlNodeType]::Element) {
|
||||
switch ($reader.Name) {
|
||||
'SoftwareComponent' { $isDriverComponent = $false } # Reset flag
|
||||
'ComponentType' { if ($reader.GetAttribute('value') -eq 'DRVR') { $isDriverComponent = $true } }
|
||||
'Model' { if ($isDriverComponent) { $isModelElement = $true; $modelDepth = $reader.Depth } }
|
||||
'SoftwareComponent' { $inDriver = $false }
|
||||
'ComponentType' { if ($reader.GetAttribute('value') -eq 'DRVR') { $inDriver = $true } }
|
||||
'Model' { if ($inDriver) { $inModel = $true; $depthModel = $reader.Depth } }
|
||||
}
|
||||
}
|
||||
([System.Xml.XmlNodeType]::CDATA) {
|
||||
if ($isModelElement -and $isDriverComponent) {
|
||||
$modelName = $reader.Value.Trim()
|
||||
if (-not [string]::IsNullOrWhiteSpace($modelName)) { $uniqueModelNames.Add($modelName) | Out-Null }
|
||||
$isModelElement = $false # Reset after reading CDATA
|
||||
if ($inDriver -and $inModel) {
|
||||
$val = $reader.Value.Trim()
|
||||
if ($val) { $modelsHash.Add($val) | Out-Null }
|
||||
$inModel = $false
|
||||
}
|
||||
}
|
||||
([System.Xml.XmlNodeType]::EndElement) {
|
||||
switch ($reader.Name) {
|
||||
'SoftwareComponent' { $isDriverComponent = $false; $isModelElement = $false; $modelDepth = -1 }
|
||||
'Model' { if ($reader.Depth -eq $modelDepth) { $isModelElement = $false; $modelDepth = -1 } }
|
||||
}
|
||||
if ($reader.Name -eq 'SoftwareComponent') { $inDriver = $false; $inModel = $false }
|
||||
elseif ($reader.Name -eq 'Model' -and $reader.Depth -eq $depthModel) { $inModel = $false; $depthModel = -1 }
|
||||
}
|
||||
}
|
||||
} # End while ($reader.Read())
|
||||
|
||||
WriteLog "Finished XML stream parsing. Found $($uniqueModelNames.Count) unique Dell models."
|
||||
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error getting Dell models: $($_.Exception.ToString())" # Log full exception
|
||||
throw "Failed to retrieve Dell models. Check log for details." # Re-throw for UI handling
|
||||
}
|
||||
}
|
||||
finally {
|
||||
# Ensure the reader is closed and disposed
|
||||
if ($null -ne $reader) {
|
||||
$reader.Dispose()
|
||||
}
|
||||
# Ensure CAB file is deleted even if extraction failed but download succeeded
|
||||
if (Test-Path -Path $dellCabFile) {
|
||||
WriteLog "Cleaning up downloaded Dell CAB file: $dellCabFile"
|
||||
Remove-Item -Path $dellCabFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
$reader.Dispose()
|
||||
}
|
||||
|
||||
# Convert HashSet to sorted list of PSCustomObjects
|
||||
$models = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
foreach ($modelName in ($uniqueModelNames | Sort-Object)) {
|
||||
$models.Add([PSCustomObject]@{
|
||||
Make = $Make
|
||||
Model = $modelName
|
||||
# Link is not applicable here like for Microsoft
|
||||
})
|
||||
$out = [System.Collections.Generic.List[pscustomobject]]::new()
|
||||
foreach ($nm in ($modelsHash | Sort-Object)) {
|
||||
$out.Add([pscustomobject]@{ Make = $Make; Model = $nm })
|
||||
}
|
||||
|
||||
return $models
|
||||
return $out
|
||||
}
|
||||
|
||||
# Function to download and extract drivers for a specific Dell model (Modified for ForEach-Object -Parallel)
|
||||
@@ -160,548 +111,261 @@ function Save-DellDriversTask {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[PSCustomObject]$DriverItemData, # Contains Model property
|
||||
[pscustomobject]$DriverItemData,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DriversFolder, # Base drivers folder (e.g., C:\FFUDevelopment\Drivers)
|
||||
[string]$DriversFolder,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$WindowsArch,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$WindowsRelease,
|
||||
[Parameter()] # Made optional
|
||||
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null
|
||||
[Parameter()]
|
||||
[bool]$CompressToWim = $false # New parameter for compression
|
||||
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null,
|
||||
[Parameter()]
|
||||
[bool]$CompressToWim = $false,
|
||||
[Parameter()]
|
||||
[bool]$PreserveSourceOnCompress = $false
|
||||
)
|
||||
|
||||
$modelName = $DriverItemData.Model
|
||||
$make = "Dell" # Hardcoded for this task
|
||||
$status = "Starting..." # Initial local status
|
||||
$success = $false
|
||||
$modelDisplay = $DriverItemData.Model
|
||||
$make = 'Dell'
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Checking...' }
|
||||
|
||||
# Initial status update
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Checking..." }
|
||||
$sanitizedModelName = ConvertTo-SafeName -Name $modelDisplay
|
||||
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $make
|
||||
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $sanitizedModelName
|
||||
$driverRelativePath = Join-Path -Path $make -ChildPath $sanitizedModelName
|
||||
|
||||
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $Make
|
||||
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $modelName
|
||||
$driverRelativePath = Join-Path -Path $make -ChildPath $modelName # Relative path for the driver folder
|
||||
# Helper: safe folder removal
|
||||
function Remove-SafeFolder {
|
||||
param([string]$Path)
|
||||
if ([string]::IsNullOrWhiteSpace($Path)) { return }
|
||||
# Never allow deleting the entire Dell root folder accidentally
|
||||
$dellRoot = (Resolve-Path $makeDriversPath).ProviderPath
|
||||
$target = (Resolve-Path $Path -ErrorAction SilentlyContinue)?.ProviderPath
|
||||
if ($null -eq $target) { return }
|
||||
if ($target -eq $dellRoot) { return }
|
||||
if (-not ($target.StartsWith($dellRoot, [System.StringComparison]::OrdinalIgnoreCase))) { return }
|
||||
Remove-Item -Path $target -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
try {
|
||||
# Check for existing drivers
|
||||
$existingDriver = Test-ExistingDriver -Make $make -Model $modelName -DriversFolder $DriversFolder -Identifier $modelName -ProgressQueue $ProgressQueue
|
||||
if ($null -ne $existingDriver) {
|
||||
# Add the 'Model' property to the return object for consistency if it's not there
|
||||
if (-not $existingDriver.PSObject.Properties['Model']) {
|
||||
$existingDriver | Add-Member -MemberType NoteProperty -Name 'Model' -Value $modelName
|
||||
# Existing drivers short‑circuit
|
||||
$existing = Test-ExistingDriver -Make $make -Model $sanitizedModelName -DriversFolder $DriversFolder -Identifier $modelDisplay -ProgressQueue $ProgressQueue
|
||||
if ($existing) {
|
||||
if (-not $existing.PSObject.Properties['Model']) {
|
||||
$existing | Add-Member -MemberType NoteProperty -Name 'Model' -Value $modelDisplay
|
||||
}
|
||||
|
||||
# Special handling for existing folders that need compression
|
||||
if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') {
|
||||
$wimFilePath = Join-Path -Path $makeDriversPath -ChildPath "$($modelName).wim"
|
||||
$sourceFolderPath = Join-Path -Path $makeDriversPath -ChildPath $modelName
|
||||
WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Compressing existing..." }
|
||||
if ($CompressToWim -and $existing.Status -eq 'Already downloaded') {
|
||||
$wimPath = Join-Path $makeDriversPath "$sanitizedModelName.wim"
|
||||
$wimRelativePath = Join-Path $make "$sanitizedModelName.wim"
|
||||
$srcPath = Join-Path $makeDriversPath $sanitizedModelName
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Compressing existing...' }
|
||||
try {
|
||||
Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $modelName -WimDescription "Drivers for $modelName" -ErrorAction Stop
|
||||
$existingDriver.Status = "Already downloaded & Compressed"
|
||||
$existingDriver.DriverPath = Join-Path -Path $make -ChildPath "$($modelName).wim"
|
||||
$existingDriver.Success = $true
|
||||
WriteLog "Successfully compressed existing drivers for $modelName to $wimFilePath."
|
||||
$null = Compress-DriverFolderToWim -SourceFolderPath $srcPath -DestinationWimPath $wimPath -WimName $modelDisplay -WimDescription "Drivers for $modelDisplay" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
|
||||
$existing.Status = 'Compression successful'
|
||||
$existing.DriverPath = $wimRelativePath
|
||||
$existing.Success = $true
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error compressing existing drivers for $($modelName): $($_.Exception.Message)"
|
||||
$existingDriver.Status = "Already downloaded (Compression failed)"
|
||||
$existingDriver.Success = $false
|
||||
WriteLog "Compression failed for $($modelDisplay): $($_.Exception.Message)"
|
||||
$existing.Status = 'Already downloaded (Compression failed)'
|
||||
$existing.Success = $false
|
||||
}
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $existingDriver.Status }
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status $existing.Status }
|
||||
}
|
||||
|
||||
return $existingDriver
|
||||
return $existing
|
||||
}
|
||||
|
||||
# Define paths for Dell catalog. The catalog is assumed to be prepared by the calling function.
|
||||
$dellDriversFolder = Join-Path -Path $DriversFolder -ChildPath "Dell"
|
||||
$catalogBaseName = if ($WindowsRelease -le 11) { "CatalogPC" } else { "Catalog" }
|
||||
$dellCatalogXML = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).xml"
|
||||
if (-not (Test-Path $makeDriversPath)) { New-Item -Path $makeDriversPath -ItemType Directory -Force | Out-Null }
|
||||
if (-not (Test-Path $modelPath)) { New-Item -Path $modelPath -ItemType Directory -Force | Out-Null }
|
||||
|
||||
# 3. Parse the *EXISTING* XML and Find Drivers for *this specific model*
|
||||
$status = "Finding drivers..."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
$packages = @()
|
||||
|
||||
# Check if the provided XML path exists
|
||||
if (-not (Test-Path -Path $dellCatalogXML -PathType Leaf)) {
|
||||
throw "Dell Catalog XML file not found at specified path: $dellCatalogXML"
|
||||
if ($WindowsRelease -le 11) {
|
||||
$cabUrl = $DriverItemData.CabUrl
|
||||
if ([string]::IsNullOrWhiteSpace($cabUrl)) {
|
||||
WriteLog "CabUrl missing for '$modelDisplay' – resolving via CatalogIndexPC."
|
||||
$resolved = Resolve-DellCabUrlFromModel -DriversFolder $DriversFolder -ModelDisplay $modelDisplay
|
||||
if ($null -eq $resolved -or [string]::IsNullOrWhiteSpace($resolved.CabUrl)) {
|
||||
throw "Unable to resolve CabUrl for $modelDisplay from CatalogIndexPC."
|
||||
}
|
||||
$cabUrl = $resolved.CabUrl
|
||||
# Optionally persist back into the incoming object if property exists
|
||||
if ($DriverItemData.PSObject.Properties['CabUrl']) {
|
||||
$DriverItemData.CabUrl = $cabUrl
|
||||
}
|
||||
}
|
||||
|
||||
# Model-based workflow (always used for client pathway now)
|
||||
$modelCabName = [IO.Path]::GetFileName($cabUrl)
|
||||
if ([string]::IsNullOrWhiteSpace($modelCabName)) { throw "Derived model cab name empty for $modelDisplay" }
|
||||
$modelCabPath = Join-Path $makeDriversPath $modelCabName
|
||||
$modelXmlPath = Join-Path $makeDriversPath ([IO.Path]::GetFileNameWithoutExtension($modelCabName) + '.xml')
|
||||
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Downloading catalog...' }
|
||||
if (Test-Path $modelCabPath) { Remove-SafeFolder $modelCabPath }
|
||||
if (Test-Path $modelXmlPath) { Remove-SafeFolder $modelXmlPath }
|
||||
|
||||
WriteLog "Downloading Dell model catalog from $cabUrl to $modelCabPath"
|
||||
Start-BitsTransferWithRetry -Source $cabUrl -Destination $modelCabPath
|
||||
Invoke-Process -FilePath Expand.exe -ArgumentList """$modelCabPath"" ""$modelXmlPath""" | Out-Null
|
||||
Remove-Item $modelCabPath -Force -ErrorAction SilentlyContinue
|
||||
if (-not (Test-Path $modelXmlPath)) { throw "Model XML not found after extraction: $modelXmlPath" }
|
||||
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Selecting latest drivers...' }
|
||||
$packages = Get-DellLatestDriverPackages -ModelXmlPath $modelXmlPath -WindowsArch $WindowsArch -WindowsRelease $WindowsRelease
|
||||
}
|
||||
else {
|
||||
# Server legacy logic unchanged (kept as before)
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Preparing server catalog...' }
|
||||
$catalogCab = Join-Path $makeDriversPath 'Catalog.cab'
|
||||
$catalogXml = Join-Path $makeDriversPath 'Catalog.xml'
|
||||
$catalogUrl = 'https://downloads.dell.com/catalog/Catalog.cab'
|
||||
$need = $true
|
||||
if (Test-Path $catalogXml) {
|
||||
if (((Get-Date) - (Get-Item $catalogXml).CreationTime).TotalDays -lt 7) { $need = $false }
|
||||
}
|
||||
if ($need) {
|
||||
if (Test-Path $catalogCab) { Remove-SafeFolder $catalogCab }
|
||||
if (Test-Path $catalogXml) { Remove-SafeFolder $catalogXml }
|
||||
WriteLog "Downloading Dell server catalog from $catalogUrl to $catalogCab"
|
||||
Start-BitsTransferWithRetry -Source $catalogUrl -Destination $catalogCab
|
||||
Invoke-Process -FilePath Expand.exe -ArgumentList """$catalogCab"" ""$catalogXml""" | Out-Null
|
||||
Remove-Item $catalogCab -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
if (-not (Test-Path $catalogXml)) { throw "Server catalog XML missing: $catalogXml" }
|
||||
|
||||
[xml]$xmlContent = Get-Content -Path $catalogXml -Raw
|
||||
$baseLocation = "https://$($xmlContent.manifest.baseLocation)/"
|
||||
$softwareComponents = $xmlContent.Manifest.SoftwareComponent | Where-Object { $_.ComponentType.value -eq 'DRVR' }
|
||||
$latestDrivers = @{}
|
||||
foreach ($component in $softwareComponents) {
|
||||
$models = $component.SupportedSystems.Brand.Model
|
||||
foreach ($m in $models) {
|
||||
if ($m.Display.'#cdata-section' -eq $modelDisplay) {
|
||||
$validOS = $component.SupportedOperatingSystems.OperatingSystem | Where-Object { $_.osArch -eq $WindowsArch }
|
||||
if (-not $validOS) { continue }
|
||||
$driverPath = $component.path
|
||||
$downloadUrl = $baseLocation + $driverPath
|
||||
$fileName = [IO.Path]::GetFileName($driverPath)
|
||||
$name = $component.Name.Display.'#cdata-section' -replace '[\\\/\:\*\?\"\<\>\| ]', '_' -replace '[\,]', '-'
|
||||
$category = $component.Category.Display.'#cdata-section' -replace '[\\\/\:\*\?\"\<\>\| ]', '_'
|
||||
$version = [version]$component.vendorVersion
|
||||
$namePrefix = ($name -split '-')[0]
|
||||
if (-not $latestDrivers[$category]) { $latestDrivers[$category] = @{} }
|
||||
if (-not $latestDrivers[$category][$namePrefix] -or $latestDrivers[$category][$namePrefix].Version -lt $version) {
|
||||
$latestDrivers[$category][$namePrefix] = [pscustomobject]@{
|
||||
Name = $name
|
||||
DownloadUrl = $downloadUrl
|
||||
DriverFileName = $fileName
|
||||
Version = $version
|
||||
Category = $category
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($cat in $latestDrivers.Keys) { foreach ($drv in $latestDrivers[$cat].Values) { $packages += $drv } }
|
||||
}
|
||||
|
||||
WriteLog "Parsing existing Dell Catalog XML for model '$modelName' from: $dellCatalogXML"
|
||||
|
||||
# Initialize variables
|
||||
$baseLocation = $null
|
||||
$latestDrivers = @{} # Hashtable to store latest drivers for this model
|
||||
$modelSpecificDriversFound = $false
|
||||
|
||||
# Create XML reader settings
|
||||
$settings = New-Object System.Xml.XmlReaderSettings
|
||||
$settings.IgnoreWhitespace = $true
|
||||
$settings.IgnoreComments = $true
|
||||
|
||||
# Create XML reader
|
||||
$reader = $null
|
||||
try {
|
||||
$reader = [System.Xml.XmlReader]::Create($dellCatalogXML, $settings)
|
||||
|
||||
# First pass - get baseLocation from manifest
|
||||
while ($reader.Read()) {
|
||||
if ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq "Manifest") {
|
||||
$baseLocationAttr = $reader.GetAttribute("baseLocation")
|
||||
if ($null -ne $baseLocationAttr) {
|
||||
$baseLocation = "https://" + $baseLocationAttr + "/"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $baseLocation) {
|
||||
throw "Invalid Dell Catalog XML format: Missing 'baseLocation' attribute in Manifest element."
|
||||
}
|
||||
|
||||
# Reset reader for second pass
|
||||
$reader.Dispose()
|
||||
$reader = [System.Xml.XmlReader]::Create($dellCatalogXML, $settings)
|
||||
|
||||
# Process SoftwareComponents
|
||||
while ($reader.Read()) {
|
||||
if ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq "SoftwareComponent") {
|
||||
# Read the entire SoftwareComponent subtree
|
||||
$componentXml = $reader.ReadSubtree()
|
||||
$component = New-Object System.Xml.XmlDocument
|
||||
$component.Load($componentXml)
|
||||
$componentXml.Dispose()
|
||||
|
||||
# Check if it's a driver component
|
||||
$componentTypeNode = $component.SelectSingleNode("//ComponentType[@value='DRVR']")
|
||||
if ($null -eq $componentTypeNode) {
|
||||
continue
|
||||
}
|
||||
|
||||
# Check if component supports the model
|
||||
$modelNodes = $component.SelectNodes("//SupportedSystems/Brand/Model")
|
||||
$modelMatch = $false
|
||||
|
||||
foreach ($modelNode in $modelNodes) {
|
||||
$displayNode = $modelNode.SelectSingleNode("Display")
|
||||
if ($null -ne $displayNode -and $displayNode.InnerText.Trim() -eq $modelName) {
|
||||
$modelMatch = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ($modelMatch) {
|
||||
# Check OS compatibility
|
||||
$validOS = $null
|
||||
$osNodes = $component.SelectNodes("//SupportedOperatingSystems/OperatingSystem")
|
||||
|
||||
if ($null -ne $osNodes) {
|
||||
foreach ($osNode in $osNodes) {
|
||||
$osArch = $osNode.GetAttribute("osArch")
|
||||
|
||||
if ($WindowsRelease -le 11) {
|
||||
# Client OS check
|
||||
if ($osArch -eq $WindowsArch) {
|
||||
$validOS = $osNode
|
||||
break
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Server OS check
|
||||
$osCode = $osNode.GetAttribute("osCode")
|
||||
$osCodePattern = switch ($WindowsRelease) {
|
||||
2016 { "W14" }
|
||||
2019 { "W19" }
|
||||
2022 { "W22" }
|
||||
2025 { "W25" }
|
||||
default { "W22" }
|
||||
}
|
||||
if ($osArch -eq $WindowsArch -and $osCode -match $osCodePattern) {
|
||||
$validOS = $osNode
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($validOS) {
|
||||
$modelSpecificDriversFound = $true
|
||||
|
||||
# Extract driver information
|
||||
$driverPath = $component.SoftwareComponent.GetAttribute("path")
|
||||
$downloadUrl = $baseLocation + $driverPath
|
||||
$driverFileName = [System.IO.Path]::GetFileName($driverPath)
|
||||
|
||||
# Get name
|
||||
$nameNode = $component.SelectSingleNode("//Name/Display")
|
||||
$name = if ($null -ne $nameNode) { $nameNode.InnerText } else { "UnknownDriver" }
|
||||
$name = $name -replace '[\\\/\:\*\?\"\<\>\| ]', '_' -replace '[\,]', '-'
|
||||
|
||||
# Get category
|
||||
$categoryNode = $component.SelectSingleNode("//Category/Display")
|
||||
$category = if ($null -ne $categoryNode) { $categoryNode.InnerText } else { "Uncategorized" }
|
||||
$category = $category -replace '[\\\/\:\*\?\"\<\>\| ]', '_'
|
||||
|
||||
# Get version
|
||||
$version = [version]"0.0"
|
||||
$vendorVersion = $component.SoftwareComponent.GetAttribute("vendorVersion")
|
||||
if ($null -ne $vendorVersion) {
|
||||
try { $version = [version]$vendorVersion } catch { WriteLog "Warning: Could not parse version '$vendorVersion' for driver '$name'. Using 0.0." }
|
||||
}
|
||||
|
||||
$namePrefix = ($name -split '-')[0]
|
||||
|
||||
# Store the latest version for each category/prefix combination
|
||||
if (-not $latestDrivers.ContainsKey($category)) { $latestDrivers[$category] = @{} }
|
||||
if (-not $latestDrivers[$category].ContainsKey($namePrefix) -or $latestDrivers[$category][$namePrefix].Version -lt $version) {
|
||||
$latestDrivers[$category][$namePrefix] = [PSCustomObject]@{
|
||||
Name = $name
|
||||
DownloadUrl = $downloadUrl
|
||||
DriverFileName = $driverFileName
|
||||
Version = $version
|
||||
Category = $category
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $reader) {
|
||||
$reader.Dispose()
|
||||
}
|
||||
if (-not $packages -or $packages.Count -eq 0) {
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'No drivers found for OS' }
|
||||
return [pscustomobject]@{ Model = $modelDisplay; Status = 'No drivers found for OS'; Success = $true; DriverPath = $driverRelativePath }
|
||||
}
|
||||
|
||||
WriteLog "Searching $($softwareComponents.Count) DRVR components in '$dellCatalogXML' for model '$modelName'..."
|
||||
$total = $packages.Count
|
||||
$idx = 0
|
||||
foreach ($pkg in $packages) {
|
||||
$idx++
|
||||
$driverName = $pkg.Name
|
||||
if ([string]::IsNullOrWhiteSpace($driverName)) { $driverName = $pkg.DriverFileName }
|
||||
$status = "$idx/$total Downloading $driverName"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status $status }
|
||||
|
||||
foreach ($component in $softwareComponents) {
|
||||
# Check if SupportedSystems and Brand exist
|
||||
if ($null -eq $component.SupportedSystems -or $null -eq $component.SupportedSystems.Brand) { continue }
|
||||
# Ensure Model is iterable
|
||||
$componentModels = @($component.SupportedSystems.Brand.Model)
|
||||
if ($null -eq $componentModels) { continue }
|
||||
$categorySafe = ($pkg.Category -replace '[\\\/\:\*\?\"\<\>\| ]', '_')
|
||||
$downloadFolder = Join-Path $modelPath $categorySafe
|
||||
if (-not (Test-Path $downloadFolder)) { New-Item -Path $downloadFolder -ItemType Directory -Force | Out-Null }
|
||||
$driverFilePath = Join-Path $downloadFolder $pkg.DriverFileName
|
||||
$plainName = [IO.Path]::GetFileNameWithoutExtension($pkg.DriverFileName)
|
||||
if ([string]::IsNullOrWhiteSpace($plainName)) { $plainName = "_extract" }
|
||||
$extractFolder = Join-Path $downloadFolder $plainName
|
||||
|
||||
$modelMatch = $false
|
||||
foreach ($item in $componentModels) {
|
||||
# Check if Display and its CDATA section exist before accessing
|
||||
if ($null -ne $item.Display -and $null -ne $item.Display.'#cdata-section' -and $item.Display.'#cdata-section'.Trim() -eq $modelName) {
|
||||
$modelMatch = $true
|
||||
break
|
||||
}
|
||||
if (Test-Path $extractFolder) {
|
||||
$sz = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($sz -gt 1KB) { continue }
|
||||
}
|
||||
|
||||
if ($modelMatch) {
|
||||
# Model matches, now check OS compatibility
|
||||
$validOS = $null
|
||||
if ($null -ne $component.SupportedOperatingSystems) {
|
||||
# Ensure OperatingSystem is always an array/collection
|
||||
$osList = @($component.SupportedOperatingSystems.OperatingSystem)
|
||||
|
||||
if ($null -ne $osList) {
|
||||
if ($WindowsRelease -le 11) {
|
||||
# Client OS check
|
||||
$validOS = $osList | Where-Object { $_.osArch -eq $WindowsArch } | Select-Object -First 1
|
||||
}
|
||||
else {
|
||||
# Server OS check
|
||||
$osCodePattern = switch ($WindowsRelease) {
|
||||
2016 { "W14" } # Note: Dell uses W14 for Server 2016
|
||||
2019 { "W19" }
|
||||
2022 { "W22" }
|
||||
2025 { "W25" }
|
||||
default { "W22" } # Fallback, adjust as needed
|
||||
}
|
||||
$validOS = $osList | Where-Object { ($_.osArch -eq $WindowsArch) -and ($_.osCode -match $osCodePattern) } | Select-Object -First 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($validOS) {
|
||||
$modelSpecificDriversFound = $true # Mark that we found at least one relevant driver component
|
||||
$driverPath = $component.path
|
||||
$downloadUrl = $baseLocation + $driverPath
|
||||
$driverFileName = [System.IO.Path]::GetFileName($driverPath)
|
||||
# Check if Name, Display, and CDATA exist
|
||||
$name = "UnknownDriver" # Default name
|
||||
if ($null -ne $component.Name -and $null -ne $component.Name.Display -and $null -ne $component.Name.Display.'#cdata-section') {
|
||||
$name = $component.Name.Display.'#cdata-section'
|
||||
$name = $name -replace '[\\\/\:\*\?\"\<\>\| ]', '_' -replace '[\,]', '-'
|
||||
}
|
||||
# Check if Category, Display, and CDATA exist
|
||||
$category = "Uncategorized" # Default category
|
||||
if ($null -ne $component.Category -and $null -ne $component.Category.Display -and $null -ne $component.Category.Display.'#cdata-section') {
|
||||
$category = $component.Category.Display.'#cdata-section'
|
||||
$category = $category -replace '[\\\/\:\*\?\"\<\>\| ]', '_'
|
||||
}
|
||||
$version = [version]"0.0" # Default version
|
||||
if ($null -ne $component.vendorVersion) {
|
||||
try { $version = [version]$component.vendorVersion } catch { WriteLog "Warning: Could not parse version '$($component.vendorVersion)' for driver '$name'. Using 0.0." }
|
||||
}
|
||||
$namePrefix = ($name -split '-')[0] # Group by prefix within category
|
||||
|
||||
# Store the latest version for each category/prefix combination
|
||||
if (-not $latestDrivers.ContainsKey($category)) { $latestDrivers[$category] = @{} }
|
||||
if (-not $latestDrivers[$category].ContainsKey($namePrefix) -or $latestDrivers[$category][$namePrefix].Version -lt $version) {
|
||||
$latestDrivers[$category][$namePrefix] = [PSCustomObject]@{
|
||||
Name = $name
|
||||
DownloadUrl = $downloadUrl
|
||||
DriverFileName = $driverFileName
|
||||
Version = $version
|
||||
Category = $category
|
||||
}
|
||||
}
|
||||
}
|
||||
} # End if ($modelMatch)
|
||||
} # End foreach ($component in $softwareComponents)
|
||||
|
||||
if (-not $modelSpecificDriversFound) {
|
||||
$status = "No drivers found for OS"
|
||||
WriteLog "No drivers found for model '$modelName' matching Windows Release '$WindowsRelease' and Arch '$WindowsArch' in '$dellCatalogXML'."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
# Consider this success as the process completed, just no drivers to download
|
||||
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $true }
|
||||
}
|
||||
|
||||
# 4. Download and Extract Found Drivers (Logic remains largely the same)
|
||||
$totalDriversToProcess = ($latestDrivers.Values | ForEach-Object { $_.Values.Count } | Measure-Object -Sum).Sum
|
||||
$driversProcessed = 0
|
||||
WriteLog "Found $totalDriversToProcess latest driver packages to download for $modelName."
|
||||
|
||||
# Ensure base directories exist before loop
|
||||
if (-not (Test-Path -Path $makeDriversPath)) { New-Item -Path $makeDriversPath -ItemType Directory -Force | Out-Null }
|
||||
if (-not (Test-Path -Path $modelPath)) { New-Item -Path $modelPath -ItemType Directory -Force | Out-Null }
|
||||
|
||||
foreach ($category in $latestDrivers.Keys) {
|
||||
foreach ($driver in $latestDrivers[$category].Values) {
|
||||
$driversProcessed++
|
||||
$status = "Downloading $($driversProcessed)/$($totalDriversToProcess): $($driver.Name)"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
|
||||
$downloadFolder = Join-Path -Path $modelPath -ChildPath $driver.Category
|
||||
$driverFilePath = Join-Path -Path $downloadFolder -ChildPath $driver.DriverFileName
|
||||
$extractFolder = Join-Path -Path $downloadFolder -ChildPath $driver.DriverFileName.TrimEnd($driver.DriverFileName[-4..-1])
|
||||
|
||||
# Check if already extracted (more robust check)
|
||||
if (Test-Path -Path $extractFolder -PathType Container) {
|
||||
$extractSize = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($extractSize -gt 1KB) {
|
||||
WriteLog "Driver already extracted: $($driver.Name) in $extractFolder. Skipping."
|
||||
continue # Skip to next driver
|
||||
}
|
||||
}
|
||||
# Check if download file exists but extraction folder doesn't or is empty
|
||||
if (Test-Path -Path $driverFilePath -PathType Leaf) {
|
||||
WriteLog "Download file $($driver.DriverFileName) exists, but extraction folder '$extractFolder' is missing or empty. Will attempt extraction."
|
||||
# Proceed to extraction logic below
|
||||
}
|
||||
else {
|
||||
# Download the driver
|
||||
WriteLog "Downloading driver: $($driver.Name) ($($driver.DriverFileName))"
|
||||
if (-not (Test-Path -Path $downloadFolder)) {
|
||||
WriteLog "Creating download folder: $downloadFolder"
|
||||
New-Item -Path $downloadFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
WriteLog "Downloading from: $($driver.DownloadUrl) to $driverFilePath"
|
||||
try {
|
||||
Start-BitsTransferWithRetry -Source $driver.DownloadUrl -Destination $driverFilePath
|
||||
WriteLog "Driver downloaded: $($driver.DriverFileName)"
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to download driver: $($driver.DownloadUrl). Error: $($_.Exception.Message). Skipping."
|
||||
# Update status for this specific driver failure? Maybe too granular.
|
||||
continue # Skip to next driver
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Extract the driver
|
||||
$status = "Extracting $($driversProcessed)/$($totalDriversToProcess): $($driver.Name)"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
|
||||
# Ensure extraction folder exists before attempting extraction
|
||||
if (-not (Test-Path -Path $extractFolder)) {
|
||||
WriteLog "Creating extraction folder: $extractFolder"
|
||||
New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
# Dell uses /e to extact the entire DUP while /drivers to extract only the drivers
|
||||
# In many cases /drivers will extract drivers for mutliple OS versions
|
||||
# Which can cause many duplicate files and bloat your driver folder
|
||||
# /e seems to be better and only extracts what is necessary and has less issues
|
||||
# We will default to using /e, but will fall back to /drivers if content cannot be found
|
||||
|
||||
$arguments = "/s /e=`"$extractFolder`" /l=`"$extractFolder\log.log`""
|
||||
$altArguments = "/s /drivers=`"$extractFolder`" /l=`"$extractFolder\log.log`""
|
||||
$extractionSuccess = $false
|
||||
try {
|
||||
# Handle special cases (Chipset/Network) - Check if OS is Server
|
||||
$osInfo = Get-CimInstance -ClassName Win32_OperatingSystem # Get OS info within the task scope
|
||||
$isServer = $osInfo.Caption -match 'server'
|
||||
|
||||
# Chipset drivers may require killing child processes in some cases
|
||||
if ($driver.Category -eq "Chipset") {
|
||||
WriteLog "Extracting Chipset driver: $driverFilePath $arguments"
|
||||
$process = Invoke-Process -FilePath $driverFilePath -ArgumentList $arguments -Wait $false
|
||||
Start-Sleep -Seconds 5 # Allow time for extraction
|
||||
WriteLog "Extraction exited with exit code: $($process.ExitCode)"
|
||||
# Attempt to gracefully close child process if needed (logic from original script)
|
||||
$childProcesses = Get-CimInstance Win32_Process -Filter "ParentProcessId = $($process.Id)"
|
||||
if ($childProcesses) {
|
||||
$latestProcess = $childProcesses | Sort-Object CreationDate -Descending | Select-Object -First 1
|
||||
WriteLog "Stopping child process for Chipset driver: $($latestProcess.Name) (PID: $($latestProcess.ProcessId))"
|
||||
Stop-Process -Id $latestProcess.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
}
|
||||
# Network drivers on client OS may require killing child processes
|
||||
elseif ($driver.Category -eq "Network" -and -not $isServer) {
|
||||
WriteLog "Extracting Network driver: $driverFilePath $arguments"
|
||||
$process = Invoke-Process -FilePath $driverFilePath -ArgumentList $arguments -Wait $false
|
||||
Start-Sleep -Seconds 5
|
||||
WriteLog "Extraction exited with exit code: $($process.ExitCode)"
|
||||
if (-not $process.HasExited) {
|
||||
$childProcesses = Get-CimInstance Win32_Process -Filter "ParentProcessId = $($process.Id)"
|
||||
if ($childProcesses) {
|
||||
$latestProcess = $childProcesses | Sort-Object CreationDate -Descending | Select-Object -First 1
|
||||
WriteLog "Stopping child process for Network driver: $($latestProcess.Name) (PID: $($latestProcess.ProcessId))"
|
||||
Stop-Process -Id $latestProcess.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "Extracting driver: $driverFilePath $arguments"
|
||||
$process = Invoke-Process -FilePath $driverFilePath -ArgumentList $arguments
|
||||
WriteLog "Extraction exited with exit code: $($process.ExitCode)"
|
||||
}
|
||||
|
||||
# Verify extraction (check if folder has content)
|
||||
if (Test-Path -Path $extractFolder -PathType Container) {
|
||||
$extractSize = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($extractSize -gt 1KB) {
|
||||
$extractionSuccess = $true
|
||||
WriteLog "Extraction successful (Method 1) for $driverFilePath $arguments"
|
||||
}
|
||||
}
|
||||
|
||||
# If primary extraction failed or folder is empty, try alternative
|
||||
if (-not $extractionSuccess) {
|
||||
# $arguments = "/s /e=`"$extractFolder`""
|
||||
# $altArguments = "/s /drivers=`"$extractFolder`""
|
||||
WriteLog "Extraction with $arguments failed or resulted in empty folder for $driverFilePath. Retrying with $altArguments"
|
||||
# Clean up potentially empty folder before retrying
|
||||
Remove-Item -Path $extractFolder -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null # Recreate empty folder
|
||||
$process = Invoke-Process -FilePath $driverFilePath -ArgumentList $altArguments
|
||||
WriteLog "Extraction exited with exit code: $($process.ExitCode)"
|
||||
|
||||
# Verify extraction again
|
||||
if (Test-Path -Path $extractFolder -PathType Container) {
|
||||
$extractSize = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($extractSize -gt 1KB) {
|
||||
$extractionSuccess = $true
|
||||
WriteLog "Extraction successful (Method 2) for $driverFilePath $altArguments"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not (Test-Path $driverFilePath)) {
|
||||
WriteLog "$status URL: $($pkg.DownloadUrl)"
|
||||
try { Start-BitsTransferWithRetry -Source $pkg.DownloadUrl -Destination $driverFilePath }
|
||||
catch {
|
||||
WriteLog "Error during extraction process for $($driver.DriverFileName): $($_.Exception.Message). Trying alternative method."
|
||||
# Try alternative method on any error during the first attempt block
|
||||
try {
|
||||
if (Test-Path -Path $extractFolder) {
|
||||
# Clean up before retry if needed
|
||||
Remove-Item -Path $extractFolder -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null
|
||||
# $arguments = "/s /e=`"$extractFolder`""
|
||||
# $altArguments = "/s /drivers=`"$extractFolder`""
|
||||
WriteLog "Extracting driver (Method 2): $driverFilePath $altArguments"
|
||||
$process = Invoke-Process -FilePath $driverFilePath -ArgumentList $altArguments
|
||||
WriteLog "Extraction exited with exit code: $($process.ExitCode)"
|
||||
|
||||
# Verify extraction again
|
||||
if (Test-Path -Path $extractFolder -PathType Container) {
|
||||
$extractSize = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($extractSize -gt 1KB) {
|
||||
$extractionSuccess = $true
|
||||
WriteLog "Extraction successful (Method 2) for $driverFilePath."
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Alternative extraction method also failed for $($driver.DriverFileName): $($_.Exception.Message)."
|
||||
# Extraction failed completely
|
||||
}
|
||||
$failureMessage = "Failed to download driver '$driverName' from $($pkg.DownloadUrl): $($_.Exception.Message)"
|
||||
WriteLog $failureMessage
|
||||
throw (New-Object System.Exception($failureMessage, $_.Exception))
|
||||
}
|
||||
}
|
||||
|
||||
# Cleanup downloaded file only if extraction was successful
|
||||
if ($extractionSuccess) {
|
||||
WriteLog "Deleting driver file: $driverFilePath"
|
||||
Remove-Item -Path $driverFilePath -Force -ErrorAction SilentlyContinue
|
||||
WriteLog "Driver file deleted: $driverFilePath"
|
||||
}
|
||||
else {
|
||||
WriteLog "Extraction failed for $($driver.DriverFileName). Downloaded file kept at $driverFilePath for inspection."
|
||||
# Update status to indicate partial failure?
|
||||
}
|
||||
$status = "$idx/$total Extracting $driverName"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status $status }
|
||||
|
||||
} # End foreach ($driver in $latestDrivers)
|
||||
} # End foreach ($category in $latestDrivers)
|
||||
if (-not (Test-Path $extractFolder)) { New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null }
|
||||
|
||||
# --- Compress to WIM if requested (after all drivers processed) ---
|
||||
if ($CompressToWim) {
|
||||
$status = "Compressing..."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
$wimFileName = "$($modelName).wim"
|
||||
$destinationWimPath = Join-Path -Path $makeDriversPath -ChildPath $wimFileName
|
||||
$driverRelativePath = Join-Path -Path $make -ChildPath $wimFileName # Update relative path to the WIM file
|
||||
WriteLog "Compressing '$modelPath' to '$destinationWimPath'..."
|
||||
$arg1 = "/s /e=`"$extractFolder`" /l=`"$extractFolder\log.log`""
|
||||
$arg2 = "/s /drivers=`"$extractFolder`" /l=`"$extractFolder\log.log`""
|
||||
$ok = $false
|
||||
try {
|
||||
$compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $modelName -WimDescription $modelName -ErrorAction Stop
|
||||
if ($compressResult) {
|
||||
WriteLog "Compression successful for '$modelName'."
|
||||
$status = "Completed & Compressed"
|
||||
}
|
||||
else {
|
||||
WriteLog "Compression failed for '$modelName'. Check verbose/error output from Compress-DriverFolderToWim."
|
||||
$status = "Completed (Compression Failed)"
|
||||
Invoke-Process -FilePath $driverFilePath -ArgumentList $arg1 | Out-Null
|
||||
$sz = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($sz -gt 1KB) { $ok = $true }
|
||||
if (-not $ok) {
|
||||
Remove-SafeFolder $extractFolder
|
||||
New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null
|
||||
Invoke-Process -FilePath $driverFilePath -ArgumentList $arg2 | Out-Null
|
||||
$sz = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($sz -gt 1KB) { $ok = $true }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error during compression for '$modelName': $($_.Exception.Message)"
|
||||
$status = "Completed (Compression Error)"
|
||||
WriteLog "Extraction error: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if ($ok) {
|
||||
Remove-Item $driverFilePath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
else {
|
||||
$failureMessage = "Failed to extract driver '$driverName'."
|
||||
WriteLog $failureMessage
|
||||
throw (New-Object System.Exception($failureMessage))
|
||||
}
|
||||
}
|
||||
|
||||
if ($CompressToWim) {
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Compressing...' }
|
||||
$wimPath = Join-Path $makeDriversPath "$sanitizedModelName.wim"
|
||||
try {
|
||||
$null = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $wimPath -WimName $modelDisplay -WimDescription $modelDisplay -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
|
||||
$driverRelativePath = Join-Path $make "$sanitizedModelName.wim"
|
||||
$statusFinal = 'Completed & Compressed'
|
||||
}
|
||||
catch {
|
||||
WriteLog "Compression failed for $($modelDisplay): $($_.Exception.Message)"
|
||||
$statusFinal = 'Completed (Compression Failed)'
|
||||
}
|
||||
}
|
||||
else {
|
||||
$status = "Completed" # Final status if not compressing
|
||||
$statusFinal = 'Completed'
|
||||
}
|
||||
# --- End Compression ---
|
||||
|
||||
$success = $true # Mark success as download/extract was okay
|
||||
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status $statusFinal }
|
||||
return [pscustomobject]@{ Model = $modelDisplay; Status = $statusFinal; Success = $true; DriverPath = $driverRelativePath }
|
||||
}
|
||||
catch {
|
||||
$status = "Error: $($_.Exception.Message.Split('.')[0])" # Shorten error message
|
||||
WriteLog "Error saving Dell drivers for $($modelName): $($_.Exception.ToString())" # Log full exception string
|
||||
$success = $false
|
||||
# Enqueue the error status before returning
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
# Ensure return object is created even on error
|
||||
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $success; DriverPath = $null }
|
||||
$errorStatus = "Error: $($_.Exception.Message)"
|
||||
WriteLog "Save-DellDriversTask error for $($modelDisplay): $($_.Exception.ToString())"
|
||||
Remove-DriverModelFolder -DriversFolder $DriversFolder -TargetFolder $modelPath -Description $modelDisplay
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status $errorStatus }
|
||||
return [pscustomobject]@{ Model = $modelDisplay; Status = $errorStatus; Success = $false; DriverPath = $null }
|
||||
}
|
||||
|
||||
# Enqueue the final status (success or error) before returning
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
|
||||
# Return the final status
|
||||
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $success; DriverPath = $driverRelativePath }
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function *
|
||||
@@ -66,30 +66,45 @@ function Get-HPDriversModelList {
|
||||
$settings.Async = $false # Ensure synchronous reading
|
||||
|
||||
$reader = [System.Xml.XmlReader]::Create($platformListXml, $settings)
|
||||
$uniqueModels = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
$uniqueEntries = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
|
||||
while ($reader.Read()) {
|
||||
if ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq 'Platform') {
|
||||
# Read the inner content of the Platform node
|
||||
$platformReader = $reader.ReadSubtree()
|
||||
$platformNames = [System.Collections.Generic.List[string]]::new()
|
||||
$platformSystemId = $null
|
||||
|
||||
while ($platformReader.Read()) {
|
||||
if ($platformReader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $platformReader.Name -eq 'ProductName') {
|
||||
$modelName = $platformReader.ReadElementContentAsString()
|
||||
if (-not [string]::IsNullOrWhiteSpace($modelName) -and $uniqueModels.Add($modelName)) {
|
||||
# Add to list only if it's a new unique model
|
||||
$modelList.Add([PSCustomObject]@{
|
||||
Make = $Make
|
||||
Model = $modelName
|
||||
})
|
||||
if ($platformReader.NodeType -eq [System.Xml.XmlNodeType]::Element) {
|
||||
if ($platformReader.Name -eq 'ProductName') {
|
||||
$modelName = $platformReader.ReadElementContentAsString()
|
||||
if (-not [string]::IsNullOrWhiteSpace($modelName)) {
|
||||
$platformNames.Add($modelName.Trim())
|
||||
}
|
||||
}
|
||||
elseif ($platformReader.Name -eq 'SystemID') {
|
||||
$platformSystemId = $platformReader.ReadElementContentAsString().Trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
$platformReader.Close()
|
||||
|
||||
foreach ($name in $platformNames) {
|
||||
$systemIdKey = if (-not [string]::IsNullOrWhiteSpace($platformSystemId)) { $platformSystemId } else { '' }
|
||||
$compositeKey = "$name|$systemIdKey"
|
||||
if ($uniqueEntries.Add($compositeKey)) {
|
||||
$modelList.Add([PSCustomObject]@{
|
||||
Make = $Make
|
||||
Model = $name
|
||||
SystemId = $platformSystemId
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$reader.Close()
|
||||
|
||||
WriteLog "Successfully parsed $($modelList.Count) unique HP models from PlatformList.xml."
|
||||
WriteLog "Successfully parsed $($modelList.Count) HP model and SystemID combinations from PlatformList.xml."
|
||||
|
||||
}
|
||||
catch {
|
||||
@@ -97,7 +112,7 @@ function Get-HPDriversModelList {
|
||||
}
|
||||
|
||||
# Sort the list alphabetically by Model name before returning
|
||||
return $modelList | Sort-Object -Property Model
|
||||
return $modelList | Sort-Object -Property Model, SystemId
|
||||
}
|
||||
# Function to download and extract drivers for a specific HP model (Designed for ForEach-Object -Parallel)
|
||||
function Save-HPDriversTask {
|
||||
@@ -118,13 +133,22 @@ function Save-HPDriversTask {
|
||||
[Parameter()] # Made optional
|
||||
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null
|
||||
[Parameter()]
|
||||
[bool]$CompressToWim = $false # New parameter for compression
|
||||
[bool]$CompressToWim = $false, # New parameter for compression
|
||||
[Parameter()]
|
||||
[bool]$PreserveSourceOnCompress = $false
|
||||
)
|
||||
|
||||
$modelName = $DriverItemData.Model
|
||||
$displayModelName = if (-not [string]::IsNullOrWhiteSpace($DriverItemData.Model)) { $DriverItemData.Model } else { $DriverItemData.Id }
|
||||
$make = $DriverItemData.Make # Should be 'HP'
|
||||
$identifier = $modelName # Unique identifier for progress updates
|
||||
$sanitizedModelName = $modelName -replace '[\\/:"*?<>|]', '_'
|
||||
$productName = if ($DriverItemData.PSObject.Properties['ProductName'] -and -not [string]::IsNullOrWhiteSpace($DriverItemData.ProductName)) { $DriverItemData.ProductName } else { ConvertTo-DriverBaseName -ModelString $displayModelName }
|
||||
if ([string]::IsNullOrWhiteSpace($productName)) { $productName = $displayModelName }
|
||||
$systemIdentifier = if ($DriverItemData.PSObject.Properties['SystemId'] -and -not [string]::IsNullOrWhiteSpace($DriverItemData.SystemId)) { $DriverItemData.SystemId } else { $null }
|
||||
if ([string]::IsNullOrWhiteSpace($displayModelName)) {
|
||||
$displayModelName = if ([string]::IsNullOrWhiteSpace($systemIdentifier)) { $productName } else { Get-DriverDisplayName -BaseName $productName -Identifier $systemIdentifier }
|
||||
}
|
||||
$identifier = $displayModelName # Unique identifier for progress updates
|
||||
$sanitizedModelName = ConvertTo-SafeName -Name $identifier
|
||||
if ($sanitizedModelName -ne $identifier) { WriteLog "Sanitized model name: '$identifier' -> '$sanitizedModelName'" }
|
||||
$hpDriversBaseFolder = Join-Path -Path $DriversFolder -ChildPath $make # Changed variable name for clarity
|
||||
$platformListXml = Join-Path -Path $hpDriversBaseFolder -ChildPath "PlatformList.xml"
|
||||
$modelSpecificFolder = Join-Path -Path $hpDriversBaseFolder -ChildPath $sanitizedModelName # Sanitize model name for folder path
|
||||
@@ -132,7 +156,16 @@ function Save-HPDriversTask {
|
||||
$finalStatus = "" # Initialize final status
|
||||
$successState = $true # Assume success unless an operation fails
|
||||
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Checking HP drivers for $modelName..." }
|
||||
if (-not (Test-Path -Path $DriversFolder -PathType Container)) {
|
||||
WriteLog "Creating Drivers folder: $DriversFolder"
|
||||
New-Item -Path $DriversFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
if (-not (Test-Path -Path $hpDriversBaseFolder -PathType Container)) {
|
||||
WriteLog "Creating HP drivers folder: $hpDriversBaseFolder"
|
||||
New-Item -Path $hpDriversBaseFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Checking HP drivers for $displayModelName..." }
|
||||
|
||||
try {
|
||||
# Check for existing drivers
|
||||
@@ -146,13 +179,14 @@ function Save-HPDriversTask {
|
||||
# Special handling for existing folders that need compression
|
||||
if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') {
|
||||
$wimFilePath = Join-Path -Path $hpDriversBaseFolder -ChildPath "$($sanitizedModelName).wim"
|
||||
$wimRelativePath = Join-Path -Path $make -ChildPath "$($sanitizedModelName).wim"
|
||||
$sourceFolderPath = Join-Path -Path $hpDriversBaseFolder -ChildPath $sanitizedModelName
|
||||
WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Compressing existing..." }
|
||||
try {
|
||||
Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -ErrorAction Stop
|
||||
$existingDriver.Status = "Already downloaded & Compressed"
|
||||
$existingDriver.DriverPath = Join-Path -Path $make -ChildPath "$($sanitizedModelName).wim"
|
||||
$null = Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
|
||||
$existingDriver.Status = "Compression successful"
|
||||
$existingDriver.DriverPath = $wimRelativePath
|
||||
$existingDriver.Success = $true
|
||||
WriteLog "Successfully compressed existing drivers for $identifier to $wimFilePath."
|
||||
}
|
||||
@@ -187,13 +221,23 @@ function Save-HPDriversTask {
|
||||
}
|
||||
}
|
||||
|
||||
# Parse PlatformList.xml to find SystemID and OSReleaseID for the specific model
|
||||
WriteLog "Parsing $platformListXml for model '$modelName' details..."
|
||||
# Parse the PlatformList.xml to find the SystemID based on the ProductName
|
||||
WriteLog "Parsing $platformListXml for model '$displayModelName' (SystemID: $systemIdentifier) details..."
|
||||
[xml]$platformListContent = Get-Content -Path $platformListXml -Raw -Encoding UTF8 -ErrorAction Stop
|
||||
$platformNode = $platformListContent.ImagePal.Platform | Where-Object { $_.ProductName.'#text' -match "^$([regex]::Escape($modelName))$" } | Select-Object -First 1
|
||||
$platformNode = $null
|
||||
if (-not [string]::IsNullOrWhiteSpace($systemIdentifier)) {
|
||||
$platformNode = $platformListContent.ImagePal.Platform | Where-Object { $_.SystemID -eq $systemIdentifier } | Select-Object -First 1
|
||||
if ($null -eq $platformNode) {
|
||||
WriteLog "SystemID '$systemIdentifier' not found in PlatformList.xml. Falling back to ProductName search."
|
||||
}
|
||||
}
|
||||
if ($null -eq $platformNode) {
|
||||
$searchName = if (-not [string]::IsNullOrWhiteSpace($productName)) { $productName } else { $displayModelName }
|
||||
$platformNode = $platformListContent.ImagePal.Platform | Where-Object { $_.ProductName.'#text' -match "^$([regex]::Escape($searchName))$" } | Select-Object -First 1
|
||||
}
|
||||
|
||||
if ($null -eq $platformNode) {
|
||||
throw "Model '$modelName' not found in PlatformList.xml."
|
||||
throw "Model '$displayModelName' (SystemID: $systemIdentifier) not found in PlatformList.xml."
|
||||
}
|
||||
|
||||
$systemID = $platformNode.SystemID
|
||||
@@ -286,11 +330,11 @@ function Save-HPDriversTask {
|
||||
}
|
||||
$availableVersionsString = ($allAvailableVersions | Select-Object -Unique) -join ', '
|
||||
if ([string]::IsNullOrWhiteSpace($availableVersionsString)) { $availableVersionsString = "None" }
|
||||
throw "Could not find any suitable OS driver pack for model '$modelName' matching requested or fallback versions (Win$($WindowsRelease) $WindowsVersion). Available: $availableVersionsString"
|
||||
throw "Could not find any suitable OS driver pack for model '$displayModelName' matching requested or fallback versions (Win$($WindowsRelease) $WindowsVersion). Available: $availableVersionsString"
|
||||
}
|
||||
|
||||
$osReleaseIdFileName = $selectedOSNode.OSReleaseIdFileName -replace 'H', 'h'
|
||||
WriteLog "Using SystemID: $systemID and OS Info: Win$($selectedOSRelease) ($($selectedOSVersion)) for '$modelName'"
|
||||
WriteLog "Using SystemID: $systemID and OS Info: Win$($selectedOSRelease) ($($selectedOSVersion)) for '$displayModelName'"
|
||||
$archSuffix = $WindowsArch -replace "^x", ""
|
||||
$modelRelease = "$($systemID)_$($archSuffix)_$($selectedOSRelease).0.$($selectedOSVersion.ToLower())"
|
||||
$driverCabUrl = "https://hpia.hpcloud.hp.com/ref/$systemID/$modelRelease.cab"
|
||||
@@ -309,7 +353,7 @@ function Save-HPDriversTask {
|
||||
$updates = $driverXmlContent.ImagePal.Solutions.UpdateInfo | Where-Object { $_.Category -match '^Driver' }
|
||||
$totalDrivers = ($updates | Measure-Object).Count
|
||||
$downloadedCount = 0
|
||||
WriteLog "Found $totalDrivers driver updates for $modelName."
|
||||
WriteLog "Found $totalDrivers driver updates for $displayModelName."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Found $totalDrivers drivers. Downloading..." }
|
||||
|
||||
if (-not (Test-Path -Path $modelSpecificFolder)) {
|
||||
@@ -327,7 +371,7 @@ function Save-HPDriversTask {
|
||||
$extractFolder = Join-Path -Path $downloadFolder -ChildPath ($driverName + "_" + $version + "_" + ($driverFileName -replace '\.exe$', ''))
|
||||
|
||||
$downloadedCount++
|
||||
$progressMsg = "($downloadedCount/$totalDrivers) Downloading $driverName..."
|
||||
$progressMsg = "$downloadedCount/$totalDrivers Downloading $driverName"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $progressMsg }
|
||||
WriteLog "$progressMsg URL: $driverUrl"
|
||||
|
||||
@@ -341,6 +385,8 @@ function Save-HPDriversTask {
|
||||
WriteLog "Downloading driver to: $driverFilePath"
|
||||
Start-BitsTransferWithRetry -Source $driverUrl -Destination $driverFilePath -ErrorAction Stop
|
||||
WriteLog "Driver downloaded: $driverFilePath"
|
||||
$progressMsg = "$downloadedCount/$totalDrivers Extracting $driverName"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $progressMsg }
|
||||
WriteLog "Creating extraction folder: $extractFolder"
|
||||
New-Item -Path $extractFolder -ItemType Directory -Force -ErrorAction Stop | Out-Null
|
||||
$arguments = "/s /e /f `"$extractFolder`""
|
||||
@@ -354,7 +400,7 @@ function Save-HPDriversTask {
|
||||
}
|
||||
|
||||
Remove-Item -Path $driverCabFile, $driverXmlFile -Force -ErrorAction SilentlyContinue
|
||||
WriteLog "Cleaned up driver cab and xml files for $modelName"
|
||||
WriteLog "Cleaned up driver cab and xml files for $displayModelName"
|
||||
|
||||
$finalStatus = "Completed"
|
||||
if ($CompressToWim) {
|
||||
@@ -362,7 +408,7 @@ function Save-HPDriversTask {
|
||||
$wimFilePath = Join-Path -Path $hpDriversBaseFolder -ChildPath "$($identifier).wim"
|
||||
WriteLog "Compressing '$modelSpecificFolder' to '$wimFilePath'..."
|
||||
try {
|
||||
Compress-DriverFolderToWim -SourceFolderPath $modelSpecificFolder -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -ErrorAction Stop
|
||||
$null = Compress-DriverFolderToWim -SourceFolderPath $modelSpecificFolder -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
|
||||
WriteLog "Compression successful for '$identifier'."
|
||||
$finalStatus = "Completed & Compressed"
|
||||
$driverRelativePath = Join-Path -Path $make -ChildPath "$($identifier).wim" # Update relative path to the WIM
|
||||
@@ -375,15 +421,12 @@ function Save-HPDriversTask {
|
||||
$successState = $true
|
||||
}
|
||||
catch {
|
||||
$errorMessage = "Error saving HP drivers for $($modelName): $($_.Exception.Message)"
|
||||
$errorMessage = "Error saving HP drivers for $($displayModelName): $($_.Exception.Message)"
|
||||
WriteLog $errorMessage
|
||||
$finalStatus = "Error: $($_.Exception.Message.Split([Environment]::NewLine)[0])"
|
||||
$successState = $false
|
||||
$driverRelativePath = $null # Ensure path is null on error
|
||||
if (Test-Path -Path $modelSpecificFolder -PathType Container) {
|
||||
WriteLog "Attempting to remove partially created folder $modelSpecificFolder due to error."
|
||||
Remove-Item -Path $modelSpecificFolder -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Remove-DriverModelFolder -DriversFolder $DriversFolder -TargetFolder $modelSpecificFolder -Description $identifier
|
||||
}
|
||||
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $finalStatus }
|
||||
|
||||
@@ -94,17 +94,20 @@ function Save-LenovoDriversTask {
|
||||
[hashtable]$Headers,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$UserAgent,
|
||||
[Parameter()] # Made optional
|
||||
[Parameter()]
|
||||
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null,
|
||||
[Parameter()]
|
||||
[bool]$CompressToWim = $false
|
||||
[bool]$CompressToWim = $false,
|
||||
[Parameter()]
|
||||
[bool]$PreserveSourceOnCompress = $false
|
||||
)
|
||||
|
||||
# The Model property from the UI already contains the combined "ProductName (MachineType)" string
|
||||
$identifier = $DriverItemData.Model
|
||||
$machineType = $DriverItemData.MachineType
|
||||
$make = "Lenovo"
|
||||
$sanitizedIdentifier = $identifier -replace '[\\/:"*?<>|]', '_'
|
||||
$sanitizedIdentifier = ConvertTo-SafeName -Name $identifier
|
||||
if ($sanitizedIdentifier -ne $identifier) { WriteLog "Sanitized model identifier: '$identifier' -> '$sanitizedIdentifier'" }
|
||||
$status = "Starting..."
|
||||
$success = $false
|
||||
|
||||
@@ -129,13 +132,14 @@ function Save-LenovoDriversTask {
|
||||
# Special handling for existing folders that need compression
|
||||
if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') {
|
||||
$wimFilePath = Join-Path -Path $makeDriversPath -ChildPath "$($sanitizedIdentifier).wim"
|
||||
$wimRelativePath = Join-Path -Path $make -ChildPath "$($sanitizedIdentifier).wim"
|
||||
$sourceFolderPath = Join-Path -Path $makeDriversPath -ChildPath $sanitizedIdentifier
|
||||
WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Compressing existing..." }
|
||||
try {
|
||||
Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -ErrorAction Stop
|
||||
$existingDriver.Status = "Already downloaded & Compressed"
|
||||
$existingDriver.DriverPath = Join-Path -Path $make -ChildPath "$($sanitizedIdentifier).wim"
|
||||
$null = Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
|
||||
$existingDriver.Status = "Compression successful"
|
||||
$existingDriver.DriverPath = $wimRelativePath
|
||||
$existingDriver.Success = $true
|
||||
WriteLog "Successfully compressed existing drivers for $identifier to $wimFilePath."
|
||||
}
|
||||
@@ -204,17 +208,16 @@ function Save-LenovoDriversTask {
|
||||
$packageXMLPath = Join-Path -Path $tempDownloadPath -ChildPath $packageName
|
||||
$baseURL = $packageUrl -replace [regex]::Escape($packageName), "" # Base URL for the driver file
|
||||
|
||||
$status = "($processedPackages/$totalPackages) Getting package info..."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
|
||||
|
||||
# Download the package XML
|
||||
WriteLog "($processedPackages/$totalPackages) Downloading package XML: $packageUrl"
|
||||
try {
|
||||
Start-BitsTransferWithRetry -Source $packageUrl -Destination $packageXMLPath
|
||||
}
|
||||
catch {
|
||||
WriteLog "($processedPackages/$totalPackages) Failed to download package XML '$packageUrl'. Skipping. Error: $($_.Exception.Message)"
|
||||
continue # Skip this package
|
||||
$failureMessage = "Failed to download Lenovo package XML '$packageUrl': $($_.Exception.Message)"
|
||||
WriteLog "($processedPackages/$totalPackages) $failureMessage"
|
||||
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
|
||||
throw (New-Object System.Exception($failureMessage, $_.Exception))
|
||||
}
|
||||
|
||||
# Load and parse the package XML
|
||||
@@ -275,7 +278,7 @@ function Save-LenovoDriversTask {
|
||||
}
|
||||
|
||||
# Download the driver .exe
|
||||
$status = "($processedPackages/$totalPackages) Downloading $packageTitle..."
|
||||
$status = "$processedPackages/$totalPackages Downloading $packageTitle"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
|
||||
WriteLog "($processedPackages/$totalPackages) Downloading driver: $driverUrl to $driverFilePath"
|
||||
try {
|
||||
@@ -283,13 +286,14 @@ function Save-LenovoDriversTask {
|
||||
WriteLog "($processedPackages/$totalPackages) Driver downloaded: $driverFileName"
|
||||
}
|
||||
catch {
|
||||
WriteLog "($processedPackages/$totalPackages) Failed to download driver '$driverUrl'. Skipping. Error: $($_.Exception.Message)"
|
||||
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue # Clean up package XML
|
||||
continue # Skip this driver
|
||||
$failureMessage = "Failed to download driver '$packageTitle' from $($driverUrl): $($_.Exception.Message)"
|
||||
WriteLog "($processedPackages/$totalPackages) $failureMessage"
|
||||
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
|
||||
throw (New-Object System.Exception($failureMessage, $_.Exception))
|
||||
}
|
||||
|
||||
# --- Extraction Logic ---
|
||||
$status = "($processedPackages/$totalPackages) Extracting $packageTitle..."
|
||||
$status = "$processedPackages/$totalPackages Extracting $packageTitle"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
|
||||
|
||||
# Always use a temporary extraction path to avoid long path issues
|
||||
@@ -314,7 +318,7 @@ function Save-LenovoDriversTask {
|
||||
|
||||
# Modify the extract command to point to the temporary folder
|
||||
$modifiedExtractCommand = $extractCommand -replace '%PACKAGEPATH%', "`"$extractFolder`""
|
||||
WriteLog "($processedPackages/$totalPackages) Extracting driver: $driverFilePath using command: $modifiedExtractCommand"
|
||||
WriteLog "$processedPackages/$totalPackages Extracting driver: $driverFilePath using command: $modifiedExtractCommand"
|
||||
|
||||
try {
|
||||
Invoke-Process -FilePath $driverFilePath -ArgumentList $modifiedExtractCommand -Wait $true | Out-Null
|
||||
@@ -322,14 +326,13 @@ function Save-LenovoDriversTask {
|
||||
$extractionSucceeded = $true
|
||||
}
|
||||
catch {
|
||||
WriteLog "($processedPackages/$totalPackages) Failed to extract driver '$driverFilePath' to temporary path. Skipping. Error: $($_.Exception.Message)"
|
||||
# Don't delete the downloaded exe yet if extraction fails
|
||||
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue # Clean up package XML
|
||||
# Clean up temp folder if extraction failed
|
||||
$failureMessage = "Failed to extract driver package '$packageTitle': $($_.Exception.Message)"
|
||||
WriteLog "($processedPackages/$totalPackages) $failureMessage"
|
||||
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
|
||||
if ($tempExtractBase -and (Test-Path -Path $tempExtractBase)) {
|
||||
Remove-Item -Path $tempExtractBase -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
continue # Skip further processing for this driver
|
||||
throw (New-Object System.Exception($failureMessage, $_.Exception))
|
||||
}
|
||||
|
||||
# --- Post-Extraction Handling (Move from Temp to Final Destination) ---
|
||||
@@ -372,10 +375,9 @@ function Save-LenovoDriversTask {
|
||||
Move-Item -Path $item.FullName -Destination $finalDestinationPath -Force -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
WriteLog "($processedPackages/$totalPackages) Failed to move item '$($item.FullName)' to '$finalDestinationPath'. Error: $($_.Exception.Message)"
|
||||
# Decide if this should stop the whole process or just skip this item
|
||||
# For now, we'll log and continue, but mark overall success as false
|
||||
$extractionSucceeded = $false
|
||||
$failureMessage = "Failed to move extracted item '$($item.FullName)' to '$finalDestinationPath': $($_.Exception.Message)"
|
||||
WriteLog "($processedPackages/$totalPackages) $failureMessage"
|
||||
throw (New-Object System.Exception($failureMessage, $_.Exception))
|
||||
}
|
||||
} # End foreach ($item in $extractedItems)
|
||||
|
||||
@@ -412,6 +414,9 @@ function Save-LenovoDriversTask {
|
||||
# Always delete the package XML
|
||||
WriteLog "($processedPackages/$totalPackages) Deleting package XML file: $packageXMLPath"
|
||||
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
|
||||
if (-not $extractionSucceeded) {
|
||||
throw (New-Object System.Exception("Failed to extract driver '$packageTitle'. See log for details."))
|
||||
}
|
||||
|
||||
} # End foreach package
|
||||
|
||||
@@ -424,7 +429,7 @@ function Save-LenovoDriversTask {
|
||||
$driverRelativePath = Join-Path -Path $make -ChildPath $wimFileName # Update relative path to the WIM file
|
||||
WriteLog "Compressing '$modelPath' to '$destinationWimPath'..."
|
||||
try {
|
||||
$compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $identifier -WimDescription $identifier -ErrorAction Stop
|
||||
$compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $identifier -WimDescription $identifier -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
|
||||
if ($compressResult) {
|
||||
WriteLog "Compression successful for '$identifier'."
|
||||
$status = "Completed & Compressed"
|
||||
@@ -448,12 +453,11 @@ function Save-LenovoDriversTask {
|
||||
|
||||
}
|
||||
catch {
|
||||
$status = "Error: $($_.Exception.Message.Split('.')[0])" # Shorten error message
|
||||
WriteLog "Error saving Lenovo drivers for '$identifier': $($_.Exception.ToString())" # Log full exception string
|
||||
$status = "Error: $($_.Exception.Message)"
|
||||
WriteLog "Error saving Lenovo drivers for '$identifier': $($_.Exception.ToString())"
|
||||
$success = $false
|
||||
# Enqueue the error status before returning
|
||||
Remove-DriverModelFolder -DriversFolder $DriversFolder -TargetFolder $modelPath -Description $identifier
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
|
||||
# Ensure return object is created even on error
|
||||
return [PSCustomObject]@{ Identifier = $identifier; Status = $status; Success = $success; DriverPath = $null }
|
||||
}
|
||||
finally {
|
||||
|
||||
@@ -94,7 +94,9 @@ function Save-MicrosoftDriversTask {
|
||||
[Parameter()] # Made optional
|
||||
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null
|
||||
[Parameter()]
|
||||
[bool]$CompressToWim = $false # New parameter for compression
|
||||
[bool]$CompressToWim = $false, # New parameter for compression
|
||||
[Parameter()]
|
||||
[bool]$PreserveSourceOnCompress = $false
|
||||
# REMOVED: UI-related parameters
|
||||
)
|
||||
|
||||
@@ -104,6 +106,10 @@ function Save-MicrosoftDriversTask {
|
||||
$driverRelativePath = Join-Path -Path $make -ChildPath $modelName # Relative path for the driver folder
|
||||
$status = "Getting download link..." # Initial local status
|
||||
$success = $false
|
||||
$sanitizedModelName = ConvertTo-SafeName -Name $modelName
|
||||
if ($sanitizedModelName -ne $modelName) { WriteLog "Sanitized model name: '$modelName' -> '$sanitizedModelName'" }
|
||||
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $make
|
||||
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $sanitizedModelName
|
||||
|
||||
# Initial status update
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Checking..." }
|
||||
@@ -121,13 +127,14 @@ function Save-MicrosoftDriversTask {
|
||||
if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') {
|
||||
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $make
|
||||
$wimFilePath = Join-Path -Path $makeDriversPath -ChildPath "$($modelName).wim"
|
||||
$wimRelativePath = Join-Path -Path $make -ChildPath "$($modelName).wim"
|
||||
$sourceFolderPath = Join-Path -Path $makeDriversPath -ChildPath $modelName
|
||||
WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Compressing existing..." }
|
||||
try {
|
||||
Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $modelName -WimDescription "Drivers for $modelName" -ErrorAction Stop
|
||||
$existingDriver.Status = "Already downloaded & Compressed"
|
||||
$existingDriver.DriverPath = Join-Path -Path $make -ChildPath "$($modelName).wim"
|
||||
$null = Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $modelName -WimDescription "Drivers for $modelName" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
|
||||
$existingDriver.Status = "Compression successful"
|
||||
$existingDriver.DriverPath = $wimRelativePath
|
||||
$existingDriver.Success = $true
|
||||
WriteLog "Successfully compressed existing drivers for $modelName to $wimFilePath."
|
||||
}
|
||||
@@ -224,7 +231,7 @@ function Save-MicrosoftDriversTask {
|
||||
### DOWNLOAD AND EXTRACT
|
||||
if ($downloadLink) {
|
||||
WriteLog "Selected Download Link for $modelName (Actual: Windows $downloadedVersion): $downloadLink"
|
||||
$status = "Downloading (Win$downloadedVersion)..." # Update status message
|
||||
$status = "Downloading Win$downloadedVersion $fileName"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
|
||||
# Create directories
|
||||
@@ -232,8 +239,6 @@ function Save-MicrosoftDriversTask {
|
||||
WriteLog "Creating Drivers folder: $DriversFolder"
|
||||
New-Item -Path $DriversFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $Make
|
||||
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $modelName
|
||||
if (-Not (Test-Path -Path $modelPath)) {
|
||||
WriteLog "Creating model folder: $modelPath"
|
||||
New-Item -Path $modelPath -ItemType Directory -Force | Out-Null
|
||||
@@ -253,7 +258,7 @@ function Save-MicrosoftDriversTask {
|
||||
|
||||
### EXTRACT
|
||||
if ($fileExtension -eq ".msi") {
|
||||
$status = "Waiting for MSI lock..." # Set initial status
|
||||
$status = "Waiting for MSI lock..."
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
|
||||
# Use a named mutex to ensure only one MSI extraction happens at a time across all parallel tasks
|
||||
@@ -282,14 +287,14 @@ function Save-MicrosoftDriversTask {
|
||||
catch [System.Threading.WaitHandleCannotBeOpenedException] {
|
||||
# Mutex is clear, proceed to extraction attempt
|
||||
WriteLog "System MSI mutex clear. Proceeding with MSI extraction attempt for $modelName."
|
||||
$status = "Extracting MSI..."
|
||||
$status = "Extracting Win$downloadedVersion $fileName"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
$mutexClear = $true
|
||||
}
|
||||
catch {
|
||||
# Handle other potential errors when checking the mutex
|
||||
WriteLog "Warning: Error checking system MSI mutex for $($modelName): $_. Proceeding with caution."
|
||||
$status = "Extracting MSI (Mutex Error)..."
|
||||
$status = "Extracting Win$downloadedVersion $fileName (Mutex Error)"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
$mutexClear = $true # Proceed despite mutex error
|
||||
}
|
||||
@@ -347,7 +352,7 @@ function Save-MicrosoftDriversTask {
|
||||
}
|
||||
}
|
||||
elseif ($fileExtension -eq ".zip") {
|
||||
$status = "Extracting ZIP..." # Set status before extraction
|
||||
$status = "Extracting Win$downloadedVersion $fileName"
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
WriteLog "Extracting ZIP file to $modelPath"
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
@@ -377,7 +382,7 @@ function Save-MicrosoftDriversTask {
|
||||
WriteLog "Compressing '$modelPath' to '$destinationWimPath'..."
|
||||
try {
|
||||
# Use the function from the imported common module
|
||||
$compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $modelName -WimDescription $modelName -ErrorAction Stop
|
||||
$compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $modelName -WimDescription $modelName -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
|
||||
if ($compressResult) {
|
||||
WriteLog "Compression successful for '$modelName'."
|
||||
$status = "Completed & Compressed"
|
||||
@@ -417,6 +422,7 @@ function Save-MicrosoftDriversTask {
|
||||
$status = "Error: $($_.Exception.Message.Split('.')[0])" # Shorten error message
|
||||
WriteLog "Error saving Microsoft drivers for $($modelName): $($_.Exception.Message)"
|
||||
$success = $false
|
||||
Remove-DriverModelFolder -DriversFolder $DriversFolder -TargetFolder $modelPath -Description $modelName
|
||||
# Enqueue the error status before returning
|
||||
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
|
||||
# Ensure return object is created even on error
|
||||
|
||||
@@ -5,8 +5,142 @@
|
||||
This module contains all the business logic for the 'Drivers' tab in the FFU Builder UI. It handles fetching driver model lists from various manufacturers (Microsoft, Dell, HP, Lenovo), displaying and filtering them in the UI, and managing the selection state. It also includes functions to import and export driver selections to a JSON file (Drivers.json) and to orchestrate the parallel download of selected driver packages using the common parallel processing module.
|
||||
#>
|
||||
|
||||
# Helper function to get models for a selected Make and standardize them
|
||||
function Get-ModelsForMake {
|
||||
function ConvertTo-DriverBaseName {
|
||||
param(
|
||||
[string]$ModelString
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ModelString)) {
|
||||
return $ModelString
|
||||
}
|
||||
|
||||
if ($ModelString -match '^(.*?)\s*\((.+)\)\s*$') {
|
||||
return $matches[1].Trim()
|
||||
}
|
||||
|
||||
return $ModelString.Trim()
|
||||
}
|
||||
|
||||
function Get-DriverDisplayName {
|
||||
param(
|
||||
[string]$BaseName,
|
||||
[string]$Identifier
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($BaseName)) {
|
||||
return $Identifier
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Identifier)) {
|
||||
return $BaseName.Trim()
|
||||
}
|
||||
|
||||
return "$($BaseName.Trim()) ($($Identifier.Trim()))"
|
||||
}
|
||||
|
||||
function Convert-DriverItemToJsonModel {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[pscustomobject]$DriverItem
|
||||
)
|
||||
|
||||
$makeName = $DriverItem.Make
|
||||
switch ($makeName) {
|
||||
'Microsoft' {
|
||||
$modelObject = @{ Name = $DriverItem.Model }
|
||||
if ($DriverItem.PSObject.Properties['Link'] -and -not [string]::IsNullOrWhiteSpace($DriverItem.Link)) {
|
||||
$modelObject.Link = $DriverItem.Link
|
||||
}
|
||||
return $modelObject
|
||||
}
|
||||
'Dell' {
|
||||
$systemId = if ($DriverItem.PSObject.Properties['SystemId']) { $DriverItem.SystemId } else { $null }
|
||||
$baseName = ConvertTo-DriverBaseName -ModelString $DriverItem.Model
|
||||
if ([string]::IsNullOrWhiteSpace($baseName)) {
|
||||
$baseName = $DriverItem.Model
|
||||
}
|
||||
$modelObject = @{ Name = $baseName }
|
||||
if (-not [string]::IsNullOrWhiteSpace($systemId)) {
|
||||
$modelObject.SystemId = $systemId
|
||||
}
|
||||
if ($DriverItem.PSObject.Properties['CabUrl'] -and -not [string]::IsNullOrWhiteSpace($DriverItem.CabUrl)) {
|
||||
$modelObject.CabUrl = $DriverItem.CabUrl
|
||||
}
|
||||
return $modelObject
|
||||
}
|
||||
'HP' {
|
||||
$baseName = if ($DriverItem.PSObject.Properties['ProductName'] -and -not [string]::IsNullOrWhiteSpace($DriverItem.ProductName)) { $DriverItem.ProductName } else { ConvertTo-DriverBaseName -ModelString $DriverItem.Model }
|
||||
if ([string]::IsNullOrWhiteSpace($baseName)) {
|
||||
$baseName = $DriverItem.Model
|
||||
}
|
||||
$systemId = if ($DriverItem.PSObject.Properties['SystemId']) { $DriverItem.SystemId } else { $null }
|
||||
$modelObject = @{ Name = $baseName.Trim() }
|
||||
if (-not [string]::IsNullOrWhiteSpace($systemId)) {
|
||||
$modelObject.SystemId = $systemId
|
||||
}
|
||||
return $modelObject
|
||||
}
|
||||
'Lenovo' {
|
||||
$machineType = $DriverItem.MachineType
|
||||
$baseName = if ($DriverItem.ProductName) { $DriverItem.ProductName } else { ConvertTo-DriverBaseName -ModelString $DriverItem.Model }
|
||||
if ([string]::IsNullOrWhiteSpace($baseName) -or [string]::IsNullOrWhiteSpace($machineType)) {
|
||||
WriteLog "Skipping Lenovo driver '$($DriverItem.Model)' because Name or MachineType is missing."
|
||||
return $null
|
||||
}
|
||||
return @{
|
||||
Name = $baseName
|
||||
MachineType = $machineType
|
||||
}
|
||||
}
|
||||
default {
|
||||
WriteLog "Convert-DriverItemToJsonModel: Unsupported Make '$makeName'."
|
||||
return $null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-DriverModelFolder {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$DriversFolder,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TargetFolder,
|
||||
[string]$Description
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($DriversFolder) -or [string]::IsNullOrWhiteSpace($TargetFolder)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (-not (Test-Path -Path $TargetFolder -PathType Container)) {
|
||||
return
|
||||
}
|
||||
|
||||
$driversRoot = [System.IO.Path]::GetFullPath((Resolve-Path -Path $DriversFolder -ErrorAction Stop).ProviderPath)
|
||||
$targetPath = [System.IO.Path]::GetFullPath((Resolve-Path -Path $TargetFolder -ErrorAction Stop).ProviderPath)
|
||||
|
||||
if ($targetPath -eq $driversRoot) {
|
||||
WriteLog "Remove-DriverModelFolder skipped deleting Drivers root: $targetPath"
|
||||
return
|
||||
}
|
||||
|
||||
if (-not ($targetPath.StartsWith($driversRoot, [System.StringComparison]::OrdinalIgnoreCase))) {
|
||||
WriteLog "Remove-DriverModelFolder skipped path outside Drivers root: $targetPath"
|
||||
return
|
||||
}
|
||||
|
||||
$contextMessage = if ([string]::IsNullOrWhiteSpace($Description)) { $targetPath } else { "$Description ($targetPath)" }
|
||||
WriteLog "Removing driver folder $contextMessage due to failure."
|
||||
Remove-Item -Path $targetPath -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
catch {
|
||||
WriteLog "Remove-DriverModelFolder failed for $($TargetFolder): $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Helper function to get models for a selected Make and standardize them
|
||||
function Get-ModelsForMake {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SelectedMake,
|
||||
@@ -84,11 +218,12 @@ function ConvertTo-StandardizedDriverModel {
|
||||
[psobject]$State
|
||||
)
|
||||
|
||||
$modelDisplay = $RawDriverObject.Model # Default
|
||||
$id = $RawDriverObject.Model # Default
|
||||
$modelDisplay = $RawDriverObject.Model
|
||||
$id = $RawDriverObject.Model
|
||||
$link = $null
|
||||
$productName = $null
|
||||
$machineType = $null
|
||||
$systemId = $null
|
||||
|
||||
if ($RawDriverObject.PSObject.Properties['Link']) {
|
||||
$link = $RawDriverObject.Link
|
||||
@@ -102,7 +237,30 @@ function ConvertTo-StandardizedDriverModel {
|
||||
$id = $RawDriverObject.MachineType
|
||||
}
|
||||
|
||||
return [PSCustomObject]@{
|
||||
# HP specific handling
|
||||
if ($Make -eq 'HP') {
|
||||
$productName = if ($RawDriverObject.PSObject.Properties['ProductName'] -and -not [string]::IsNullOrWhiteSpace($RawDriverObject.ProductName)) { $RawDriverObject.ProductName } else { ConvertTo-DriverBaseName -ModelString $RawDriverObject.Model }
|
||||
if ([string]::IsNullOrWhiteSpace($productName)) { $productName = $RawDriverObject.Model }
|
||||
if ($RawDriverObject.PSObject.Properties['SystemId'] -and -not [string]::IsNullOrWhiteSpace($RawDriverObject.SystemId)) {
|
||||
$systemId = $RawDriverObject.SystemId
|
||||
}
|
||||
$modelDisplay = if ([string]::IsNullOrWhiteSpace($systemId)) { $productName } else { Get-DriverDisplayName -BaseName $productName -Identifier $systemId }
|
||||
$id = if ([string]::IsNullOrWhiteSpace($systemId)) { $productName } else { $systemId }
|
||||
}
|
||||
|
||||
# Dell-specific passthrough (needed for per-model cab workflow)
|
||||
$dellBrand = $null
|
||||
$dellModelNumber = $null
|
||||
$dellSystemId = $null
|
||||
$dellCabUrl = $null
|
||||
if ($Make -eq 'Dell') {
|
||||
if ($RawDriverObject.PSObject.Properties['Brand']) { $dellBrand = $RawDriverObject.Brand }
|
||||
if ($RawDriverObject.PSObject.Properties['ModelNumber']) { $dellModelNumber = $RawDriverObject.ModelNumber }
|
||||
if ($RawDriverObject.PSObject.Properties['SystemId']) { $dellSystemId = $RawDriverObject.SystemId }
|
||||
if ($RawDriverObject.PSObject.Properties['CabUrl']) { $dellCabUrl = $RawDriverObject.CabUrl }
|
||||
}
|
||||
|
||||
$output = [PSCustomObject]@{
|
||||
IsSelected = $false
|
||||
Make = $Make
|
||||
Model = $modelDisplay
|
||||
@@ -110,12 +268,25 @@ function ConvertTo-StandardizedDriverModel {
|
||||
Id = $id
|
||||
ProductName = $productName
|
||||
MachineType = $machineType
|
||||
Version = "" # Placeholder
|
||||
Type = "" # Placeholder
|
||||
Size = "" # Placeholder
|
||||
Arch = "" # Placeholder
|
||||
DownloadStatus = "" # Initial download status
|
||||
Version = ""
|
||||
Type = ""
|
||||
Size = ""
|
||||
Arch = ""
|
||||
DownloadStatus = ""
|
||||
}
|
||||
|
||||
if ($Make -eq 'Dell') {
|
||||
# Add Dell-only fields so Save-DellDriversTask can use CabUrl
|
||||
$output | Add-Member -NotePropertyName Brand -NotePropertyValue $dellBrand
|
||||
$output | Add-Member -NotePropertyName ModelNumber -NotePropertyValue $dellModelNumber
|
||||
$output | Add-Member -NotePropertyName SystemId -NotePropertyValue $dellSystemId
|
||||
$output | Add-Member -NotePropertyName CabUrl -NotePropertyValue $dellCabUrl
|
||||
}
|
||||
elseif ($Make -eq 'HP' -and -not [string]::IsNullOrWhiteSpace($systemId)) {
|
||||
$output | Add-Member -NotePropertyName SystemId -NotePropertyValue $systemId
|
||||
}
|
||||
|
||||
return $output
|
||||
}
|
||||
|
||||
# Function to filter the driver model list based on text input
|
||||
@@ -188,35 +359,7 @@ function Save-DriversJson {
|
||||
$modelsForThisMake = @() # Initialize an array to hold model objects
|
||||
|
||||
foreach ($driverItem in $_.Group) {
|
||||
$modelObject = $null
|
||||
switch ($makeName) {
|
||||
'Microsoft' {
|
||||
$modelObject = @{
|
||||
Name = $driverItem.Model # Model is the display name
|
||||
Link = $driverItem.Link
|
||||
}
|
||||
}
|
||||
'Dell' {
|
||||
$modelObject = @{
|
||||
Name = $driverItem.Model
|
||||
}
|
||||
}
|
||||
'HP' {
|
||||
$modelObject = @{
|
||||
Name = $driverItem.Model
|
||||
}
|
||||
}
|
||||
'Lenovo' {
|
||||
$modelObject = @{
|
||||
Name = $driverItem.Model # This is "ProductName (MachineType)"
|
||||
ProductName = $driverItem.ProductName # This is "ProductName"
|
||||
MachineType = $driverItem.MachineType # This is "MachineType"
|
||||
}
|
||||
}
|
||||
default {
|
||||
WriteLog "Save-DriversJson: Unknown Make '$makeName' encountered for model '$($driverItem.Model)'. Skipping."
|
||||
}
|
||||
}
|
||||
$modelObject = Convert-DriverItemToJsonModel -DriverItem $driverItem
|
||||
if ($null -ne $modelObject) {
|
||||
$modelsForThisMake += $modelObject
|
||||
}
|
||||
@@ -307,11 +450,97 @@ function Import-DriversJson {
|
||||
continue
|
||||
}
|
||||
|
||||
$normalizedName = $importedModelNameFromObject
|
||||
$skipModel = $false
|
||||
switch ($makeName) {
|
||||
'Lenovo' {
|
||||
$productName = if ($importedModelObject.PSObject.Properties['ProductName'] -and -not [string]::IsNullOrWhiteSpace($importedModelObject.ProductName)) { $importedModelObject.ProductName } else { ConvertTo-DriverBaseName -ModelString $normalizedName }
|
||||
$machineType = if ($importedModelObject.PSObject.Properties['MachineType'] -and -not [string]::IsNullOrWhiteSpace($importedModelObject.MachineType)) { $importedModelObject.MachineType } else { $null }
|
||||
if ([string]::IsNullOrWhiteSpace($machineType) -and $normalizedName -match '(.+?)\s*\((.+?)\)$') {
|
||||
if ([string]::IsNullOrWhiteSpace($productName)) { $productName = $matches[1].Trim() }
|
||||
$machineType = $matches[2].Trim()
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($productName) -or [string]::IsNullOrWhiteSpace($machineType)) {
|
||||
WriteLog "Import-DriversJson: Skipping Lenovo model '$normalizedName' due to missing ProductName or MachineType."
|
||||
$skipModel = $true
|
||||
}
|
||||
else {
|
||||
$normalizedName = Get-DriverDisplayName -BaseName $productName -Identifier $machineType
|
||||
if ($importedModelObject.PSObject.Properties['ProductName']) {
|
||||
$importedModelObject.ProductName = $productName
|
||||
}
|
||||
else {
|
||||
$importedModelObject | Add-Member -NotePropertyName ProductName -NotePropertyValue $productName
|
||||
}
|
||||
if ($importedModelObject.PSObject.Properties['MachineType']) {
|
||||
$importedModelObject.MachineType = $machineType
|
||||
}
|
||||
else {
|
||||
$importedModelObject | Add-Member -NotePropertyName MachineType -NotePropertyValue $machineType
|
||||
}
|
||||
}
|
||||
}
|
||||
'Dell' {
|
||||
$baseName = ConvertTo-DriverBaseName -ModelString $normalizedName
|
||||
if ([string]::IsNullOrWhiteSpace($baseName)) { $baseName = $normalizedName }
|
||||
$systemId = if ($importedModelObject.PSObject.Properties['SystemId'] -and -not [string]::IsNullOrWhiteSpace($importedModelObject.SystemId)) { $importedModelObject.SystemId } else { $null }
|
||||
if ([string]::IsNullOrWhiteSpace($systemId) -and $normalizedName -match '(.+?)\s*\((.+?)\)$') {
|
||||
if ([string]::IsNullOrWhiteSpace($baseName)) { $baseName = $matches[1].Trim() }
|
||||
$systemId = $matches[2].Trim()
|
||||
}
|
||||
$normalizedName = if ([string]::IsNullOrWhiteSpace($systemId)) { $baseName.Trim() } else { Get-DriverDisplayName -BaseName $baseName -Identifier $systemId }
|
||||
if ($importedModelObject.PSObject.Properties['SystemId']) {
|
||||
$importedModelObject.SystemId = $systemId
|
||||
}
|
||||
else {
|
||||
$importedModelObject | Add-Member -NotePropertyName SystemId -NotePropertyValue $systemId
|
||||
}
|
||||
}
|
||||
'HP' {
|
||||
$baseName = ConvertTo-DriverBaseName -ModelString $normalizedName
|
||||
if ([string]::IsNullOrWhiteSpace($baseName)) { $baseName = $normalizedName }
|
||||
$systemId = if ($importedModelObject.PSObject.Properties['SystemId'] -and -not [string]::IsNullOrWhiteSpace($importedModelObject.SystemId)) { $importedModelObject.SystemId } else { $null }
|
||||
if ([string]::IsNullOrWhiteSpace($systemId) -and $normalizedName -match '(.+?)\s*\((.+?)\)$') {
|
||||
if ([string]::IsNullOrWhiteSpace($baseName)) { $baseName = $matches[1].Trim() }
|
||||
$systemId = $matches[2].Trim()
|
||||
}
|
||||
$normalizedName = if ([string]::IsNullOrWhiteSpace($systemId)) { $baseName.Trim() } else { Get-DriverDisplayName -BaseName $baseName -Identifier $systemId }
|
||||
if ($importedModelObject.PSObject.Properties['ProductName']) {
|
||||
$importedModelObject.ProductName = $baseName
|
||||
}
|
||||
else {
|
||||
$importedModelObject | Add-Member -NotePropertyName ProductName -NotePropertyValue $baseName
|
||||
}
|
||||
if ($importedModelObject.PSObject.Properties['SystemId']) {
|
||||
$importedModelObject.SystemId = $systemId
|
||||
}
|
||||
else {
|
||||
$importedModelObject | Add-Member -NotePropertyName SystemId -NotePropertyValue $systemId
|
||||
}
|
||||
}
|
||||
default {
|
||||
$normalizedName = $normalizedName.Trim()
|
||||
}
|
||||
}
|
||||
|
||||
if ($skipModel) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($normalizedName)) {
|
||||
WriteLog "Import-DriversJson: Skipping normalized model name for Make '$makeName'."
|
||||
continue
|
||||
}
|
||||
|
||||
$importedModelObject.Name = $normalizedName
|
||||
$importedModelNameFromObject = $normalizedName
|
||||
|
||||
$existingModel = $State.Data.allDriverModels | Where-Object { $_.Make -eq $makeName -and $_.Model -eq $importedModelNameFromObject } | Select-Object -First 1
|
||||
|
||||
if ($null -ne $existingModel) {
|
||||
$existingModel.IsSelected = $true
|
||||
$existingModel.DownloadStatus = "Imported"
|
||||
$existingModel.Model = $importedModelNameFromObject
|
||||
|
||||
if ($makeName -eq 'Microsoft' -and $importedModelObject.PSObject.Properties['Link']) {
|
||||
if ($existingModel.Link -ne $importedModelObject.Link) {
|
||||
@@ -327,13 +556,36 @@ function Import-DriversJson {
|
||||
}
|
||||
if ($importedModelObject.PSObject.Properties['MachineType'] -and $existingModel.PSObject.Properties['MachineType'] -and $existingModel.MachineType -ne $importedModelObject.MachineType) {
|
||||
$existingModel.MachineType = $importedModelObject.MachineType
|
||||
$existingModel.Id = $importedModelObject.MachineType # Update Id as well
|
||||
$existingModel.Id = $importedModelObject.MachineType
|
||||
$updateExistingLenovo = $true
|
||||
}
|
||||
if ($updateExistingLenovo) {
|
||||
WriteLog "Import-DriversJson: Updated ProductName/MachineType/Id for existing Lenovo model '$($existingModel.Model)'."
|
||||
}
|
||||
}
|
||||
elseif ($makeName -eq 'Dell') {
|
||||
# Update Dell extended fields if provided
|
||||
if ($importedModelObject.PSObject.Properties['SystemId'] -and $existingModel.PSObject.Properties['SystemId'] -and -not [string]::IsNullOrWhiteSpace($importedModelObject.SystemId)) {
|
||||
if ($existingModel.SystemId -ne $importedModelObject.SystemId) {
|
||||
$existingModel.SystemId = $importedModelObject.SystemId
|
||||
WriteLog "Import-DriversJson: Updated SystemId for existing Dell model '$($existingModel.Model)'."
|
||||
}
|
||||
}
|
||||
if ($importedModelObject.PSObject.Properties['CabUrl'] -and $existingModel.PSObject.Properties['CabUrl'] -and -not [string]::IsNullOrWhiteSpace($importedModelObject.CabUrl)) {
|
||||
if ($existingModel.CabUrl -ne $importedModelObject.CabUrl) {
|
||||
$existingModel.CabUrl = $importedModelObject.CabUrl
|
||||
WriteLog "Import-DriversJson: Updated CabUrl for existing Dell model '$($existingModel.Model)'."
|
||||
}
|
||||
}
|
||||
}
|
||||
elseif ($makeName -eq 'HP') {
|
||||
$importedProductName = if ($importedModelObject.PSObject.Properties['ProductName'] -and -not [string]::IsNullOrWhiteSpace($importedModelObject.ProductName)) { $importedModelObject.ProductName } else { ConvertTo-DriverBaseName -ModelString $importedModelNameFromObject }
|
||||
if ([string]::IsNullOrWhiteSpace($importedProductName)) { $importedProductName = $importedModelNameFromObject }
|
||||
if ($importedModelObject.PSObject.Properties['SystemId'] -and -not [string]::IsNullOrWhiteSpace($importedModelObject.SystemId)) {
|
||||
$importedId = $importedModelObject.SystemId
|
||||
}
|
||||
}
|
||||
|
||||
$existingModelsUpdated++
|
||||
WriteLog "Import-DriversJson: Marked existing model '$($existingModel.Make) - $($existingModel.Model)' as imported."
|
||||
}
|
||||
@@ -370,7 +622,7 @@ function Import-DriversJson {
|
||||
$newDriverModel = [PSCustomObject]@{
|
||||
IsSelected = $true
|
||||
Make = $makeName
|
||||
Model = $importedModelNameFromObject # Full display name
|
||||
Model = $importedModelNameFromObject
|
||||
Link = $importedLink
|
||||
Id = $importedId
|
||||
ProductName = $importedProductName
|
||||
@@ -381,6 +633,20 @@ function Import-DriversJson {
|
||||
Arch = ""
|
||||
DownloadStatus = "Imported"
|
||||
}
|
||||
if ($makeName -eq 'Dell') {
|
||||
# Attach optional Dell extended fields if present
|
||||
if ($importedModelObject.PSObject.Properties['SystemId'] -and -not [string]::IsNullOrWhiteSpace($importedModelObject.SystemId)) {
|
||||
$newDriverModel | Add-Member -NotePropertyName SystemId -NotePropertyValue $importedModelObject.SystemId
|
||||
}
|
||||
if ($importedModelObject.PSObject.Properties['CabUrl'] -and -not [string]::IsNullOrWhiteSpace($importedModelObject.CabUrl)) {
|
||||
$newDriverModel | Add-Member -NotePropertyName CabUrl -NotePropertyValue $importedModelObject.CabUrl
|
||||
}
|
||||
}
|
||||
elseif ($makeName -eq 'HP') {
|
||||
if ($importedModelObject.PSObject.Properties['SystemId'] -and -not [string]::IsNullOrWhiteSpace($importedModelObject.SystemId)) {
|
||||
$newDriverModel | Add-Member -NotePropertyName SystemId -NotePropertyValue $importedModelObject.SystemId
|
||||
}
|
||||
}
|
||||
$State.Data.allDriverModels.Add($newDriverModel)
|
||||
$newModelsAdded++
|
||||
WriteLog "Import-DriversJson: Added new model '$($newDriverModel.Make) - $($newDriverModel.Model)' from import. ID: $($newDriverModel.Id), Link: $($newDriverModel.Link)"
|
||||
@@ -571,6 +837,8 @@ function Invoke-DownloadSelectedDrivers {
|
||||
$localHeaders = $coreStaticVars.Headers
|
||||
$localUserAgent = $coreStaticVars.UserAgent
|
||||
$compressDrivers = $State.Controls.chkCompressDriversToWIM.IsChecked
|
||||
# Determine if we must preserve source folders (used later for PE driver harvesting)
|
||||
$preserveSource = ($State.Controls.chkUseDriversAsPEDrivers.IsChecked -and $State.Controls.chkCompressDriversToWIM.IsChecked)
|
||||
|
||||
$State.Controls.txtStatus.Text = "Processing all selected drivers..."
|
||||
WriteLog "Processing all selected drivers: $($selectedDrivers.Model -join ', ')"
|
||||
@@ -580,10 +848,10 @@ function Invoke-DownloadSelectedDrivers {
|
||||
WriteLog "Dell drivers selected. Ensuring Dell Catalog is up-to-date..."
|
||||
try {
|
||||
$dellDriversFolder = Join-Path -Path $localDriversFolder -ChildPath "Dell"
|
||||
$catalogBaseName = if ($localWindowsRelease -le 11) { "CatalogPC" } else { "Catalog" }
|
||||
$catalogBaseName = if ($localWindowsRelease -le 11) { "CatalogIndexPC" } else { "Catalog" }
|
||||
$dellCabFile = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).cab"
|
||||
$dellCatalogXML = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).xml"
|
||||
$catalogUrl = if ($localWindowsRelease -le 11) { "http://downloads.dell.com/catalog/CatalogPC.cab" } else { "https://downloads.dell.com/catalog/Catalog.cab" }
|
||||
$catalogUrl = if ($localWindowsRelease -le 11) { "https://downloads.dell.com/catalog/CatalogIndexPC.cab" } else { "https://downloads.dell.com/catalog/Catalog.cab" }
|
||||
|
||||
$downloadCatalog = $true
|
||||
if (Test-Path -Path $dellCatalogXML -PathType Leaf) {
|
||||
@@ -620,15 +888,16 @@ function Invoke-DownloadSelectedDrivers {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
$preserveSource = ($State.Controls.chkUseDriversAsPEDrivers.IsChecked -and $State.Controls.chkCompressDriversToWIM.IsChecked)
|
||||
$taskArguments = @{
|
||||
DriversFolder = $localDriversFolder
|
||||
WindowsRelease = $localWindowsRelease
|
||||
WindowsArch = $localWindowsArch
|
||||
WindowsVersion = $localWindowsVersion
|
||||
Headers = $localHeaders
|
||||
UserAgent = $localUserAgent
|
||||
CompressToWim = $compressDrivers
|
||||
DriversFolder = $localDriversFolder
|
||||
WindowsRelease = $localWindowsRelease
|
||||
WindowsArch = $localWindowsArch
|
||||
WindowsVersion = $localWindowsVersion
|
||||
Headers = $localHeaders
|
||||
UserAgent = $localUserAgent
|
||||
CompressToWim = $compressDrivers
|
||||
PreserveSourceOnCompress = $preserveSource
|
||||
}
|
||||
|
||||
$parallelResults = Invoke-ParallelProcessing -ItemsToProcess $selectedDrivers `
|
||||
@@ -645,13 +914,17 @@ function Invoke-DownloadSelectedDrivers {
|
||||
|
||||
$overallSuccess = $true
|
||||
$successfullyDownloaded = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
$failedDownloads = [System.Collections.Generic.List[PSCustomObject]]::new()
|
||||
|
||||
# Check the results from the parallel processing tasks
|
||||
if ($null -ne $parallelResults) {
|
||||
# Create a lookup from the original selected drivers to get the 'Make' property,
|
||||
# as the result object might only have 'Identifier' or 'Model'.
|
||||
$makeLookup = @{}
|
||||
$selectedDrivers | ForEach-Object { $makeLookup[$_.Model] = $_.Make }
|
||||
# Create a lookup from the original selected drivers to retain full metadata for mapping.
|
||||
$driverLookup = @{}
|
||||
foreach ($driver in $selectedDrivers) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($driver.Model)) {
|
||||
$driverLookup[$driver.Model] = $driver
|
||||
}
|
||||
}
|
||||
|
||||
# Filter for objects that could be results, avoiding stray log strings
|
||||
foreach ($result in ($parallelResults | Where-Object { $_ -is [hashtable] })) {
|
||||
@@ -666,27 +939,61 @@ function Invoke-DownloadSelectedDrivers {
|
||||
if ([string]::IsNullOrWhiteSpace($modelName)) {
|
||||
WriteLog "Could not determine model name from result object: $($result | ConvertTo-Json -Compress -Depth 3)"
|
||||
$overallSuccess = $false
|
||||
$failedDownloads.Add([PSCustomObject]@{
|
||||
Model = 'Unknown model'
|
||||
Status = 'Driver task returned without a model identifier.'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if ($resultCode -ne 0) {
|
||||
$overallSuccess = $false
|
||||
$failureStatus = $result['Status']
|
||||
if ([string]::IsNullOrWhiteSpace($failureStatus)) { $failureStatus = 'Driver download failed. Check the log for details.' }
|
||||
$failedDownloads.Add([PSCustomObject]@{
|
||||
Model = $modelName
|
||||
Status = $failureStatus
|
||||
})
|
||||
WriteLog "Error detected for model $modelName."
|
||||
}
|
||||
elseif (-not [string]::IsNullOrWhiteSpace($driverPath)) {
|
||||
# The task was successful and returned a driver path.
|
||||
$make = $makeLookup[$modelName]
|
||||
if ($make) {
|
||||
$successfullyDownloaded.Add([PSCustomObject]@{
|
||||
Make = $make
|
||||
Model = $modelName
|
||||
DriverPath = $driverPath
|
||||
})
|
||||
$driverMetadata = $null
|
||||
if (-not [string]::IsNullOrWhiteSpace($modelName) -and $driverLookup.ContainsKey($modelName)) {
|
||||
$driverMetadata = $driverLookup[$modelName]
|
||||
}
|
||||
|
||||
if ($driverMetadata) {
|
||||
$driverRecord = [PSCustomObject]@{
|
||||
Make = $driverMetadata.Make
|
||||
Model = $modelName
|
||||
DriverPath = $driverPath
|
||||
}
|
||||
if ($driverMetadata.PSObject.Properties['SystemId'] -and -not [string]::IsNullOrWhiteSpace($driverMetadata.SystemId)) {
|
||||
$driverRecord | Add-Member -NotePropertyName SystemId -NotePropertyValue $driverMetadata.SystemId
|
||||
}
|
||||
if ($driverMetadata.PSObject.Properties['MachineType'] -and -not [string]::IsNullOrWhiteSpace($driverMetadata.MachineType)) {
|
||||
$driverRecord | Add-Member -NotePropertyName MachineType -NotePropertyValue $driverMetadata.MachineType
|
||||
}
|
||||
if ($driverMetadata.PSObject.Properties['ProductName'] -and -not [string]::IsNullOrWhiteSpace($driverMetadata.ProductName)) {
|
||||
$driverRecord | Add-Member -NotePropertyName ProductName -NotePropertyValue $driverMetadata.ProductName
|
||||
}
|
||||
$successfullyDownloaded.Add($driverRecord)
|
||||
}
|
||||
else {
|
||||
WriteLog "Warning: Could not find 'Make' for successful download of model '$modelName'. Skipping from DriverMapping.json."
|
||||
WriteLog "Warning: Could not find driver metadata for successful download of model '$modelName'. Skipping from DriverMapping.json."
|
||||
}
|
||||
}
|
||||
else {
|
||||
$overallSuccess = $false
|
||||
$fallbackStatus = $result['Status']
|
||||
if ([string]::IsNullOrWhiteSpace($fallbackStatus)) { $fallbackStatus = 'Driver download did not return a driver path.' }
|
||||
$failedDownloads.Add([PSCustomObject]@{
|
||||
Model = $modelName
|
||||
Status = $fallbackStatus
|
||||
})
|
||||
WriteLog "Driver download did not provide a path for model $modelName."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -714,43 +1021,17 @@ function Invoke-DownloadSelectedDrivers {
|
||||
$makeName = $_.Name
|
||||
$modelsForThisMake = @() # Initialize an array to hold model objects
|
||||
|
||||
foreach ($driverItem in $_.Group) {
|
||||
$modelObject = $null
|
||||
switch ($makeName) {
|
||||
'Microsoft' {
|
||||
$modelObject = @{
|
||||
Name = $driverItem.Model # Model is the display name
|
||||
Link = $driverItem.Link
|
||||
}
|
||||
}
|
||||
'Dell' {
|
||||
$modelObject = @{
|
||||
Name = $driverItem.Model # Model is the display name
|
||||
}
|
||||
}
|
||||
'HP' {
|
||||
$modelObject = @{
|
||||
Name = $driverItem.Model
|
||||
}
|
||||
}
|
||||
'Lenovo' {
|
||||
$modelObject = @{
|
||||
Name = $driverItem.Model
|
||||
ProductName = $driverItem.ProductName
|
||||
MachineType = $driverItem.MachineType
|
||||
}
|
||||
}
|
||||
default {
|
||||
WriteLog "Auto-Save Drivers.json: Unrecognized Make '$makeName' for driver '$($driverItem.Model)'. Skipping."
|
||||
foreach ($driverItem in $_.Group) {
|
||||
$modelObject = Convert-DriverItemToJsonModel -DriverItem $driverItem
|
||||
if ($null -ne $modelObject) {
|
||||
$modelsForThisMake += $modelObject
|
||||
}
|
||||
}
|
||||
if ($null -ne $modelObject) {
|
||||
$modelsForThisMake += $modelObject
|
||||
|
||||
if ($modelsForThisMake.Count -gt 0) {
|
||||
$outputJson[$makeName] = @{ Models = $modelsForThisMake }
|
||||
}
|
||||
}
|
||||
# Add the models array to the make-specific object
|
||||
$outputJson[$makeName] = @{ Models = $modelsForThisMake }
|
||||
}
|
||||
|
||||
# Ensure directory exists
|
||||
$parentDir = Split-Path -Path $driversJsonPath -Parent
|
||||
@@ -775,8 +1056,21 @@ function Invoke-DownloadSelectedDrivers {
|
||||
[System.Windows.MessageBox]::Show("All selected driver downloads processed. Check status column for details.", "Download Process Finished", "OK", "Information")
|
||||
}
|
||||
else {
|
||||
$State.Controls.txtStatus.Text = "Driver downloads processed with some errors. Check status column and log."
|
||||
[System.Windows.MessageBox]::Show("Driver downloads processed, but some errors occurred. Please check the status column for each driver and the log file for details.", "Download Process Finished with Errors", "OK", "Warning")
|
||||
$State.Controls.txtStatus.Text = "Driver download failed. Resolve the errors and try again."
|
||||
$messageLines = [System.Collections.Generic.List[string]]::new()
|
||||
if ($failedDownloads.Count -gt 0) {
|
||||
$messageLines.Add("Driver download failed for:")
|
||||
foreach ($item in ($failedDownloads | Select-Object -First 5)) {
|
||||
$messageLines.Add("- $($item.Model): $($item.Status)")
|
||||
}
|
||||
if ($failedDownloads.Count -gt 5) {
|
||||
$messageLines.Add("...see the log for additional failures.")
|
||||
}
|
||||
}
|
||||
else {
|
||||
$messageLines.Add("One or more driver downloads failed. Check the log for details.")
|
||||
}
|
||||
[System.Windows.MessageBox]::Show(($messageLines -join [System.Environment]::NewLine), "Driver Download Failed", "OK", "Error")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,8 @@ function Register-EventHandlers {
|
||||
$State.Controls.txtDiskSize,
|
||||
$State.Controls.txtMemory,
|
||||
$State.Controls.txtProcessors,
|
||||
$State.Controls.txtThreads
|
||||
$State.Controls.txtThreads,
|
||||
$State.Controls.txtMaxUSBDrives
|
||||
)
|
||||
|
||||
# Attach the handlers to each relevant textbox
|
||||
@@ -72,6 +73,20 @@ function Register-EventHandlers {
|
||||
})
|
||||
}
|
||||
|
||||
# Add specific validation for the Max USB Drives textbox to ensure it's an integer >=0 (allow 0 meaning all)
|
||||
if ($null -ne $State.Controls.txtMaxUSBDrives) {
|
||||
$State.Controls.txtMaxUSBDrives.Add_LostFocus({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$textBox = $eventSource
|
||||
$currentValue = 0
|
||||
$isValidInteger = [int]::TryParse($textBox.Text, [ref]$currentValue)
|
||||
if (-not $isValidInteger -or $currentValue -lt 0) {
|
||||
$textBox.Text = '0'
|
||||
WriteLog "Max USB Drives value was invalid or less than 0. Reset to 0 (process all)."
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
# Build Tab Event Handlers
|
||||
$State.Controls.btnBrowseFFUDevPath.Add_Click({
|
||||
param($eventSource, $routedEventArgs)
|
||||
@@ -137,6 +152,61 @@ function Register-EventHandlers {
|
||||
$localState.Controls.chkPromptExternalHardDiskMedia.IsChecked = $false
|
||||
})
|
||||
|
||||
if ($null -ne $State.Controls.cmbBitsPriority) {
|
||||
$State.Controls.cmbBitsPriority.Add_SelectionChanged({
|
||||
param($eventSource, $selectionChangedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
if ($null -eq $window -or $null -eq $window.Tag) {
|
||||
return
|
||||
}
|
||||
Update-BitsPrioritySetting -State $window.Tag
|
||||
})
|
||||
}
|
||||
|
||||
# Additional FFU Files events
|
||||
$State.Controls.chkCopyAdditionalFFUFiles.Add_Checked({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
$localState.Controls.additionalFFUPanel.Visibility = 'Visible'
|
||||
Update-AdditionalFFUList -State $localState
|
||||
})
|
||||
$State.Controls.chkCopyAdditionalFFUFiles.Add_Unchecked({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
$localState.Controls.additionalFFUPanel.Visibility = 'Collapsed'
|
||||
$localState.Controls.lstAdditionalFFUs.Items.Clear()
|
||||
$headerChk = $localState.Controls.chkSelectAllAdditionalFFUs
|
||||
if ($null -ne $headerChk) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $localState.Controls.lstAdditionalFFUs -HeaderCheckBox $headerChk
|
||||
}
|
||||
})
|
||||
$State.Controls.btnRefreshAdditionalFFUs.Add_Click({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
Update-AdditionalFFUList -State $localState
|
||||
})
|
||||
$State.Controls.lstAdditionalFFUs.Add_PreviewKeyDown({
|
||||
param($eventSource, $keyEvent)
|
||||
if ($keyEvent.Key -eq 'Space') {
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
Invoke-ListViewItemToggle -ListView $eventSource -State $localState -HeaderCheckBoxKeyName 'chkSelectAllAdditionalFFUs'
|
||||
$keyEvent.Handled = $true
|
||||
}
|
||||
})
|
||||
$State.Controls.lstAdditionalFFUs.Add_SelectionChanged({
|
||||
param($eventSource, $selChangeEvent)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
$headerChk = $localState.Controls.chkSelectAllAdditionalFFUs
|
||||
if ($null -ne $headerChk) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $localState.Controls.lstAdditionalFFUs -HeaderCheckBox $headerChk
|
||||
}
|
||||
})
|
||||
|
||||
$State.Controls.btnCheckUSBDrives.Add_Click({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
@@ -192,19 +262,42 @@ function Register-EventHandlers {
|
||||
$selectedItem = $eventSource.SelectedItem
|
||||
if ($selectedItem -eq 'Other') {
|
||||
$localState.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
|
||||
$localState.Controls.txtVMHostIPAddress.Text = '' # Clear IP for custom
|
||||
if ([string]::IsNullOrWhiteSpace($localState.Controls.txtCustomVMSwitchName.Text) -and $null -ne $localState.Data.customVMSwitchName) {
|
||||
$localState.Controls.txtCustomVMSwitchName.Text = $localState.Data.customVMSwitchName
|
||||
}
|
||||
if ($null -ne $localState.Data.customVMHostIP -and -not [string]::IsNullOrWhiteSpace($localState.Data.customVMHostIP)) {
|
||||
$localState.Controls.txtVMHostIPAddress.Text = $localState.Data.customVMHostIP
|
||||
}
|
||||
}
|
||||
else {
|
||||
$localState.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
|
||||
if ($localState.Data.vmSwitchMap.ContainsKey($selectedItem)) {
|
||||
if ($null -ne $selectedItem -and $localState.Data.vmSwitchMap.ContainsKey($selectedItem)) {
|
||||
$localState.Controls.txtVMHostIPAddress.Text = $localState.Data.vmSwitchMap[$selectedItem]
|
||||
}
|
||||
else {
|
||||
$localState.Controls.txtVMHostIPAddress.Text = '' # Clear IP if not found in map
|
||||
$localState.Controls.txtVMHostIPAddress.Text = '' # Clear IP if not found or key null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# Persist custom VM switch name/IP when user edits them while 'Other' is selected
|
||||
$State.Controls.txtVMHostIPAddress.Add_LostFocus({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
if ($localState.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') {
|
||||
$localState.Data.customVMHostIP = $localState.Controls.txtVMHostIPAddress.Text
|
||||
}
|
||||
})
|
||||
$State.Controls.txtCustomVMSwitchName.Add_LostFocus({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
if ($localState.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') {
|
||||
$localState.Data.customVMSwitchName = $localState.Controls.txtCustomVMSwitchName.Text
|
||||
}
|
||||
})
|
||||
|
||||
# Windows Settings tab Event Handlers
|
||||
$State.Controls.txtISOPath.Add_TextChanged({
|
||||
param($eventSource, $textChangedEventArgs)
|
||||
@@ -356,6 +449,13 @@ function Register-EventHandlers {
|
||||
Add-BYOApplication -State $localState
|
||||
})
|
||||
|
||||
$State.Controls.btnEditApplication.Add_Click({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
Start-EditBYOApplication -State $localState
|
||||
})
|
||||
|
||||
$State.Controls.btnSaveBYOApplications.Add_Click({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
@@ -398,12 +498,21 @@ function Register-EventHandlers {
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
|
||||
# Before clearing, check if we are in edit mode and reset the state
|
||||
if ($null -ne $localState.Data.editingBYOApplication) {
|
||||
$localState.Data.editingBYOApplication = $null
|
||||
$localState.Controls.btnAddApplication.Content = "Add Application"
|
||||
}
|
||||
|
||||
Clear-ListViewContent -State $localState `
|
||||
-ListViewControl $localState.Controls.lstApplications `
|
||||
-ConfirmationTitle "Clear BYO Applications" `
|
||||
-ConfirmationMessage "Are you sure you want to clear all 'Bring Your Own' applications?" `
|
||||
-StatusMessage "BYO application list cleared." `
|
||||
-PostClearAction { Update-CopyButtonState -State $State }
|
||||
-PostClearAction {
|
||||
Update-CopyButtonState -State $State
|
||||
Update-BYOAppsActionButtonsState -State $State
|
||||
}
|
||||
})
|
||||
|
||||
$State.Controls.btnCopyBYOApps.Add_Click({
|
||||
@@ -413,6 +522,13 @@ function Register-EventHandlers {
|
||||
Invoke-CopyBYOApps -State $localState -Button $eventSource
|
||||
})
|
||||
|
||||
$State.Controls.btnRemoveSelectedBYOApps.Add_Click({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
Remove-SelectedBYOApplications -State $localState
|
||||
})
|
||||
|
||||
$State.Controls.btnMoveTop.Add_Click({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
@@ -441,6 +557,65 @@ function Register-EventHandlers {
|
||||
Move-ListViewItemBottom -ListView $localState.Controls.lstApplications
|
||||
})
|
||||
|
||||
$State.Controls.lstApplications.Add_PreviewKeyDown({
|
||||
param($eventSource, $keyEvent)
|
||||
if ($keyEvent.Key -eq 'Space') {
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
Invoke-ListViewItemToggle -ListView $eventSource -State $localState -HeaderCheckBoxKeyName 'chkSelectAllBYOApps'
|
||||
# Update button states after toggle
|
||||
Update-BYOAppsActionButtonsState -State $localState
|
||||
$keyEvent.Handled = $true
|
||||
}
|
||||
})
|
||||
|
||||
$State.Controls.lstApplications.Add_SelectionChanged({
|
||||
param($eventSource, $selChangeEvent)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
$headerChk = $localState.Controls.chkSelectAllBYOApps
|
||||
if ($null -ne $headerChk) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $localState.Controls.lstApplications -HeaderCheckBox $headerChk
|
||||
}
|
||||
# Update button states based on selection
|
||||
Update-BYOAppsActionButtonsState -State $localState
|
||||
})
|
||||
|
||||
# Add a routed event handler to catch checkbox clicks within the ListView
|
||||
$State.Controls.lstApplications.AddHandler(
|
||||
[System.Windows.Controls.Primitives.ButtonBase]::ClickEvent,
|
||||
[System.Windows.RoutedEventHandler] {
|
||||
param($eventSource, $e)
|
||||
# Check if the original source of the click was a CheckBox
|
||||
$clickedCheckBox = $e.OriginalSource
|
||||
if ($clickedCheckBox -is [System.Windows.Controls.CheckBox]) {
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
$dataItem = $clickedCheckBox.DataContext
|
||||
|
||||
if ($null -ne $dataItem) {
|
||||
# Defensively add the 'IsSelected' property if it's missing from the data object.
|
||||
# This can happen in some complex UI scenarios or if the object was created without it.
|
||||
if ($null -eq $dataItem.PSObject.Properties['IsSelected']) {
|
||||
$dataItem | Add-Member -MemberType NoteProperty -Name 'IsSelected' -Value $false
|
||||
}
|
||||
|
||||
# Now that we're sure the property exists, set its value.
|
||||
$dataItem.IsSelected = $clickedCheckBox.IsChecked
|
||||
}
|
||||
|
||||
# Update the state of the action buttons based on the new selection.
|
||||
Update-BYOAppsActionButtonsState -State $localState
|
||||
|
||||
# Also, update the header checkbox to reflect the change.
|
||||
$headerChk = $localState.Controls.chkSelectAllBYOApps
|
||||
if ($null -ne $headerChk) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $localState.Controls.lstApplications -HeaderCheckBox $headerChk
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# Apps Script Variables Event Handlers
|
||||
# Attach the handler to the script variables checkbox
|
||||
$State.Controls.chkDefineAppsScriptVariables.Add_Checked($appPanelUpdateHandler)
|
||||
@@ -688,6 +863,10 @@ function Register-EventHandlers {
|
||||
$State.Controls.chkCopyDrivers.Add_Unchecked($driverCheckboxHandler)
|
||||
$State.Controls.chkCompressDriversToWIM.Add_Checked($driverCheckboxHandler)
|
||||
$State.Controls.chkCompressDriversToWIM.Add_Unchecked($driverCheckboxHandler)
|
||||
$State.Controls.chkCopyPEDrivers.Add_Checked($driverCheckboxHandler)
|
||||
$State.Controls.chkCopyPEDrivers.Add_Unchecked($driverCheckboxHandler)
|
||||
$State.Controls.chkUseDriversAsPEDrivers.Add_Checked($driverCheckboxHandler)
|
||||
$State.Controls.chkUseDriversAsPEDrivers.Add_Unchecked($driverCheckboxHandler)
|
||||
|
||||
$State.Controls.btnBrowseDriversFolder.Add_Click({
|
||||
param($eventSource, $routedEventArgs)
|
||||
@@ -830,6 +1009,12 @@ function Register-EventHandlers {
|
||||
$localState = $window.Tag
|
||||
Invoke-LoadConfiguration -State $localState
|
||||
})
|
||||
$State.Controls.btnRestoreDefaults.Add_Click({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
$localState = $window.Tag
|
||||
Invoke-RestoreDefaults -State $localState
|
||||
})
|
||||
$State.Controls.btnBuildConfig.Add_Click({
|
||||
param($eventSource, $routedEventArgs)
|
||||
$window = [System.Windows.Window]::GetWindow($eventSource)
|
||||
|
||||
@@ -28,7 +28,6 @@ function Initialize-UIControls {
|
||||
$State.Controls.cmbWindowsSKU = $window.FindName('cmbWindowsSKU')
|
||||
$State.Controls.cmbMediaType = $window.FindName('cmbMediaType')
|
||||
$State.Controls.MediaTypeStackPanel = $window.FindName('MediaTypeStackPanel')
|
||||
$State.Controls.txtOptionalFeatures = $window.FindName('txtOptionalFeatures')
|
||||
$State.Controls.featuresPanel = $window.FindName('stackFeaturesContainer')
|
||||
$State.Controls.chkDownloadDrivers = $window.FindName('chkDownloadDrivers')
|
||||
$State.Controls.cmbMake = $window.FindName('cmbMake')
|
||||
@@ -60,6 +59,10 @@ function Initialize-UIControls {
|
||||
$State.Controls.usbSelectionPanel = $window.FindName('usbDriveSelectionPanel')
|
||||
$State.Controls.chkAllowExternalHardDiskMedia = $window.FindName('chkAllowExternalHardDiskMedia')
|
||||
$State.Controls.chkPromptExternalHardDiskMedia = $window.FindName('chkPromptExternalHardDiskMedia')
|
||||
$State.Controls.chkCopyAdditionalFFUFiles = $window.FindName('chkCopyAdditionalFFUFiles')
|
||||
$State.Controls.additionalFFUPanel = $window.FindName('additionalFFUPanel')
|
||||
$State.Controls.lstAdditionalFFUs = $window.FindName('lstAdditionalFFUs')
|
||||
$State.Controls.btnRefreshAdditionalFFUs = $window.FindName('btnRefreshAdditionalFFUs')
|
||||
$State.Controls.chkInstallWingetApps = $window.FindName('chkInstallWingetApps')
|
||||
$State.Controls.wingetPanel = $window.FindName('wingetPanel')
|
||||
$State.Controls.btnCheckWingetModule = $window.FindName('btnCheckWingetModule')
|
||||
@@ -89,10 +92,14 @@ function Initialize-UIControls {
|
||||
$State.Controls.txtAppCommandLine = $window.FindName('txtAppCommandLine')
|
||||
$State.Controls.txtAppArguments = $window.FindName('txtAppArguments')
|
||||
$State.Controls.txtAppSource = $window.FindName('txtAppSource')
|
||||
$State.Controls.txtAppAdditionalExitCodes = $window.FindName('txtAppAdditionalExitCodes')
|
||||
$State.Controls.chkIgnoreExitCodes = $window.FindName('chkIgnoreExitCodes')
|
||||
$State.Controls.btnAddApplication = $window.FindName('btnAddApplication')
|
||||
$State.Controls.btnSaveBYOApplications = $window.FindName('btnSaveBYOApplications')
|
||||
$State.Controls.btnLoadBYOApplications = $window.FindName('btnLoadBYOApplications')
|
||||
$State.Controls.btnEditApplication = $window.FindName('btnEditApplication')
|
||||
$State.Controls.btnClearBYOApplications = $window.FindName('btnClearBYOApplications')
|
||||
$State.Controls.btnRemoveSelectedBYOApps = $window.FindName('btnRemoveSelectedBYOApps')
|
||||
$State.Controls.btnCopyBYOApps = $window.FindName('btnCopyBYOApps')
|
||||
$State.Controls.lstApplications = $window.FindName('lstApplications')
|
||||
$State.Controls.btnMoveTop = $window.FindName('btnMoveTop')
|
||||
@@ -111,11 +118,14 @@ function Initialize-UIControls {
|
||||
$State.Controls.txtShareName = $window.FindName('txtShareName')
|
||||
$State.Controls.txtUsername = $window.FindName('txtUsername')
|
||||
$State.Controls.txtThreads = $window.FindName('txtThreads')
|
||||
$State.Controls.cmbBitsPriority = $window.FindName('cmbBitsPriority')
|
||||
$State.Controls.txtMaxUSBDrives = $window.FindName('txtMaxUSBDrives')
|
||||
$State.Controls.chkCompactOS = $window.FindName('chkCompactOS')
|
||||
$State.Controls.chkOptimize = $window.FindName('chkOptimize')
|
||||
$State.Controls.chkAllowVHDXCaching = $window.FindName('chkAllowVHDXCaching')
|
||||
$State.Controls.chkCreateCaptureMedia = $window.FindName('chkCreateCaptureMedia')
|
||||
$State.Controls.chkCreateDeploymentMedia = $window.FindName('chkCreateDeploymentMedia')
|
||||
$State.Controls.chkInjectUnattend = $window.FindName('chkInjectUnattend')
|
||||
$State.Controls.chkVerbose = $window.FindName('chkVerbose')
|
||||
$State.Controls.chkCopyAutopilot = $window.FindName('chkCopyAutopilot')
|
||||
$State.Controls.chkCopyUnattend = $window.FindName('chkCopyUnattend')
|
||||
@@ -138,6 +148,7 @@ function Initialize-UIControls {
|
||||
$State.Controls.txtDriversFolder = $window.FindName('txtDriversFolder')
|
||||
$State.Controls.txtPEDriversFolder = $window.FindName('txtPEDriversFolder')
|
||||
$State.Controls.chkCopyPEDrivers = $window.FindName('chkCopyPEDrivers')
|
||||
$State.Controls.chkUseDriversAsPEDrivers = $window.FindName('chkUseDriversAsPEDrivers')
|
||||
$State.Controls.chkUpdateLatestCU = $window.FindName('chkUpdateLatestCU')
|
||||
$State.Controls.chkUpdateLatestNet = $window.FindName('chkUpdateLatestNet')
|
||||
$State.Controls.chkUpdateLatestDefender = $window.FindName('chkUpdateLatestDefender')
|
||||
@@ -165,6 +176,7 @@ function Initialize-UIControls {
|
||||
$State.Controls.btnBrowseDriversJsonPath = $window.FindName('btnBrowseDriversJsonPath')
|
||||
$State.Controls.chkUpdateADK = $window.FindName('chkUpdateADK')
|
||||
$State.Controls.btnLoadConfig = $window.FindName('btnLoadConfig')
|
||||
$State.Controls.btnRestoreDefaults = $window.FindName('btnRestoreDefaults')
|
||||
$State.Controls.btnBuildConfig = $window.FindName('btnBuildConfig')
|
||||
|
||||
# Monitor Tab
|
||||
@@ -193,11 +205,11 @@ function Initialize-VMSwitchData {
|
||||
if ($State.Controls.cmbVMSwitchName.Items.Count -gt 1) {
|
||||
$State.Controls.cmbVMSwitchName.SelectedIndex = 0
|
||||
$firstSwitch = $State.Controls.cmbVMSwitchName.SelectedItem
|
||||
if ($State.Data.vmSwitchMap.ContainsKey($firstSwitch)) {
|
||||
if ($null -ne $firstSwitch -and $State.Data.vmSwitchMap.ContainsKey($firstSwitch)) {
|
||||
$State.Controls.txtVMHostIPAddress.Text = $State.Data.vmSwitchMap[$firstSwitch]
|
||||
}
|
||||
else {
|
||||
$State.Controls.txtVMHostIPAddress.Text = $State.Defaults.generalDefaults.VMHostIPAddress # Use default if IP not found
|
||||
$State.Controls.txtVMHostIPAddress.Text = $State.Defaults.generalDefaults.VMHostIPAddress # Use default if IP not found or key null
|
||||
}
|
||||
$State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
|
||||
}
|
||||
@@ -223,11 +235,14 @@ function Initialize-UIDefaults {
|
||||
$State.Controls.txtShareName.Text = $State.Defaults.generalDefaults.ShareName
|
||||
$State.Controls.txtUsername.Text = $State.Defaults.generalDefaults.Username
|
||||
$State.Controls.txtThreads.Text = $State.Defaults.generalDefaults.Threads
|
||||
$State.Controls.cmbBitsPriority.SelectedItem = $State.Defaults.generalDefaults.BitsPriority
|
||||
$State.Controls.txtMaxUSBDrives.Text = $State.Defaults.generalDefaults.MaxUSBDrives
|
||||
$State.Controls.chkBuildUSBDriveEnable.IsChecked = $State.Defaults.generalDefaults.BuildUSBDriveEnable
|
||||
$State.Controls.chkCompactOS.IsChecked = $State.Defaults.generalDefaults.CompactOS
|
||||
$State.Controls.chkUpdateADK.IsChecked = $State.Defaults.generalDefaults.UpdateADK
|
||||
$State.Controls.chkOptimize.IsChecked = $State.Defaults.generalDefaults.Optimize
|
||||
$State.Controls.chkAllowVHDXCaching.IsChecked = $State.Defaults.generalDefaults.AllowVHDXCaching
|
||||
$State.Controls.chkInjectUnattend.IsChecked = $State.Defaults.generalDefaults.InjectUnattend
|
||||
$State.Controls.chkCreateCaptureMedia.IsChecked = $State.Defaults.generalDefaults.CreateCaptureMedia
|
||||
$State.Controls.chkCreateDeploymentMedia.IsChecked = $State.Defaults.generalDefaults.CreateDeploymentMedia
|
||||
$State.Controls.chkAllowExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.AllowExternalHardDiskMedia
|
||||
@@ -248,6 +263,9 @@ function Initialize-UIDefaults {
|
||||
$State.Controls.usbSelectionPanel.Visibility = if ($State.Controls.chkSelectSpecificUSBDrives.IsChecked) { 'Visible' } else { 'Collapsed' }
|
||||
$State.Controls.chkSelectSpecificUSBDrives.IsEnabled = $State.Controls.chkBuildUSBDriveEnable.IsChecked
|
||||
$State.Controls.chkPromptExternalHardDiskMedia.IsEnabled = $State.Controls.chkAllowExternalHardDiskMedia.IsChecked
|
||||
$State.Controls.chkCopyAdditionalFFUFiles.IsChecked = $State.Defaults.generalDefaults.CopyAdditionalFFUFiles
|
||||
$State.Controls.additionalFFUPanel.Visibility = if ($State.Controls.chkCopyAdditionalFFUFiles.IsChecked) { 'Visible' } else { 'Collapsed' }
|
||||
Update-BitsPrioritySetting -State $State
|
||||
|
||||
# Hyper-V Settings defaults from General Defaults
|
||||
Initialize-VMSwitchData -State $State
|
||||
@@ -266,7 +284,6 @@ function Initialize-UIDefaults {
|
||||
$State.Controls.cmbWindowsLang.SelectedItem = $State.Defaults.windowsSettingsDefaults.DefaultWindowsLang
|
||||
$State.Controls.cmbMediaType.ItemsSource = $State.Defaults.windowsSettingsDefaults.AllowedMediaTypes
|
||||
$State.Controls.cmbMediaType.SelectedItem = $State.Defaults.windowsSettingsDefaults.DefaultMediaType
|
||||
$State.Controls.txtOptionalFeatures.Text = $State.Defaults.windowsSettingsDefaults.DefaultOptionalFeatures
|
||||
$State.Controls.txtProductKey.Text = $State.Defaults.windowsSettingsDefaults.DefaultProductKey
|
||||
|
||||
# Updates tab defaults from General Defaults
|
||||
@@ -303,15 +320,20 @@ function Initialize-UIDefaults {
|
||||
$State.Controls.chkInstallDrivers.IsChecked = $State.Defaults.generalDefaults.InstallDrivers
|
||||
$State.Controls.chkCopyDrivers.IsChecked = $State.Defaults.generalDefaults.CopyDrivers
|
||||
$State.Controls.chkCopyPEDrivers.IsChecked = $State.Defaults.generalDefaults.CopyPEDrivers
|
||||
$State.Controls.chkUseDriversAsPEDrivers.IsChecked = $State.Defaults.generalDefaults.UseDriversAsPEDrivers
|
||||
$State.Controls.chkCompressDriversToWIM.IsChecked = $State.Defaults.generalDefaults.CompressDownloadedDriversToWim
|
||||
|
||||
# Drivers tab UI logic
|
||||
$makeList = @('Microsoft', 'Dell', 'HP', 'Lenovo')
|
||||
foreach ($m in $makeList) {
|
||||
[void]$State.Controls.cmbMake.Items.Add($m)
|
||||
}
|
||||
if ($State.Controls.cmbMake.Items.Count -gt 0) {
|
||||
$State.Controls.cmbMake.SelectedIndex = 0
|
||||
if ($null -ne $State.Controls.cmbMake) {
|
||||
# Clear existing items to prevent duplication on re-initialization (e.g., after Restore Defaults)
|
||||
$State.Controls.cmbMake.Items.Clear()
|
||||
foreach ($m in $makeList) {
|
||||
[void]$State.Controls.cmbMake.Items.Add($m)
|
||||
}
|
||||
if ($State.Controls.cmbMake.Items.Count -gt 0) {
|
||||
$State.Controls.cmbMake.SelectedIndex = 0
|
||||
}
|
||||
}
|
||||
Update-DriverDownloadPanelVisibility -State $State
|
||||
|
||||
@@ -437,6 +459,75 @@ function Initialize-DynamicUIElements {
|
||||
$wingetGridView.Columns.Add($archColumn)
|
||||
# --- END: Add Architecture Column ---
|
||||
|
||||
# --- START: Add Additional Exit Codes Column ---
|
||||
$exitCodesColumn = New-Object System.Windows.Controls.GridViewColumn
|
||||
$exitCodesHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||
$exitCodesHeader.Tag = "AdditionalExitCodes"
|
||||
$exitCodesHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||
|
||||
$exitHeaderTextFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock])
|
||||
$exitHeaderTextFactory.SetValue([System.Windows.Controls.TextBlock]::TextProperty, "Additional Exit Codes")
|
||||
$exitHeaderTextFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, (New-Object System.Windows.Thickness(5, 2, 5, 2)))
|
||||
$exitHeaderTextFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
|
||||
|
||||
$exitHeaderTemplate = New-Object System.Windows.DataTemplate
|
||||
$exitHeaderTemplate.VisualTree = $exitHeaderTextFactory
|
||||
$exitCodesHeader.ContentTemplate = $exitHeaderTemplate
|
||||
|
||||
$exitCodesColumn.Header = $exitCodesHeader
|
||||
$exitCodesColumn.Width = 140
|
||||
|
||||
$exitCodesCellTemplate = New-Object System.Windows.DataTemplate
|
||||
$exitCodesTextBoxFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBox])
|
||||
$exitBinding = New-Object System.Windows.Data.Binding("AdditionalExitCodes")
|
||||
$exitBinding.Mode = [System.Windows.Data.BindingMode]::TwoWay
|
||||
$exitCodesTextBoxFactory.SetBinding([System.Windows.Controls.TextBox]::TextProperty, $exitBinding)
|
||||
$exitCodesCellTemplate.VisualTree = $exitCodesTextBoxFactory
|
||||
$exitCodesColumn.CellTemplate = $exitCodesCellTemplate
|
||||
$wingetGridView.Columns.Add($exitCodesColumn)
|
||||
# --- END: Add Additional Exit Codes Column ---
|
||||
|
||||
# --- START: Add Ignore Non-Zero Exit Codes Column ---
|
||||
$ignoreColumn = New-Object System.Windows.Controls.GridViewColumn
|
||||
$ignoreHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||
$ignoreHeader.Tag = "IgnoreNonZeroExitCodes"
|
||||
$ignoreHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||
|
||||
$ignoreHeaderTextFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock])
|
||||
$ignoreHeaderTextFactory.SetValue([System.Windows.Controls.TextBlock]::TextProperty, "Ignore Exit Codes")
|
||||
$ignoreHeaderTextFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, (New-Object System.Windows.Thickness(5, 2, 5, 2)))
|
||||
$ignoreHeaderTextFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
|
||||
|
||||
$ignoreHeaderTemplate = New-Object System.Windows.DataTemplate
|
||||
$ignoreHeaderTemplate.VisualTree = $ignoreHeaderTextFactory
|
||||
$ignoreHeader.ContentTemplate = $ignoreHeaderTemplate
|
||||
|
||||
$ignoreColumn.Header = $ignoreHeader
|
||||
$ignoreColumn.Width = 140
|
||||
|
||||
$ignoreCellTemplate = New-Object System.Windows.DataTemplate
|
||||
|
||||
# Center the checkbox in the cell
|
||||
$ignoreCellGridFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.Grid])
|
||||
$ignoreCellGridFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)
|
||||
$ignoreCellGridFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Stretch)
|
||||
|
||||
$ignoreCheckFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.CheckBox])
|
||||
$ignoreCheckFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Center)
|
||||
$ignoreCheckFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
|
||||
|
||||
$ignoreBinding = New-Object System.Windows.Data.Binding("IgnoreNonZeroExitCodes")
|
||||
$ignoreBinding.Mode = [System.Windows.Data.BindingMode]::TwoWay
|
||||
$ignoreCheckFactory.SetBinding([System.Windows.Controls.Primitives.ToggleButton]::IsCheckedProperty, $ignoreBinding)
|
||||
|
||||
# Build the visual tree: Grid -> CheckBox
|
||||
$ignoreCellGridFactory.AppendChild($ignoreCheckFactory)
|
||||
$ignoreCellTemplate.VisualTree = $ignoreCellGridFactory
|
||||
|
||||
$ignoreColumn.CellTemplate = $ignoreCellTemplate
|
||||
$wingetGridView.Columns.Add($ignoreColumn)
|
||||
# --- END: Add Ignore Non-Zero Exit Codes Column ---
|
||||
|
||||
Add-SortableColumn -gridView $wingetGridView -header "Download Status" -binding "DownloadStatus" -width 150 -headerHorizontalAlignment Left
|
||||
$State.Controls.lstWingetResults.AddHandler(
|
||||
[System.Windows.Controls.GridViewColumnHeader]::ClickEvent,
|
||||
@@ -454,6 +545,28 @@ function Initialize-DynamicUIElements {
|
||||
}
|
||||
)
|
||||
|
||||
# BYO Applications ListView setup
|
||||
$byoAppsGridView = New-Object System.Windows.Controls.GridView
|
||||
$State.Controls.lstApplications.View = $byoAppsGridView
|
||||
|
||||
# Set ListViewItem style to stretch content horizontally
|
||||
$itemStyleBYOApps = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
|
||||
$itemStyleBYOApps.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
|
||||
$State.Controls.lstApplications.ItemContainerStyle = $itemStyleBYOApps
|
||||
|
||||
# Add the selectable column
|
||||
Add-SelectableGridViewColumn -ListView $State.Controls.lstApplications -State $State -HeaderCheckBoxKeyName "chkSelectAllBYOApps" -ColumnWidth 60
|
||||
|
||||
# Add other sortable columns
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Priority" -binding "Priority" -width 60 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Name" -binding "Name" -width 150 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Command Line" -binding "CommandLine" -width 200 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Arguments" -binding "Arguments" -width 200 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Source" -binding "Source" -width 150 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Exit Codes" -binding "AdditionalExitCodes" -width 100 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Ignore Exit Codes" -binding "IgnoreExitCodes" -width 120 -headerHorizontalAlignment Left
|
||||
Add-SortableColumn -gridView $byoAppsGridView -header "Copy Status" -binding "CopyStatus" -width 150 -headerHorizontalAlignment Left
|
||||
|
||||
# Apps Script Variables ListView setup
|
||||
# Bind ItemsSource to the data list
|
||||
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
|
||||
@@ -542,14 +655,14 @@ function Initialize-DynamicUIElements {
|
||||
$modelColumn.Header = $modelHeader
|
||||
}
|
||||
|
||||
# Serial Number Column (index 1 in XAML, now 2)
|
||||
# Unique ID Column (index 1 in XAML, now 2)
|
||||
if ($usbDrivesGridView.Columns.Count -gt 2) {
|
||||
$serialColumn = $usbDrivesGridView.Columns[2]
|
||||
$serialHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||
$serialHeader.Content = "Serial Number"
|
||||
$serialHeader.Tag = "SerialNumber" # Property to sort by
|
||||
$serialHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||
$serialColumn.Header = $serialHeader
|
||||
$uniqueIdColumn = $usbDrivesGridView.Columns[2]
|
||||
$uniqueIdHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||
$uniqueIdHeader.Content = "Unique ID"
|
||||
$uniqueIdHeader.Tag = "UniqueId" # Property to sort by
|
||||
$uniqueIdHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||
$uniqueIdColumn.Header = $uniqueIdHeader
|
||||
}
|
||||
|
||||
# Size Column (index 2 in XAML, now 3)
|
||||
@@ -582,6 +695,51 @@ function Initialize-DynamicUIElements {
|
||||
else {
|
||||
WriteLog "Warning: lstUSBDrives.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
|
||||
}
|
||||
|
||||
# Additional FFUs ListView setup
|
||||
$itemStyleAdditionalFFUs = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
|
||||
$itemStyleAdditionalFFUs.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
|
||||
$State.Controls.lstAdditionalFFUs.ItemContainerStyle = $itemStyleAdditionalFFUs
|
||||
|
||||
if ($State.Controls.lstAdditionalFFUs.View -is [System.Windows.Controls.GridView]) {
|
||||
Add-SelectableGridViewColumn -ListView $State.Controls.lstAdditionalFFUs -State $State -HeaderCheckBoxKeyName "chkSelectAllAdditionalFFUs" -ColumnWidth 70
|
||||
|
||||
$additionalFFUsGridView = $State.Controls.lstAdditionalFFUs.View
|
||||
|
||||
if ($additionalFFUsGridView.Columns.Count -gt 1) {
|
||||
$nameColumn = $additionalFFUsGridView.Columns[1]
|
||||
$nameHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||
$nameHeader.Content = "FFU Name"
|
||||
$nameHeader.Tag = "Name"
|
||||
$nameHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||
$nameColumn.Header = $nameHeader
|
||||
}
|
||||
if ($additionalFFUsGridView.Columns.Count -gt 2) {
|
||||
$lastModColumn = $additionalFFUsGridView.Columns[2]
|
||||
$lastModHeader = New-Object System.Windows.Controls.GridViewColumnHeader
|
||||
$lastModHeader.Content = "Last Modified"
|
||||
$lastModHeader.Tag = "LastModified"
|
||||
$lastModHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
|
||||
$lastModColumn.Header = $lastModHeader
|
||||
}
|
||||
|
||||
$State.Controls.lstAdditionalFFUs.AddHandler(
|
||||
[System.Windows.Controls.GridViewColumnHeader]::ClickEvent,
|
||||
[System.Windows.RoutedEventHandler] {
|
||||
param($eventSource, $e)
|
||||
$header = $e.OriginalSource
|
||||
if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) {
|
||||
$listViewControl = $eventSource
|
||||
$window = [System.Windows.Window]::GetWindow($listViewControl)
|
||||
$uiStateFromWindowTag = $window.Tag
|
||||
Invoke-ListViewSort -listView $eventSource -property $header.Tag -State $uiStateFromWindowTag
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
else {
|
||||
WriteLog "Warning: lstAdditionalFFUs.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -220,6 +220,32 @@ function Invoke-ProgressUpdate {
|
||||
$ProgressQueue.Enqueue(@{ Identifier = $Identifier; Status = $Status })
|
||||
}
|
||||
|
||||
function Update-BitsPrioritySetting {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[pscustomobject]$State
|
||||
)
|
||||
|
||||
$combo = $State.Controls.cmbBitsPriority
|
||||
if ($null -eq $combo) {
|
||||
WriteLog "BITS priority control not available; skipping priority update."
|
||||
return
|
||||
}
|
||||
|
||||
$selectedPriority = $combo.SelectedItem
|
||||
if ([string]::IsNullOrWhiteSpace($selectedPriority)) {
|
||||
$selectedPriority = 'Normal'
|
||||
}
|
||||
|
||||
try {
|
||||
Set-BitsTransferPriority -Priority $selectedPriority
|
||||
WriteLog "BITS transfer priority set to $selectedPriority."
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to set BITS transfer priority: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Add a function to create a sortable list view
|
||||
function Add-SortableColumn {
|
||||
param(
|
||||
@@ -505,21 +531,29 @@ function Invoke-ListViewSort {
|
||||
[PSCustomObject]$State
|
||||
)
|
||||
|
||||
# Preserve any active CollectionView filter so sorting does not reset a filtered driver model list
|
||||
$existingFilter = $null
|
||||
$existingCollectionView = $null
|
||||
if ($null -ne $listView.ItemsSource) {
|
||||
$existingCollectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($listView.ItemsSource)
|
||||
if ($null -ne $existingCollectionView -and $existingCollectionView.Filter) {
|
||||
$existingFilter = $existingCollectionView.Filter
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure $State.Flags is a hashtable and contains the required sort properties
|
||||
if ($State.Flags -is [hashtable]) {
|
||||
if (-not $State.Flags.ContainsKey('lastSortProperty')) {
|
||||
$State.Flags['lastSortProperty'] = $null
|
||||
}
|
||||
if (-not $State.Flags.ContainsKey('lastSortAscending')) {
|
||||
$State.Flags['lastSortAscending'] = $true # Default to ascending
|
||||
$State.Flags['lastSortAscending'] = $true
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-Warning "Invoke-ListViewSort: \$State.Flags is not a hashtable or is null. Sort state may not work correctly."
|
||||
# Attempt to initialize if $State.Flags is null or unexpectedly not a hashtable,
|
||||
# though this might indicate a deeper issue with $State.Flags initialization.
|
||||
if ($null -eq $State.Flags) { $State.Flags = @{} }
|
||||
if ($State.Flags -is [hashtable]) { # Check again after potential initialization
|
||||
if ($State.Flags -is [hashtable]) {
|
||||
if (-not $State.Flags.ContainsKey('lastSortProperty')) { $State.Flags['lastSortProperty'] = $null }
|
||||
if (-not $State.Flags.ContainsKey('lastSortAscending')) { $State.Flags['lastSortAscending'] = $true }
|
||||
}
|
||||
@@ -534,10 +568,15 @@ function Invoke-ListViewSort {
|
||||
}
|
||||
$State.Flags.lastSortProperty = $property
|
||||
|
||||
# Get items from ItemsSource or Items collection
|
||||
# Build the set of items to sort, enumerating the filtered view if a filter is active
|
||||
$currentItemsSource = $listView.ItemsSource
|
||||
$itemsToSort = @()
|
||||
if ($null -ne $currentItemsSource) {
|
||||
if ($null -ne $existingCollectionView -and $null -ne $existingFilter) {
|
||||
foreach ($vItem in $existingCollectionView) {
|
||||
$itemsToSort += $vItem
|
||||
}
|
||||
}
|
||||
elseif ($null -ne $currentItemsSource) {
|
||||
$itemsToSort = @($currentItemsSource)
|
||||
}
|
||||
else {
|
||||
@@ -548,16 +587,17 @@ function Invoke-ListViewSort {
|
||||
return
|
||||
}
|
||||
|
||||
# Separate selected vs unselected for selected-first ordering
|
||||
$selectedItems = @($itemsToSort | Where-Object { $_.IsSelected })
|
||||
$unselectedItems = @($itemsToSort | Where-Object { -not $_.IsSelected })
|
||||
|
||||
# Define the primary sort criterion
|
||||
# Define primary sort criterion
|
||||
$primarySortDefinition = @{
|
||||
Expression = {
|
||||
$val = $_.$property
|
||||
if ($null -eq $val) { '' } else { $val }
|
||||
}
|
||||
Ascending = $State.Flags.lastSortAscending
|
||||
Ascending = $State.Flags.lastSortAscending
|
||||
}
|
||||
|
||||
$sortCriteria = [System.Collections.Generic.List[hashtable]]::new()
|
||||
@@ -579,11 +619,11 @@ function Invoke-ListViewSort {
|
||||
$secondarySortPropertyName = "Key"
|
||||
}
|
||||
else {
|
||||
# Default secondary sort for IsSelected or other properties
|
||||
$secondarySortPropertyName = "Key"
|
||||
}
|
||||
}
|
||||
|
||||
# Add secondary sort definition if applicable
|
||||
if ($null -ne $secondarySortPropertyName -and $property -ne $secondarySortPropertyName) {
|
||||
$itemsHaveSecondaryProperty = $false
|
||||
if ($unselectedItems.Count -gt 0) {
|
||||
@@ -598,35 +638,40 @@ function Invoke-ListViewSort {
|
||||
}
|
||||
|
||||
if ($itemsHaveSecondaryProperty) {
|
||||
# Create a scriptblock for the secondary sort expression dynamically
|
||||
$expressionScriptBlock = [scriptblock]::Create("`$_.$secondarySortPropertyName")
|
||||
|
||||
$secondarySortDefinition = @{
|
||||
Expression = {
|
||||
$val = Invoke-Command -ScriptBlock $expressionScriptBlock -ArgumentList $_
|
||||
if ($null -eq $val) { '' } else { $val }
|
||||
}
|
||||
Ascending = $true # Secondary sort always ascending
|
||||
Ascending = $true
|
||||
}
|
||||
$sortCriteria.Add($secondarySortDefinition)
|
||||
}
|
||||
}
|
||||
|
||||
# Sort unselected items by combined sort criteria
|
||||
$sortedUnselected = $unselectedItems | Sort-Object -Property $sortCriteria.ToArray()
|
||||
# Ensure $sortedUnselected is not null before attempting to add its range
|
||||
if ($null -eq $sortedUnselected) {
|
||||
$sortedUnselected = @()
|
||||
}
|
||||
|
||||
# Combine sorted items: selected items first, then sorted unselected items
|
||||
# Merge selected first, then sorted unselected
|
||||
$newSortedList = [System.Collections.Generic.List[object]]::new()
|
||||
$newSortedList.AddRange($selectedItems)
|
||||
$newSortedList.AddRange($sortedUnselected)
|
||||
|
||||
# Set the new sorted list as the ItemsSource
|
||||
# Try nulling out ItemsSource first to force a more complete refresh
|
||||
# Reset ItemsSource and assign sorted list
|
||||
$listView.ItemsSource = $null
|
||||
$listView.ItemsSource = $newSortedList.ToArray()
|
||||
|
||||
# Reapply preserved filter to maintain the user's filtered view
|
||||
if ($null -ne $existingFilter) {
|
||||
$newView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($listView.ItemsSource)
|
||||
if ($null -ne $newView) {
|
||||
$newView.Filter = $existingFilter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -126,7 +126,7 @@ $script:mctWindowsReleases = @(
|
||||
|
||||
$script:windowsVersionMap = @{
|
||||
10 = @("22H2")
|
||||
11 = @("22H2", "23H2", "24H2")
|
||||
11 = @("25H2", "24H2", "23H2", "22H2")
|
||||
2016 = @("1607") # Windows 10 LTSB 2016 & Server 2016
|
||||
2019 = @("1809") # Windows 10 LTSC 2019 & Server 2019
|
||||
# Note: Server 2016 and LTSB 2016 now share the key 2016, mapping to version "1607"
|
||||
@@ -268,10 +268,15 @@ function Get-AvailableWindowsVersions {
|
||||
# Logic for when an ISO is specified
|
||||
$result.Versions = $validVersions
|
||||
# Set default selection logic (e.g., latest for Win11)
|
||||
if ($SelectedRelease -eq 11 -and $validVersions -contains "24H2") {
|
||||
$result.DefaultVersion = "24H2"
|
||||
if ($SelectedRelease -eq 11) {
|
||||
if ($validVersions -contains "25H2") {
|
||||
$result.DefaultVersion = "25H2"
|
||||
}
|
||||
elseif ($validVersions -contains "24H2") {
|
||||
$result.DefaultVersion = "24H2"
|
||||
}
|
||||
}
|
||||
elseif ($validVersions.Count -gt 0) {
|
||||
if (-not $result.DefaultVersion -and $validVersions.Count -gt 0) {
|
||||
$result.DefaultVersion = $validVersions[0]
|
||||
}
|
||||
$result.IsEnabled = $true
|
||||
@@ -280,7 +285,7 @@ function Get-AvailableWindowsVersions {
|
||||
# Logic for when no ISO is specified (MCT scenario)
|
||||
switch ($SelectedRelease) {
|
||||
10 { $result.DefaultVersion = "22H2" }
|
||||
11 { $result.DefaultVersion = "24H2" }
|
||||
11 { $result.DefaultVersion = "25H2" }
|
||||
# Server versions typically require an ISO, but handle just in case
|
||||
2016 { $result.DefaultVersion = "1607" }
|
||||
2019 { $result.DefaultVersion = "1809" }
|
||||
@@ -515,7 +520,7 @@ function Update-WindowsArchCombo {
|
||||
}
|
||||
else {
|
||||
# Standard Windows 11
|
||||
if ($versionValue -eq '24H2') {
|
||||
if ($versionValue -in @('24H2', '25H2')) {
|
||||
$availableArchitectures = @('x64', 'arm64')
|
||||
}
|
||||
else {
|
||||
@@ -608,7 +613,6 @@ function UpdateOptionalFeaturesString {
|
||||
foreach ($entry in $State.Controls.featureCheckBoxes.GetEnumerator()) {
|
||||
if ($entry.Value.IsChecked) { $checkedFeatures += $entry.Key }
|
||||
}
|
||||
$State.Controls.txtOptionalFeatures.Text = $checkedFeatures -join ";"
|
||||
}
|
||||
function BuildFeaturesGrid {
|
||||
param (
|
||||
|
||||
@@ -98,10 +98,12 @@ function Save-WingetList {
|
||||
$appList = @{
|
||||
apps = @($selectedApps | ForEach-Object {
|
||||
[ordered]@{
|
||||
name = $_.Name
|
||||
id = $_.Id
|
||||
source = $_.Source.ToLower()
|
||||
architecture = $_.Architecture
|
||||
name = (ConvertTo-SafeName -Name $_.Name)
|
||||
id = $_.Id
|
||||
source = $_.Source.ToLower()
|
||||
architecture = $_.Architecture
|
||||
AdditionalExitCodes = if ($_.PSObject.Properties['AdditionalExitCodes']) { $_.AdditionalExitCodes } else { "" }
|
||||
IgnoreNonZeroExitCodes = if ($_.PSObject.Properties['IgnoreNonZeroExitCodes']) { [bool]$_.IgnoreNonZeroExitCodes } else { $false }
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -148,13 +150,15 @@ function Import-WingetList {
|
||||
foreach ($appInfo in $importedAppsData.apps) {
|
||||
$arch = if ($appInfo.source -eq 'msstore') { 'NA' } else { if ($appInfo.PSObject.Properties['architecture']) { $appInfo.architecture } else { $defaultArch } }
|
||||
$newAppListForItemsSource.Add([PSCustomObject]@{
|
||||
IsSelected = $true # Imported apps are marked as selected
|
||||
Name = $appInfo.name
|
||||
Id = $appInfo.id
|
||||
Version = "" # Will be populated when searching or if data exists
|
||||
Source = $appInfo.source
|
||||
Architecture = $arch
|
||||
DownloadStatus = ""
|
||||
IsSelected = $true # Imported apps are marked as selected
|
||||
Name = $appInfo.name
|
||||
Id = $appInfo.id
|
||||
Version = "" # Will be populated when searching or if data exists
|
||||
Source = $appInfo.source
|
||||
Architecture = $arch
|
||||
AdditionalExitCodes = if ($appInfo.PSObject.Properties['AdditionalExitCodes']) { $appInfo.AdditionalExitCodes } else { "" }
|
||||
IgnoreNonZeroExitCodes = if ($appInfo.PSObject.Properties['IgnoreNonZeroExitCodes']) { [bool]$appInfo.IgnoreNonZeroExitCodes } else { $false }
|
||||
DownloadStatus = ""
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -191,13 +195,15 @@ function Search-WingetPackagesPublic {
|
||||
$output = $results | ForEach-Object -Parallel {
|
||||
$arch = if ($_.Source -eq 'msstore') { 'NA' } else { $using:DefaultArchitecture }
|
||||
[PSCustomObject]@{
|
||||
IsSelected = [bool]$false
|
||||
Name = [string]$_.Name
|
||||
Id = [string]$_.Id
|
||||
Version = [string]$_.Version
|
||||
Source = [string]$_.Source
|
||||
Architecture = [string]$arch
|
||||
DownloadStatus = [string]::Empty
|
||||
IsSelected = [bool]$false
|
||||
Name = [string]$_.Name
|
||||
Id = [string]$_.Id
|
||||
Version = [string]$_.Version
|
||||
Source = [string]$_.Source
|
||||
Architecture = [string]$arch
|
||||
AdditionalExitCodes = [string]::Empty
|
||||
IgnoreNonZeroExitCodes = [bool]$false
|
||||
DownloadStatus = [string]::Empty
|
||||
}
|
||||
} -ThrottleLimit 20
|
||||
WriteLog "Winget search completed. Created $($output.Count) output objects."
|
||||
@@ -372,361 +378,9 @@ function Confirm-WingetInstallationUI {
|
||||
|
||||
return $result
|
||||
}
|
||||
# Function to handle downloading a winget application (Modified for ForEach-Object -Parallel)
|
||||
function Start-WingetAppDownloadTask {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[PSCustomObject]$ApplicationItemData, # Pass data, not the UI object
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$AppListJsonPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$AppsPath, # Pass necessary paths
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$OrchestrationPath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue # Add queue parameter
|
||||
)
|
||||
|
||||
$appName = $ApplicationItemData.Name
|
||||
$appId = $ApplicationItemData.Id
|
||||
$source = $ApplicationItemData.Source
|
||||
$status = "Checking..." # Initial local status
|
||||
$resultCode = -1 # Default to error/unknown
|
||||
|
||||
# Initial status update
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
|
||||
WriteLog "Starting download task for $($appName) with ID $($appId) from source $($source)."
|
||||
# WriteLog "Apps Path: $($AppsPath)"
|
||||
# WriteLog "AppList JSON Path: $($AppListJsonPath)"
|
||||
# WriteLog "Windows Architecture: $($ApplicationItemData.Architecture)"
|
||||
# WriteLog "Orchestration Path: $($OrchestrationPath)"
|
||||
|
||||
try {
|
||||
# Define paths
|
||||
$userAppListPath = Join-Path -Path $AppsPath -ChildPath "UserAppList.json"
|
||||
$appFound = $false # Flag to track if the app is found locally
|
||||
# WriteLog "UserAppList Path: $($userAppListPath)"
|
||||
# WriteLog "Checking for existing app in UserAppList.json and content folder."
|
||||
|
||||
# 1. Check UserAppList.json and content
|
||||
if (Test-Path -Path $userAppListPath) {
|
||||
# WriteLog "UserAppList.json found at $($userAppListPath). Checking for app entry."
|
||||
try {
|
||||
$userAppListContent = Get-Content -Path $userAppListPath -Raw | ConvertFrom-Json
|
||||
$userAppEntry = $userAppListContent | Where-Object { $_.Name -eq $appName }
|
||||
|
||||
if ($userAppEntry) {
|
||||
$appFolder = Join-Path -Path "$AppsPath\Win32" -ChildPath $appName
|
||||
if (Test-Path -Path $appFolder -PathType Container) {
|
||||
$folderSize = (Get-ChildItem -Path $appFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($folderSize -gt 1MB) {
|
||||
$appFound = $true
|
||||
$status = "Not Downloaded: App in $userAppListPath and found in $appFolder"
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
WriteLog "Found '$appName' in $userAppListPath and content exists in '$appFolder'."
|
||||
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 0 }
|
||||
}
|
||||
else {
|
||||
$appFound = $true
|
||||
$status = "App in '$userAppListPath' but content missing/small in '$appFolder'. Copy content or remove from UserAppList.json."
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
WriteLog $status
|
||||
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
|
||||
}
|
||||
}
|
||||
else {
|
||||
$appFound = $true
|
||||
$status = "App in '$userAppListPath' but content folder '$appFolder' not found. Copy content or remove from UserAppList.json."
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
WriteLog $status
|
||||
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Warning: Could not read or parse '$userAppListPath'. Error: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# 2. Check previous Winget download
|
||||
if (-not $appFound) {
|
||||
if (-not $appFound) {
|
||||
$wingetWin32jsonFile = Join-Path -Path $OrchestrationPath -ChildPath "WinGetWin32Apps.json"
|
||||
if (Test-Path -Path $wingetWin32jsonFile) {
|
||||
try {
|
||||
$wingetAppsJson = Get-Content -Path $wingetWin32jsonFile -Raw | ConvertFrom-Json
|
||||
# Check if app already exists in WinGetWin32Apps.json
|
||||
# For multi-arch apps, there might be entries like "AppName (x86)" and "AppName (x64)"
|
||||
$existingWin32Entries = @($wingetAppsJson | Where-Object {
|
||||
$_.Name -eq $appName -or
|
||||
$_.Name -eq "$appName (x86)" -or
|
||||
$_.Name -eq "$appName (x64)"
|
||||
})
|
||||
|
||||
if ($existingWin32Entries.Count -gt 0) {
|
||||
$appFolder = Join-Path -Path "$AppsPath\Win32" -ChildPath $appName
|
||||
$appContentFound = $false
|
||||
|
||||
# Check if it's a multi-arch app with subfolders
|
||||
if ($ApplicationItemData.Architecture -eq 'x86 x64') {
|
||||
$x86Folder = Join-Path -Path $appFolder -ChildPath "x86"
|
||||
$x64Folder = Join-Path -Path $appFolder -ChildPath "x64"
|
||||
|
||||
if ((Test-Path -Path $x86Folder -PathType Container) -and (Test-Path -Path $x64Folder -PathType Container)) {
|
||||
$x86Size = (Get-ChildItem -Path $x86Folder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
$x64Size = (Get-ChildItem -Path $x64Folder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
|
||||
if ($x86Size -gt 1MB -and $x64Size -gt 1MB) {
|
||||
$appContentFound = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
# Single architecture app
|
||||
if (Test-Path -Path $appFolder -PathType Container) {
|
||||
$folderSize = (Get-ChildItem -Path $appFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($folderSize -gt 1MB) {
|
||||
$appContentFound = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($appContentFound) {
|
||||
$appFound = $true
|
||||
$status = "Not Downloaded: App already in $wingetWin32jsonFile and found in $appFolder"
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
WriteLog "Found '$appName' in WinGetWin32Apps.json and content exists in '$appFolder'. Skipping download to prevent duplicate entry."
|
||||
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 0 }
|
||||
}
|
||||
else {
|
||||
# App entry exists in WinGetWin32Apps.json but folder is missing or incomplete
|
||||
$appFound = $true
|
||||
$status = "App in '$wingetWin32jsonFile' but content folder '$appFolder' not found or incomplete. Remove entry from WinGetWin32Apps.json or restore content."
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
WriteLog $status
|
||||
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Warning: Could not read or parse '$wingetWin32jsonFile'. Error: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Check MSStore folder
|
||||
if (-not $appFound -and (Test-Path -Path "$AppsPath\MSStore" -PathType Container)) {
|
||||
$appFolder = Join-Path -Path "$AppsPath\MSStore" -ChildPath $appName
|
||||
if (Test-Path -Path $appFolder -PathType Container) {
|
||||
$folderSize = (Get-ChildItem -Path $appFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($folderSize -gt 1MB) {
|
||||
$appFound = $true
|
||||
$status = "Already downloaded (MSStore)"
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
WriteLog "Found '$appName' content in '$appFolder'."
|
||||
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 3. If not found locally, add to AppList.json and download
|
||||
if (-not $appFound) {
|
||||
# Add to AppList.json
|
||||
$appListContent = $null
|
||||
$appListDir = Split-Path -Path $AppListJsonPath -Parent
|
||||
if (-not (Test-Path -Path $appListDir -PathType Container)) {
|
||||
New-Item -Path $appListDir -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
if (Test-Path -Path $AppListJsonPath) {
|
||||
try {
|
||||
$appListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
|
||||
if (-not $appListContent.PSObject.Properties['apps']) {
|
||||
$appListContent = @{ apps = @() }
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Warning: Could not read or parse '$AppListJsonPath'. Creating new structure. Error: $($_.Exception.Message)"
|
||||
$appListContent = @{ apps = @() }
|
||||
}
|
||||
}
|
||||
else {
|
||||
$appListContent = @{ apps = @() }
|
||||
}
|
||||
|
||||
$appExistsInAppList = $false
|
||||
if ($appListContent.apps) {
|
||||
foreach ($app in $appListContent.apps) {
|
||||
if ($app.id -eq $appId) {
|
||||
$appExistsInAppList = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $appExistsInAppList) {
|
||||
$newApp = @{ name = $appName; id = $appId; source = $source }
|
||||
if (-not ($appListContent.apps -is [array])) { $appListContent.apps = @() }
|
||||
$appListContent.apps += $newApp
|
||||
try {
|
||||
# Use a lock to prevent race conditions when writing to the same file
|
||||
$lockName = "AppListJsonLock"
|
||||
$lock = New-Object System.Threading.Mutex($false, $lockName)
|
||||
try {
|
||||
$lock.WaitOne() | Out-Null
|
||||
# Re-read content inside lock to ensure latest version
|
||||
if (Test-Path -Path $AppListJsonPath) {
|
||||
$currentAppListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
|
||||
if (-not ($currentAppListContent.apps | Where-Object { $_.id -eq $appId })) {
|
||||
$currentAppListContent.apps += $newApp
|
||||
$currentAppListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
|
||||
WriteLog "Added '$appName' to '$AppListJsonPath'."
|
||||
}
|
||||
else {
|
||||
WriteLog "'$appName' already exists in '$AppListJsonPath' (checked inside lock)."
|
||||
}
|
||||
}
|
||||
else {
|
||||
# File doesn't exist, write the initial content
|
||||
$appListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
|
||||
WriteLog "Created '$AppListJsonPath' and added '$appName'."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$lock.ReleaseMutex()
|
||||
$lock.Dispose()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Error saving '$AppListJsonPath'. Error: $($_.Exception.Message)"
|
||||
$status = "Failed to save AppList.json: $($_.Exception.Message)"
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
|
||||
}
|
||||
}
|
||||
else {
|
||||
WriteLog "'$appName' already exists in '$AppListJsonPath'."
|
||||
}
|
||||
|
||||
# Proceed with download
|
||||
$status = "Downloading..."
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
|
||||
# Ensure variables needed by Get-Application are accessible
|
||||
# (Assuming they are available via $using: scope or global scope from main script)
|
||||
# $global:AppsPath = $AppsPath # Potentially redundant
|
||||
# $global:WindowsArch = $ApplicationItemData.Architecture # Potentially redundant
|
||||
# $global:orchestrationPath = $OrchestrationPath # Potentially redundant"
|
||||
WriteLog "Orchestration Path: $($OrchestrationPath)"
|
||||
if (-not (Test-Path -Path $OrchestrationPath -PathType Container)) {
|
||||
New-Item -Path $OrchestrationPath -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
$win32Folder = Join-Path -Path $AppsPath -ChildPath "Win32"
|
||||
if ($source -eq "winget" -and -not (Test-Path -Path $win32Folder -PathType Container)) {
|
||||
New-Item -Path $win32Folder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
$storeAppsFolder = Join-Path -Path $AppsPath -ChildPath "MSStore"
|
||||
if ($source -eq "msstore" -and -not (Test-Path -Path $storeAppsFolder -PathType Container)) {
|
||||
New-Item -Path $storeAppsFolder -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
try {
|
||||
# Call Get-Application
|
||||
$resultCode = Get-Application -AppName $appName -AppId $appId -Source $source -AppsPath $AppsPath -WindowsArch $ApplicationItemData.Architecture -OrchestrationPath $OrchestrationPath -ErrorAction Stop
|
||||
|
||||
# Determine status based on result code
|
||||
switch ($resultCode) {
|
||||
0 { $status = "Downloaded successfully" }
|
||||
1 { $status = "Error: No app installers were found" }
|
||||
2 { $status = "Silent install switch could not be found. Did not download." }
|
||||
3 { $status = "Error: Publisher does not support download" }
|
||||
4 { $status = "Skipped: Use 'msstore' source instead." }
|
||||
default { $status = "Downloaded with status: $resultCode" } # Should not happen with current Get-Application
|
||||
}
|
||||
|
||||
# Remove app from AppList.json if silent install switch could not be found (resultCode 2)
|
||||
if ($resultCode -eq 2) {
|
||||
try {
|
||||
if (Test-Path -Path $AppListJsonPath) {
|
||||
$appListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
|
||||
if ($appListContent.apps) {
|
||||
$filteredApps = @($appListContent.apps | Where-Object { $_.id -ne $appId })
|
||||
$appListContent.apps = $filteredApps
|
||||
$appListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
|
||||
WriteLog "Removed '$appName' ($appId) from '$AppListJsonPath' due to missing silent install switch."
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to remove '$appName' from '$AppListJsonPath': $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$status = $_.Exception.Message
|
||||
WriteLog "Download error for $($appName): $($_.Exception.Message)"
|
||||
$resultCode = 1 # Indicate error
|
||||
# Enqueue error status
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
|
||||
# Remove app from AppList.json if publisher does not support download
|
||||
if ($_.Exception.Message -match "does not support downloads by the publisher") {
|
||||
try {
|
||||
if (Test-Path -Path $AppListJsonPath) {
|
||||
$appListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
|
||||
if ($appListContent.apps) {
|
||||
$filteredApps = @($appListContent.apps | Where-Object { $_.id -ne $appId })
|
||||
$appListContent.apps = $filteredApps
|
||||
$appListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
|
||||
WriteLog "Removed '$appName' ($appId) from '$AppListJsonPath' due to publisher download restriction."
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Failed to remove '$appName' from '$AppListJsonPath': $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
} # End if (-not $appFound)
|
||||
|
||||
}
|
||||
catch {
|
||||
$status = $_.Exception.Message
|
||||
WriteLog "Unexpected error in Start-WingetAppDownloadTask for $($appName): $($_.Exception.Message)"
|
||||
$resultCode = 1 # Indicate error
|
||||
# Enqueue error status
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
}
|
||||
finally {
|
||||
# Ensure status is not empty before returning
|
||||
if ([string]::IsNullOrEmpty($status)) {
|
||||
$status = "Unknown failure" # Provide a default error status
|
||||
WriteLog "Status was empty for $appName ($appId), setting to default error."
|
||||
if ($resultCode -ne 0 -and $resultCode -ne 1 -and $resultCode -ne 2) {
|
||||
$resultCode = -1 # Ensure resultCode reflects an error if it was empty
|
||||
}
|
||||
# Enqueue the final (error) status if it was previously empty
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
}
|
||||
elseif ($resultCode -ne 0) {
|
||||
# Enqueue the final status if it's an error (already set in try/catch)
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
}
|
||||
else {
|
||||
# Enqueue the final success status
|
||||
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
|
||||
}
|
||||
}
|
||||
|
||||
# Prepare the return object as a Hashtable
|
||||
$returnObject = @{ Id = $appId; Status = $status; ResultCode = $resultCode }
|
||||
|
||||
# Return the final status and result code as a Hashtable
|
||||
return $returnObject
|
||||
}
|
||||
# Note: Start-WingetAppDownloadTask has been moved to FFU.Common.Winget.psm1
|
||||
# to enable code reuse between UI and CLI builds. It is imported via the FFU.Common module.
|
||||
|
||||
function Invoke-WingetDownload {
|
||||
param(
|
||||
@@ -752,14 +406,53 @@ function Invoke-WingetDownload {
|
||||
$localOrchestrationPath = Join-Path -Path $State.Controls.txtApplicationPath.Text -ChildPath "Orchestration"
|
||||
|
||||
# Create hashtable for task-specific arguments to pass to Invoke-ParallelProcessing
|
||||
# UI downloads skip WinGetWin32Apps.json creation - it's generated at build time
|
||||
$taskArguments = @{
|
||||
AppsPath = $localAppsPath
|
||||
AppListJsonPath = $localAppListJsonPath
|
||||
OrchestrationPath = $localOrchestrationPath
|
||||
AppsPath = $localAppsPath
|
||||
AppListJsonPath = $localAppListJsonPath
|
||||
OrchestrationPath = $localOrchestrationPath
|
||||
WindowsArch = $localWindowsArch
|
||||
SkipWin32Json = $true
|
||||
}
|
||||
|
||||
# Select only necessary properties before passing to Invoke-ParallelProcessing
|
||||
$itemsToProcess = $selectedApps | Select-Object Name, Id, Source, Version, Architecture # Include Version and Architecture if needed
|
||||
|
||||
# Before downloading, persist the selected apps to AppList.json including exit-code fields (parity with Save-WingetList)
|
||||
try {
|
||||
# Determine AppList.json path; default if empty
|
||||
if ([string]::IsNullOrWhiteSpace($localAppListJsonPath)) {
|
||||
$localAppListJsonPath = Join-Path -Path $localAppsPath -ChildPath "AppList.json"
|
||||
$taskArguments.AppListJsonPath = $localAppListJsonPath
|
||||
WriteLog "AppListJsonPath was empty. Defaulting to: $localAppListJsonPath"
|
||||
}
|
||||
|
||||
# Build apps payload from current selection, preserving AdditionalExitCodes/IgnoreNonZeroExitCodes
|
||||
$appListToSave = @{
|
||||
apps = @($selectedApps | ForEach-Object {
|
||||
[ordered]@{
|
||||
name = (ConvertTo-SafeName -Name $_.Name)
|
||||
id = $_.Id
|
||||
source = $_.Source.ToLower()
|
||||
architecture = $_.Architecture
|
||||
AdditionalExitCodes = if ($_.PSObject.Properties['AdditionalExitCodes']) { $_.AdditionalExitCodes } else { "" }
|
||||
IgnoreNonZeroExitCodes = if ($_.PSObject.Properties['IgnoreNonZeroExitCodes']) { [bool]$_.IgnoreNonZeroExitCodes } else { $false }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
# Ensure destination directory exists and write AppList.json
|
||||
$destDir = Split-Path -Parent $localAppListJsonPath
|
||||
if (-not (Test-Path -LiteralPath $destDir)) {
|
||||
[void][System.IO.Directory]::CreateDirectory($destDir)
|
||||
}
|
||||
$appListToSave | ConvertTo-Json -Depth 10 | Set-Content -Path $localAppListJsonPath -Encoding UTF8
|
||||
WriteLog "Persisted AppList.json with selected apps and exit-code fields to: $localAppListJsonPath"
|
||||
}
|
||||
catch {
|
||||
WriteLog "Warning: Failed to persist AppList.json prior to download. Error: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Invoke the centralized parallel processing function
|
||||
# Pass task type and task-specific arguments
|
||||
Invoke-ParallelProcessing -ItemsToProcess $itemsToProcess `
|
||||
|
||||
@@ -117,6 +117,8 @@ function Get-GeneralDefaults {
|
||||
ShareName = "FFUCaptureShare"
|
||||
Username = "ffu_user"
|
||||
Threads = 5
|
||||
BitsPriority = 'Normal'
|
||||
MaxUSBDrives = 5
|
||||
BuildUSBDriveEnable = $false
|
||||
CompactOS = $true
|
||||
Optimize = $true
|
||||
@@ -127,9 +129,11 @@ function Get-GeneralDefaults {
|
||||
AllowExternalHardDiskMedia = $false
|
||||
PromptExternalHardDiskMedia = $true
|
||||
SelectSpecificUSBDrives = $false
|
||||
CopyAdditionalFFUFiles = $false
|
||||
CopyAutopilot = $false
|
||||
CopyUnattend = $false
|
||||
CopyPPKG = $false
|
||||
InjectUnattend = $false
|
||||
CleanupAppsISO = $true
|
||||
CleanupCaptureISO = $true
|
||||
CleanupDeployISO = $true
|
||||
@@ -139,7 +143,7 @@ function Get-GeneralDefaults {
|
||||
RemoveUpdates = $false
|
||||
# Hyper-V Settings Defaults
|
||||
VMHostIPAddress = ""
|
||||
DiskSizeGB = 30
|
||||
DiskSizeGB = 50
|
||||
MemoryGB = 4
|
||||
Processors = 4
|
||||
VMLocation = $vmLocationPath
|
||||
@@ -173,25 +177,101 @@ function Get-GeneralDefaults {
|
||||
InstallDrivers = $false
|
||||
CopyDrivers = $false
|
||||
CopyPEDrivers = $false
|
||||
UseDriversAsPEDrivers = $false
|
||||
UpdateADK = $true
|
||||
CompressDownloadedDriversToWim = $false
|
||||
}
|
||||
}
|
||||
|
||||
# Function to get USB Drives (Moved from BuildFFUVM_UI.ps1)
|
||||
# Uses Get-Disk to retrieve UniqueId which is more reliable than SerialNumber
|
||||
# UniqueId is trimmed to remove the machine name suffix (characters after colon)
|
||||
function Get-USBDrives {
|
||||
Get-WmiObject Win32_DiskDrive | Where-Object {
|
||||
($_.MediaType -eq 'Removable Media' -or $_.MediaType -eq 'External hard disk media')
|
||||
} | ForEach-Object {
|
||||
$size = [math]::Round($_.Size / 1GB, 2)
|
||||
$serialNumber = if ($_.SerialNumber) { $_.SerialNumber.Trim() } else { "N/A" }
|
||||
@{
|
||||
IsSelected = $false
|
||||
Model = $_.Model.Trim()
|
||||
SerialNumber = $serialNumber
|
||||
Size = $size
|
||||
DriveIndex = $_.Index
|
||||
# Get the disk using the index to retrieve UniqueId
|
||||
$disk = Get-Disk -Number $_.Index -ErrorAction SilentlyContinue
|
||||
# Trim the machine name suffix (everything after the colon) from UniqueId
|
||||
$uniqueId = if ($disk -and $disk.UniqueId) {
|
||||
$rawId = $disk.UniqueId
|
||||
if ($rawId -match ':') {
|
||||
$rawId.Split(':')[0]
|
||||
}
|
||||
else {
|
||||
$rawId
|
||||
}
|
||||
}
|
||||
else {
|
||||
"N/A"
|
||||
}
|
||||
@{
|
||||
IsSelected = $false
|
||||
Model = $_.Model.Trim()
|
||||
UniqueId = $uniqueId
|
||||
Size = $size
|
||||
DriveIndex = $_.Index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Returns a list of FFU files from the provided folder with selection metadata
|
||||
function Get-FFUFiles {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
if (-not (Test-Path -Path $Path)) {
|
||||
return @()
|
||||
}
|
||||
Get-ChildItem -Path $Path -Filter '*.ffu' -File -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
[PSCustomObject]@{
|
||||
IsSelected = $false
|
||||
Name = $_.Name
|
||||
LastModified = $_.LastWriteTime
|
||||
FullName = $_.FullName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Helper: Populate Additional FFU List from the capture folder
|
||||
function Update-AdditionalFFUList {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[PSCustomObject]$State
|
||||
)
|
||||
try {
|
||||
$ffuFolder = $State.Controls.txtFFUCaptureLocation.Text
|
||||
$listView = $State.Controls.lstAdditionalFFUs
|
||||
if ($null -eq $listView) { return }
|
||||
$listView.Items.Clear()
|
||||
if ([string]::IsNullOrWhiteSpace($ffuFolder) -or -not (Test-Path -Path $ffuFolder)) {
|
||||
WriteLog "Additional FFUs: Capture folder not set or not found: $ffuFolder"
|
||||
}
|
||||
else {
|
||||
$items = Get-ChildItem -Path $ffuFolder -Filter '*.ffu' -File -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
ForEach-Object {
|
||||
[PSCustomObject]@{
|
||||
IsSelected = $false
|
||||
Name = $_.Name
|
||||
LastModified = $_.LastWriteTime
|
||||
FullName = $_.FullName
|
||||
}
|
||||
}
|
||||
foreach ($it in $items) { $listView.Items.Add($it) | Out-Null }
|
||||
WriteLog "Additional FFUs: Found $($listView.Items.Count) FFU files in $ffuFolder."
|
||||
}
|
||||
$headerChk = $State.Controls.chkSelectAllAdditionalFFUs
|
||||
if ($null -ne $headerChk) {
|
||||
Update-SelectAllHeaderCheckBoxState -ListView $listView -HeaderCheckBox $headerChk
|
||||
}
|
||||
}
|
||||
catch {
|
||||
WriteLog "Update-AdditionalFFUList error: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +282,11 @@ function Update-ApplicationPanelVisibility {
|
||||
[string]$TriggeringControlName # Optional: to know which control initiated the change
|
||||
)
|
||||
|
||||
# If BYO Apps, Winget Apps, or Define Apps Script Variables is checked, force Install Apps to be checked
|
||||
if ($State.Controls.chkBringYourOwnApps.IsChecked -or $State.Controls.chkInstallWingetApps.IsChecked -or $State.Controls.chkDefineAppsScriptVariables.IsChecked) {
|
||||
$State.Controls.chkInstallApps.IsChecked = $true
|
||||
}
|
||||
|
||||
$installAppsChecked = $State.Controls.chkInstallApps.IsChecked
|
||||
|
||||
# If the main 'Install Apps' is unchecked, everything below it gets hidden and reset.
|
||||
@@ -268,6 +353,12 @@ function Update-InstallAppsState {
|
||||
# If no state was saved (e.g., it was never forced), ensure it's unchecked.
|
||||
$installAppsChk.IsChecked = $false
|
||||
}
|
||||
|
||||
# If BYO, Winget, or Apps Script Variables are checked, it overrides the restoration and keeps Install Apps checked.
|
||||
if ($State.Controls.chkBringYourOwnApps.IsChecked -or $State.Controls.chkInstallWingetApps.IsChecked -or $State.Controls.chkDefineAppsScriptVariables.IsChecked) {
|
||||
$installAppsChk.IsChecked = $true
|
||||
}
|
||||
|
||||
$installAppsChk.IsEnabled = $true
|
||||
}
|
||||
}
|
||||
@@ -279,11 +370,14 @@ function Update-DriverCheckboxStates {
|
||||
$installDriversChk = $State.Controls.chkInstallDrivers
|
||||
$copyDriversChk = $State.Controls.chkCopyDrivers
|
||||
$compressWimChk = $State.Controls.chkCompressDriversToWIM
|
||||
$copyPEDriversChk = $State.Controls.chkCopyPEDrivers
|
||||
$useDriversAsPeChk = $State.Controls.chkUseDriversAsPEDrivers
|
||||
|
||||
# Default to enabled, then apply disabling rules
|
||||
$installDriversChk.IsEnabled = $true
|
||||
$copyDriversChk.IsEnabled = $true
|
||||
$compressWimChk.IsEnabled = $true
|
||||
$copyPEDriversChk.IsEnabled = $true
|
||||
|
||||
if ($installDriversChk.IsChecked) {
|
||||
$copyDriversChk.IsEnabled = $false
|
||||
@@ -297,6 +391,16 @@ function Update-DriverCheckboxStates {
|
||||
if ($compressWimChk.IsChecked) {
|
||||
$installDriversChk.IsEnabled = $false
|
||||
}
|
||||
|
||||
# Sub-option visibility logic: only show UseDriversAsPEDrivers when CopyPEDrivers is checked
|
||||
if ($copyPEDriversChk.IsChecked) {
|
||||
$useDriversAsPeChk.Visibility = 'Visible'
|
||||
}
|
||||
else {
|
||||
# Parent unchecked: hide and clear sub-option
|
||||
$useDriversAsPeChk.IsChecked = $false
|
||||
$useDriversAsPeChk.Visibility = 'Collapsed'
|
||||
}
|
||||
}
|
||||
|
||||
# Function to manage the visibility of Office UI panels
|
||||
|
||||
@@ -28,7 +28,7 @@ function Write-ProgressLog {
|
||||
}
|
||||
Function Get-RemovableDrive {
|
||||
writelog "Get information for all removable drives"
|
||||
$USBDrives = Get-WmiObject Win32_DiskDrive | Where-Object {$_.MediaType -eq "Removable media"}
|
||||
$USBDrives = Get-WmiObject Win32_DiskDrive | Where-Object {$_.MediaType -eq "Removable media" -or $_.MediaType -eq "External hard disk media"}
|
||||
If($USBDrives -and ($null -eq $USBDrives.count)) {
|
||||
$USBDrivesCount = 1
|
||||
} else {
|
||||
@@ -62,6 +62,7 @@ Function Build-DeploymentUSB{
|
||||
$ScriptBlock = {
|
||||
param($DriveNumber)
|
||||
Clear-Disk -Number $DriveNumber -RemoveData -RemoveOEM -Confirm:$false
|
||||
Initialize-Disk -Number $DriveNumber
|
||||
$Disk = Get-Disk -Number $DriveNumber
|
||||
$PartitionStyle = $Disk.PartitionStyle
|
||||
if($PartitionStyle -ne 'MBR'){
|
||||
@@ -120,13 +121,6 @@ $Destination = $Drive + ":\"
|
||||
Start-Job -ScriptBlock $jobScriptBlock -ArgumentList $ImagesPath, $Destination | Out-Null
|
||||
}
|
||||
}
|
||||
if(!($Images)){
|
||||
foreach ($Drive in $DeployDrives) {
|
||||
WriteLog "Create images directory"
|
||||
$drivepath = $Drive + ":\"
|
||||
New-Item -Path "$drivepath" -Name Images -ItemType Directory -Force -Confirm: $false | Out-Null
|
||||
}
|
||||
}
|
||||
if($Drivers){
|
||||
writelog "Copying driver files to all drives labeled deploy concurrently"
|
||||
foreach ($Drive in $DeployDrives) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -20,7 +20,7 @@ The Full-Flash update (FFU) process can automatically download the latest releas
|
||||
|
||||
# Updates
|
||||
|
||||
2507.1 has been released to preview! This is a major update that brings a new user interface to preview.
|
||||
2509.1 has been released to preview! This is a major update that brings a new user interface to preview.
|
||||
|
||||
Docs are coming, but will take a bit to write them. The youtube video is a must watch for a complete demo on how to use the UI and the changes made to apps (InstallAppsAndSysprep.cmd is gone) and drivers. I'll be recording a more formalized deep dive with slides that go a bit deeper into how things work, but the UI walkthrough should get most people going.
|
||||
|
||||
@@ -37,36 +37,69 @@ Here's a detailed overview of the new UI process.
|
||||
[](https://youtu.be/oozG1aVcg9M "Reimage Windows Fast with FFU Builder 2507.1 Preview")
|
||||
|
||||
Chapters:
|
||||
|
||||
[00:00](https://www.youtube.com/watch?v=oozG1aVcg9M&t=0s) Begin
|
||||
|
||||
[01:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=67s) Prereqs
|
||||
|
||||
[06:32](https://www.youtube.com/watch?v=oozG1aVcg9M&t=392s) Demo Begins
|
||||
|
||||
[07:16](https://www.youtube.com/watch?v=oozG1aVcg9M&t=436s) Running the BuildFFUVM_UI.ps1 script
|
||||
|
||||
[08:15](https://www.youtube.com/watch?v=oozG1aVcg9M&t=495s) UI Overview
|
||||
|
||||
[10:13](https://www.youtube.com/watch?v=oozG1aVcg9M&t=613s) Hyper-V Settings
|
||||
|
||||
[16:04](https://www.youtube.com/watch?v=oozG1aVcg9M&t=964s) Windows Settings
|
||||
|
||||
[22:35](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1355s) Updates
|
||||
|
||||
[24:49](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1489s) Applications
|
||||
|
||||
[29:39](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1779s) Install Winget Applications
|
||||
|
||||
[45:29](https://www.youtube.com/watch?v=oozG1aVcg9M&t=2729s) Bring Your Own Applications
|
||||
|
||||
[54:14](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3254s) Apps Script Variables
|
||||
|
||||
[57:43](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3463s) M365 Apps/Office
|
||||
|
||||
[59:01](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3541s) Drivers
|
||||
|
||||
[01:01:22](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3682s) Drivers.json example
|
||||
|
||||
[01:02:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3727s) DriverMapping.json explanation
|
||||
|
||||
[01:06:08](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3968s) Driver WIM Compression
|
||||
|
||||
[01:10:50](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4250s) Build
|
||||
|
||||
[01:12:41](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4361s) Build USB Drive
|
||||
|
||||
[01:20:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4807s) Monitor
|
||||
|
||||
[01:20:32](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4832s) Setting up the Demo Build
|
||||
|
||||
[01:24:10](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5050s) Save/Load Config Files
|
||||
|
||||
[01:25:11](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5111s) Kicking off the Demo Build/Going over the monitor tab
|
||||
|
||||
[01:32:26](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5546s) Demoing the new FFU Builder Orchestrator
|
||||
|
||||
[01:35:25](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5725s) New captureffu.ps1 console output
|
||||
|
||||
[01:42:29](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6149s) Demo Build Complete
|
||||
|
||||
[01:42:42](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6162s) How to configure a VM to test your newly built FFU
|
||||
|
||||
[01:48:58](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6538s) The moment of truth: What does the new deployment experience look like?
|
||||
|
||||
[01:53:13](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6793s) How to bypass OOBE using a provisioning package
|
||||
|
||||
[01:55:49](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6949s) Preview Focus Areas
|
||||
|
||||
[02:04:04](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7444s) Known Issues/Things to fix before GA
|
||||
|
||||
[02:05:38](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7538s) Providing Feedback
|
||||
|
||||
[02:06:43](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7603s) Thank you
|
||||
|
||||
Reference in New Issue
Block a user