Compare commits

...

59 Commits

Author SHA1 Message Date
rbalsleyMSFT ceeabd1ebc Add changelog for 2512.1 UI Preview release
Documents new features including shared cleanup module, Windows Security
Platform install delay, persistent KB folder for updates, and CU download
skipping when ESD is current.

Fixes WingetWin32Apps.json creation bug for pre-downloaded applications.
2026-01-05 12:34:58 -08:00
rbalsleyMSFT 15149ffa0b Bumps version to 2512.1Preview
Updates version string in BuildFFUVM.ps1 and ApplyFFU.ps1
from 2511.2Preview to 2512.1Preview for the new release.
2026-01-05 12:33:34 -08:00
rbalsleyMSFT 2f180747b7 Bumps version to 2511.2Preview
Updates version string in BuildFFUVM.ps1 and ApplyFFU.ps1
to reflect the new preview release.
2026-01-05 12:10:35 -08:00
rbalsleyMSFT 25fe90253c Regenerate Win32 app JSON for pre-downloaded content
Ensures CLI builds properly register silent install commands even when
app content already exists and download is skipped.
2026-01-05 12:07:16 -08:00
rbalsleyMSFT 86d122aacf Skips CU downloads when ESD version is current or newer
Extracts ESD metadata resolution into a separate function to enable
version comparison before downloading cumulative updates.

Parses Windows version from both ESD filenames and KB article search
results to determine if the ESD already contains the latest updates,
avoiding redundant downloads and installations.

Improves VHDX cache matching by tracking update names that were skipped
due to version matching, ensuring cached images are correctly reused
when updates are already integrated in the base image.

Adds check to skip downloading updates that already exist locally.

Removes prior behavior of always removing the KB folder. The `$RemoveUpdates` parameter now controls whether the KB folder is removed or not. This change was made due to the size of the Windows 11 CU being > 3-4GB. This will reduce bandwidth, however will require setting `$RemoveUpdates` to true to cleanup old update files.
2025-12-20 15:52:28 -08:00
rbalsleyMSFT 9737d5c930 Centralizes KB path cleanup into common cleanup module
Removes duplicated KB path cleanup logic scattered across multiple locations in the build script and consolidates it into the shared cleanup module.

Adds KBPath parameter to the cleanup function and handles removal of Windows/.NET cumulative update downloads when RemoveUpdates flag is set.

Improves maintainability by eliminating redundant cleanup code and ensures consistent cleanup behavior across different build scenarios including standard builds, VHDX caching, and restore defaults operations.
2025-12-16 21:18:42 -08:00
rbalsleyMSFT c6088d91fa Add 30 second delay to allow for Windows Security Platform to install in Update-Defender.ps1 2025-12-15 16:21:27 -08:00
rbalsleyMSFT 15fdf77ce4 Refactors cleanup logic into 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.

Enhances driver cleanup to preserve configuration files (Drivers.json and DriverMapping.json) while removing other contents, preventing loss of driver mapping data.

Improves maintainability by centralizing cleanup operations and reducing code duplication, making future updates easier to implement consistently.
2025-12-15 16:20:01 -08:00
rbalsleyMSFT f7f001ac2e Update ChangeLog for version 2511.1
Added detailed changelog for version 2511.1, including major updates to driver handling, new hardware support, and various fixes.
2025-12-09 18:00:10 -08:00
rbalsleyMSFT 3524d02047 Adds interactive disk selection for multi-disk systems
Improves disk selection logic to handle systems with multiple fixed disks by prompting the user to choose the target disk when more than one candidate is detected.

Refactors Get-HardDrive to return an array of disk candidates instead of a single disk object with extracted properties, enabling the main script to present options and validate user selection.

Displays disk information in a formatted table showing disk number, size, sector size, bus type, and model to help users make informed decisions.

Validates user input to ensure only available disk numbers can be selected, preventing deployment errors on multi-disk configurations.

Maintains single-disk auto-selection behavior for systems with only one fixed disk, preserving the original user experience for common scenarios.
2025-12-08 18:24:20 -08:00
rbalsleyMSFT 7948201e18 Replace web request with BITS transfer for ESD downloads
Switches from using Invoke-WebRequest to Start-BitsTransferWithRetry for downloading Windows ESD files. This provides better reliability and automatic retry handling for large file downloads.

Also removes trailing blank lines for cleaner formatting.
2025-11-29 18:44:34 -08:00
rbalsleyMSFT 8d84137a27 Refactor app download logic for code reuse between UI and CLI
Moves the `Start-WingetAppDownloadTask` function from the UI module to the common module to enable parallel app downloads in both CLI and UI build paths. This eliminates code duplication and ensures consistent download behavior across build modes.

Updates the `Get-Apps` function to leverage parallel processing instead of sequential iteration, improving performance when downloading multiple applications. Adds support for `LogFilePath` and `ThrottleLimit` parameters to control logging and concurrency.

Introduces a `SkipWin32Json` parameter to differentiate behavior between UI mode (skips JSON generation) and CLI mode (creates JSON for installation). This allows the same download task to work correctly in both contexts.

Updates all callers of `Get-Apps` to pass the new parameters, ensuring proper logging and parallel execution configuration across the build pipeline.
2025-11-29 16:29:52 -08:00
rbalsleyMSFT 2273cffbc2 Add Threads parameter to control parallel driver download throttling
Introduces a new `Threads` parameter that allows users to configure the concurrency level for parallel driver downloads within the script. This parameter defaults to 5, matching the existing UI behavior, and accepts values between 1 and 64 to provide flexible control over resource utilization.

The parameter is now passed to the parallel processing function via the `ThrottleLimit` argument, enabling users to optimize performance based on their system capabilities and network conditions.
2025-11-29 13:57:54 -08:00
rbalsleyMSFT 63ef35a005 Fix USB drive detection logic for empty arrays
Replaces null comparison with count check to properly handle empty arrays. The previous condition would incorrectly pass when the array exists but contains no elements, potentially causing the function to proceed without a valid USB drive.
2025-11-24 17:41:36 -08:00
rbalsleyMSFT 417be73b23 Replace SerialNumber with UniqueId for USB drive identification
Switches USB drive matching logic from relying on SerialNumber to using UniqueId, which provides more reliable and consistent device identification across different systems.

Updates the Get-USBDrive function to retrieve UniqueId via Get-Disk and trims the machine name suffix (characters after colon) for consistent matching. The new approach first filters candidates by model and media type, then validates each candidate against the configured UniqueId.

Reflects this change across the UI layer by updating column headers, configuration handling, and drive enumeration functions to use UniqueId instead of SerialNumber for saving and loading USB drive selections.
2025-11-24 16:46:57 -08:00
rbalsleyMSFT 18367219c8 Fix xcopy command to handle PPKG filenames with spaces
Adds proper quoting around file paths in the xcopy invocation to support PPKG filenames containing spaces. Without quoted paths, xcopy fails when filenames include whitespace characters.
2025-11-24 15:32:02 -08:00
rbalsleyMSFT 2a77cf1a02 Add MSI path quoting to handle spaces in msiexec arguments
Introduces a new function to detect and properly quote unquoted MSI file paths in msiexec command arguments. This prevents installation failures when MSI paths contain spaces, as the Windows installer requires quoted paths to correctly parse arguments with whitespace.

The solution uses pattern matching to identify `/i` arguments followed by unquoted `.msi` file paths and automatically wraps them in double quotes. This runs automatically during application installation when msiexec is detected, ensuring reliable installations regardless of path formatting in the configuration.
2025-11-24 14:58:00 -08:00
rbalsleyMSFT 41b0f7d742 Escape quotes in MSI installation example tooltip
Updates the tooltip text for application arguments to properly escape quotes around the MSI file path in the example command. This ensures the example is syntactically correct and prevents issues when users copy the suggested format for MSI installations.
2025-11-24 14:03:11 -08:00
rbalsleyMSFT 24c81c234f Remove redundant Images directory creation logic 2025-11-21 22:59:46 -08:00
rbalsleyMSFT 4833d9f00d Merge branch 'UI_2510' of https://github.com/rbalsleyMSFT/FFU into UI_2510 2025-11-21 17:26:52 -08:00
rbalsleyMSFT 37e3497522 Bump version to 2511.1Preview
Please read - potential breaking change with Drivers you'll want to be aware of - https://github.com/rbalsleyMSFT/FFU/discussions/350
2025-11-21 17:25:23 -08:00
rbalsleyMSFT 8229aa73fe Add BITS transfer priority configuration support
Introduces a new parameter 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, command-line parameter, or environment variable, with Normal as the default.

Updates the BITS transfer retry logic to respect the configured priority instead of hardcoding Normal priority, and fixes minor code formatting inconsistencies.
2025-11-21 14:04:52 -08:00
rbalsleyMSFT e67590d0a1 Add HTTP fallback for BITS transfer network authentication errors
Enhances the BITS transfer retry logic to detect when the current session lacks network authentication and automatically falls back to Invoke-WebRequest for completion.

Detects the "not logged on to the network" error condition by checking both the HResult code (0x800704DD) and error message patterns, then triggers an HTTP-based fallback mechanism to retry the download with remaining attempts.

This ensures downloads can succeed even when BITS fails due to authentication issues, improving reliability of the transfer function in environments with network authentication requirements.
2025-11-20 18:02:59 -08:00
rbalsleyMSFT 33f0608d84 Remove redundant progress status update in driver package processing
Eliminates an unnecessary progress queue update that occurs immediately before downloading package XML, as this status message duplicates information already provided in the subsequent log message.

This simplifies the progress reporting flow and reduces redundant communication to the progress queue without impacting user-facing functionality.
2025-11-20 17:19:20 -08:00
rbalsleyMSFT 3d1a586c73 Refactor driver cleanup logic into reusable helper function
Extracts duplicate folder removal code across multiple driver modules (Dell, HP, Lenovo, Microsoft) into a centralized `Remove-DriverModelFolder` helper function. This new utility includes safety checks to prevent accidental deletion of the drivers root directory or paths outside the drivers folder hierarchy.

Also moves model path construction in the Microsoft driver module earlier in the function to ensure consistent path handling before any operations that might fail.

Benefits improved maintainability, reduces code duplication, and adds protective safeguards for destructive operations during error handling.
2025-11-20 17:05:31 -08:00
rbalsleyMSFT 7d36253668 Standardize driver compression status reporting across vendors
Updates compression workflow to use consistent status messaging and better error handling across all driver vendor modules (Dell, HP, Lenovo, Microsoft).

Changes improve status tracking by:
- Standardizing compression success status to "Compression successful" instead of vendor-specific messages
- Introducing relative path variables to reduce code duplication and improve maintainability
- Suppressing command output by piping to `$null` for cleaner execution
- Adding explicit failure state in exception handlers to ensure success property reflects actual outcome
- Updating parallel processing logic to recognize the new standardized compression status

These modifications ensure consistent behavior across vendors and make the parallel processing coordinator aware of all compression completion states.
2025-11-20 14:32:54 -08:00
rbalsleyMSFT e076e9f4ca Add option to skip driver installation 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.

Introduces a skip flag and restructures the selection validation logic to accept either a valid driver selection or a skip command. When skipped, driver-related variables are set to null and appropriate logging messages are generated.
2025-11-19 23:58:29 -08:00
rbalsleyMSFT 44aa4d3a32 Add SUBST drive mapping for driver injection to handle long paths
Introduces helper functions to create and remove SUBST drive mappings for driver folders, addressing potential issues with long file paths during driver injection.

The new implementation:
- Adds `Get-AvailableDriveLetter` to find an unused drive letter
- Adds `New-DriverSubstMapping` to create a virtual drive mapping to the source folder
- Adds `Remove-DriverSubstMapping` to clean up the mapping after use
- Updates the driver injection workflow to use the mapped drive path instead of the original path
- Wraps the operation in try-catch-finally to ensure cleanup occurs even on errors

This approach mitigates issues with excessively long driver folder paths that could exceed command-line limits or cause path-related failures during DISM operations.
2025-11-19 23:08:39 -08:00
rbalsleyMSFT a1d08b6fa4 Simplifies driver folder discovery logic
Removes hardcoded manufacturer name list and treats all immediate child folders under the Drivers path as potential manufacturer containers. This supports both known vendors (Dell, HP, Lenovo, Microsoft) and unknown/custom manufacturers without requiring code changes.

Adds support for manufacturer folders that directly contain installable driver content (no model subfolders), enabling flatter folder structures when appropriate.

Improves code readability by consolidating the folder processing logic into a single loop that checks for model subfolders first, then falls back to treating the manufacturer folder itself as a driver source if it contains installable content.
2025-11-18 23:48:07 -08:00
rbalsleyMSFT fc4a71f7e1 Restricts manufacturer folder detection to known OEMs
Improves driver folder discovery logic by explicitly defining recognized manufacturer folder names (Dell, HP, Lenovo, Microsoft) instead of treating any root-level folder with children as a manufacturer folder.

Prevents incorrect categorization of non-manufacturer folders at the root level and ensures only legitimate OEM folders are processed for model-specific driver packages.

Refactors control flow to eliminate else block, improving code clarity and maintainability.
2025-11-18 20:03:22 -08:00
rbalsleyMSFT 9a59b9fea4 Removes Path column from driver source table display
Simplifies the driver source selection table by removing the full Path column from the formatted output.

The RelativePath column already provides sufficient information for users to identify driver sources, making the full path redundant and cluttering the display.
2025-11-18 18:59:38 -08:00
rbalsleyMSFT 19081a2e1f Skips empty driver folders during deployment
Adds validation to filter out driver folders containing only WIM files or no installable content before attempting driver injection. This prevents unnecessary processing and potential errors when scanning driver repositories.

Introduces a helper function to recursively check folders for installable driver content (non-WIM files). The validation runs before adding folders to the driver sources list, with appropriate logging when folders are skipped.

Also includes minor code formatting improvements for consistency.
2025-11-18 18:47:01 -08:00
rbalsleyMSFT 3cb4003bcd Improves driver source selection UI clarity
Enhances the driver source selection menu by displaying relative paths instead of full paths, making it easier to identify manufacturer and model folders at a glance.

Adds logic to surface Manufacturer\Model folder structures by expanding top-level folders that contain subdirectories, while preserving simple folder listings when no nested structure exists.

Includes a relative path resolver that normalizes paths and calculates display-friendly names relative to the drivers root directory.

Updates logging and console output to show relative paths for better readability while maintaining full path information internally for file operations.
2025-11-18 15:41:19 -08:00
rbalsleyMSFT beb48e500e Adds generic fallback for driver matching
Implements a generic model-based matching strategy for manufacturers without explicit handling rules in the driver mapping logic.

Previously, unsupported manufacturers would immediately return null without attempting to match driver rules. Now falls back to comparing normalized model names against available rules, improving driver detection coverage for manufacturers that don't require specialized matching logic.

Updates logging to distinguish between successful generic matches and complete failures to find matching drivers.
2025-11-17 18:24:49 -08:00
rbalsleyMSFT 93c4679c46 Removes Surface-specific validation for Microsoft devices
Allows driver mapping to support all Microsoft-branded devices, not just Surface products.

Previously, the code rejected any Microsoft device that didn't match "Surface" in the model name. This restriction prevented proper driver mapping for other Microsoft hardware.

Updates log messages to use generic "Microsoft model" terminology instead of "Surface" to reflect the broader device support.
2025-11-17 17:53:51 -08:00
rbalsleyMSFT d6688def9d Adds support for 8 new hardware manufacturers
Extends hardware detection and driver mapping capabilities to support Panasonic, Viglen, AZW, Fujitsu, Getac, ByteSpeed, and Intel devices.

Implements manufacturer-specific identification logic using baseboard SKU and product information where appropriate. Adds special handling for ByteSpeed devices that may be rebranded Intel NUCs.

Improves model name trimming to ensure consistent string comparisons across all manufacturers.
2025-11-17 17:33:13 -08:00
rbalsleyMSFT 489d53f55c Refactors system identity logic into dedicated function
Extracts manufacturer-specific identifier resolution into a new Get-SystemIdentityMetadata function to improve code organization and reusability.

Consolidates normalization logic so UI display and driver mapping share consistent system identifiers, eliminating duplicate manufacturer detection calls. Stores normalized values alongside original data for better traceability.

Improves maintainability by centralizing model name handling for Lenovo devices and identifier resolution logic for Dell, HP, and Lenovo systems into a single reusable component.
2025-11-17 15:44:15 -08:00
rbalsleyMSFT 3deb8fb8d2 Refactors manufacturer metadata handling
Consolidates manufacturer-specific identifier logic into a single metadata object to reduce code duplication and improve maintainability.

Previously, manufacturer identifiers were captured and assigned using separate variables with duplicated switch-case logic. Now captures all metadata (SystemSku, FallbackSku, MachineType, Label, IdentifierValue) upfront in a structured object, allowing downstream logic to reference a single source of truth.

Improves readability by centralizing the identifier selection logic within each manufacturer case rather than scattering it across multiple switch statements.
2025-11-17 12:59:54 -08:00
rbalsleyMSFT 1af3a0f092 Improves driver mapping with vendor-specific identifiers
Implements manufacturer-specific device identification for automatic driver selection using System SKU, Machine Type, and other vendor identifiers instead of relying solely on model name pattern matching.

Adds normalized manufacturer detection to handle vendor name variations consistently across Dell, HP, Lenovo, and Microsoft Surface devices.

Extracts comprehensive system information from WMI including baseboard details, BIOS metadata, and firmware versions to support accurate device identification and troubleshooting.

Refactors system information gathering into reusable functions that separate data collection from display logic, enabling the driver mapping feature to leverage device identifiers.

Enhances logging by capturing vendor-specific identifiers while hiding internal matching fields from user-facing output to reduce confusion.

Fixes minor log message wording for clarity when driver installation encounters expected failures.
2025-11-15 19:14:45 -08:00
rbalsleyMSFT de80ac551b Adds SystemId support for HP driver management
Enhances HP driver handling to properly track and display SystemId alongside ProductName throughout the driver workflow.

Parses SystemId from HP PlatformList.xml and creates unique entries per ProductName/SystemId combination to avoid conflicts when multiple models share the same product name but different system identifiers.

Updates driver lookup and metadata preservation to include SystemId, MachineType, and ProductName fields across download tasks and result processing, ensuring this information persists through JSON import/export operations.

Improves display name generation to show "ProductName (SystemId)" format for HP models when both values are available, providing clearer model identification in the UI and configuration files.

Standardizes driver metadata handling by replacing simple Make lookups with comprehensive driver metadata lookups that preserve all relevant fields for Dell, HP, and Lenovo vendors.
2025-11-14 19:48:22 -08:00
rbalsleyMSFT 89601efde0 Adds SystemId/MachineType extraction and tracking
Enhances driver mapping functionality to automatically extract and store manufacturer-specific identifiers alongside driver paths.

Implements SystemId lookup for HP devices using PlatformList.xml with caching and normalization for improved matching accuracy. Extracts SystemId from Dell model names and MachineType from Lenovo model names using regex pattern matching.

Updates existing mapping entries when identifiers are discovered and adds them to new entries during driver registration. Improves driver matching capabilities by providing additional metadata beyond manufacturer and model name.
2025-11-14 16:37:12 -08:00
rbalsleyMSFT 235065322c Standardizes driver model name handling across vendors
Improves consistency in how driver model names and identifiers are processed for different manufacturers (Microsoft, Dell, HP, Lenovo) throughout the codebase.

Introduces helper functions to extract base names from display names and construct standardized display names with identifiers in parentheses format.

Centralizes the logic for converting driver items to JSON model objects, eliminating code duplication across save and download operations.

Enhances validation during driver import and processing to skip invalid entries with missing required fields like MachineType for Lenovo or empty model names.

Ensures proper parsing of model names containing parenthetical identifiers (e.g., "Product Name (Type)") and extracts components correctly for each vendor's requirements.

Removes obsolete CabRelativePath property from Dell driver handling.
2025-11-14 15:23:52 -08:00
rbalsleyMSFT 11b3e120e2 Enhances driver package representation in Get-DellLatestDriverPackages function by adding a human-readable driver name. This change captures the display name from the XML, sanitizes it for display purposes, and ensures proper formatting by collapsing multiple spaces and replacing illegal characters. This improves clarity and usability when displaying driver information. 2025-11-05 17:30:53 -08:00
rbalsleyMSFT 667edf3724 Normalizes model display in Get-DellClientModels function by utilizing GroupManifest Display CDATA when available. Implements fallback logic to construct model names from brand and model nodes, improving clarity and consistency in model representation. 2025-11-05 15:33:15 -08:00
rbalsleyMSFT 4a10e27ddf Enhances driver download error handling and logging across Dell and Lenovo driver tasks.
- Implements detailed failure tracking for driver downloads, capturing model names and statuses for failed attempts.
- Updates logging to provide clearer messages for both successful and failed downloads, improving traceability.
- Modifies the user interface to display comprehensive error messages when downloads fail, including a summary of failed models.
- Ensures that all exceptions during download and extraction processes are logged and thrown, preventing silent failures.
2025-10-30 18:26:05 -07:00
rbalsleyMSFT b4305a1edb Fixed a UI issue where sorting after filtering Drivers was causing the filter to break and all items were sorted instead of the filtered items. 2025-10-27 16:40:29 -07:00
rbalsleyMSFT 7598ee96da Clears existing items in cmbMake dropdown during UI initialization to prevent duplication on re-initialization. Ensures a fresh list of manufacturers is displayed each time defaults are restored. 2025-10-27 16:04:22 -07:00
rbalsleyMSFT 6de7c861ed Adds logging for catalog downloads in Save-DellDriversTask function. Includes messages for downloading Dell model and server catalogs to improve traceability during driver retrieval operations. 2025-10-27 15:58:48 -07:00
rbalsleyMSFT 658c57e22c Updates Dell driver catalog handling to use CatalogIndexPC for Windows releases 11 and below. Modifies catalog file paths and URLs accordingly to ensure accurate driver retrieval. 2025-10-24 22:42:57 -07:00
rbalsleyMSFT 60cf1dab18 Enhances progress status messages in driver download and extraction functions for Dell, HP, Lenovo, and Microsoft drivers. Updates messages to include driver names and versions for better clarity during operations. 2025-10-24 22:28:44 -07:00
rbalsleyMSFT 4ce9183bd3 Enhances Dell driver retrieval by resolving missing CabUrl through CatalogIndexPC. Introduces Resolve-DellCabUrlFromModel function for improved reliability. Updates Save-DriversJson and Import-DriversJson functions to handle additional properties (SystemId, CabUrl, CabRelativePath) for Dell models. Refines Get-DellDrivers function to ensure consistent model handling and logging. 2025-10-24 15:19:56 -07:00
rbalsleyMSFT 7dd002396f Normalizes model display in Get-DellClientModels to prevent duplicate brand names in output. 2025-10-23 12:27:46 -07:00
rbalsleyMSFT 1130a830c7 Merge pull request #325 from arwidmark/UI_2510
Update FFU.Common.Core.psm1 to run BITS downloads in Normal Priority
2025-10-22 17:42:27 -07:00
rbalsleyMSFT 66a9026b8f Completely refactored Dell driver downloads
- Client OSes will now use CatalogIndexPC.xml to identify which ProductLine_SystemID.xml to use to identify which drivers to download. This is inline with how DCU works.
- In the UI, Dell Model names now show the full product line, model number, and system ID in the model column.
- There are many more models now shown due to breaking each model out by systemID (one model will have many systemIDs).
- Downloads per model should be much smaller as prior code was downloading drivers for models that Dell had reused their model number (e.g. Precision/Inspiron/Latitude/Vostro 3520 would result in a very large driver download)
- Dell driver downloads are best effort based on the data from the XML files. In some cases the Dell support website may show a newer driver than what is downloaded. This is rare, but in testing I've seen one or two drivers per model where the XML doesn't have what's listed on Dell's website. Again, rare, but not unexpected.
2025-10-22 13:24:29 -07:00
arwidmark 458f1e517c Update BuildFFUVM_UI.ps1 2025-10-20 23:06:18 -05:00
arwidmark a13f9b481a Update FFU.Common.Core.psm1 2025-10-20 22:53:21 -05:00
rbalsleyMSFT de70a22c42 Removes unnecessary properties and excluded applications from DownloadFFU.xml for cleaner configuration for Office downloads. 2025-10-15 12:53:48 -07:00
rbalsleyMSFT f3d3506e02 Improves Lenovo PSREF token retrieval reliability
Enhances the Edge automation process for retrieving authentication tokens with multiple reliability improvements:

- Implements dynamic port allocation to prevent port conflicts
- Adds retry logic with configurable attempts for token retrieval
- Implements proper WebSocket message polling to handle asynchronous responses
- Creates isolated temporary Edge profiles to avoid state interference
- Adds fallback mechanism to extract tokens from cookies when localStorage is unavailable
- Improves error handling and diagnostic logging throughout the workflow
- Ensures comprehensive cleanup of resources including WebSocket connections, browser processes, and temporary profiles

These changes address intermittent failures when the token is not immediately available in localStorage and improve overall robustness of the headless browser automation.
2025-10-07 15:07:30 -07:00
rbalsleyMSFT 1daa14584a Updates default Windows version to 25H2
Changes the default Windows version from 24H2 to 25H2 across the FFU development tool to reflect the latest Windows 11 release.

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.
2025-10-02 17:03:09 -07:00
28 changed files with 4015 additions and 2073 deletions
+95
View File
@@ -1,5 +1,100 @@
# 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
@@ -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>
@@ -92,6 +92,49 @@ function Invoke-Process {
}
}
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
}
# 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 $Arguments
}
function Install-Applications {
param(
[Parameter(Mandatory)]
@@ -177,6 +220,15 @@ function Install-Applications {
$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
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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()]
+31 -15
View File
@@ -359,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 &quot;D:\Win32\Mozilla firefox\setup.msi&quot; /qn /norestart)."/>
<!-- Source -->
<TextBlock Text="Source:" Margin="0,0,0,5"/>
@@ -641,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"/>
@@ -657,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>
@@ -729,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."/>
@@ -745,8 +761,8 @@
<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."/>
@@ -802,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>
@@ -811,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."/>
@@ -9,6 +9,7 @@ function Invoke-FFUPostBuildCleanup {
[string]$CaptureISOPath,
[string]$DeployISOPath,
[string]$AppsISOPath,
[string]$KBPath,
[bool]$RemoveCaptureISO = $false,
[bool]$RemoveDeployISO = $false,
[bool]$RemoveAppsISO = $false,
@@ -20,7 +21,7 @@ function Invoke-FFUPostBuildCleanup {
$originalProgressPreference = $ProgressPreference
$ProgressPreference = 'SilentlyContinue'
try {
WriteLog "CommonCleanup: Starting cleanup (CaptureISO=$RemoveCaptureISO DeployISO=$RemoveDeployISO AppsISO=$RemoveAppsISO Drivers=$RemoveDrivers FFU=$RemoveFFU Apps=$RemoveApps Updates=$RemoveUpdates)."
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)) {
@@ -49,8 +50,15 @@ function Invoke-FFUPostBuildCleanup {
}
if ($RemoveDrivers -and -not [string]::IsNullOrWhiteSpace($DriversPath) -and (Test-Path -LiteralPath $DriversPath -PathType Container)) {
WriteLog "CommonCleanup: Removing contents of $DriversPath"
try { Get-ChildItem -LiteralPath $DriversPath -Force -ErrorAction SilentlyContinue | Remove-Item -Force -Recurse -ErrorAction SilentlyContinue } catch { WriteLog "CommonCleanup: Driver content cleanup issue: $($_.Exception.Message)" }
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)) {
@@ -72,22 +80,26 @@ function Invoke-FFUPostBuildCleanup {
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) {
WriteLog "CommonCleanup: Cleaning Office artifacts"
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 -and -not [string]::IsNullOrWhiteSpace($AppsPath) -and (Test-Path -LiteralPath $AppsPath)) {
$updateDirs = @('Defender', 'Edge', 'MSRT', 'OneDrive', '.NET', 'CU', 'Microcode')
foreach ($d in $updateDirs) {
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"
@@ -95,6 +107,12 @@ function Invoke-FFUPostBuildCleanup {
}
}
}
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."
}
+94 -5
View File
@@ -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
}
@@ -205,7 +294,7 @@ function ConvertTo-SafeName {
# Collapse multiple consecutive dashes
$sanitized = $sanitized -replace '-{2,}', '-'
# Trim leading/trailing spaces, periods, and dashes
$sanitized = $sanitized.Trim(' ','.','-')
$sanitized = $sanitized.Trim(' ', '.', '-')
if ([string]::IsNullOrWhiteSpace($sanitized)) {
$sanitized = 'Unnamed'
}
@@ -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 humanreadable 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 permodel 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
+456 -85
View File
@@ -155,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++
@@ -292,113 +446,330 @@ 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()
}
# Ask the page to return localStorage['asut'].
Send-DevToolsCommand -id 1 -method 'Runtime.evaluate' -params @{
expression = "localStorage.getItem('asut')"
}
# 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
function Get-FreeLocalTcpPort {
$listener = $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."
$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()
}
}
}
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 "Could not find any process listening on port $port."
WriteLog "DevTools endpoint on port $Port returned no targets (attempt $attempt of $MaxAttempts)."
}
}
catch {
WriteLog "Could not run netstat to find listening PID. Error: $($_.Exception.Message)"
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."
}
try {
$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."
}
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 "Failed to retrieve Lenovo PSREF token. Error: $($_.Exception.Message)"
throw
}
finally {
if ($null -ne $socket) {
try {
$socket.Dispose()
WriteLog "Edge DevTools WebSocket disposed."
}
catch {
WriteLog "Error disposing Edge DevTools WebSocket: $($_.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)"
}
}
# Determine the correct PID to kill. Prioritize the one found via netstat.
$pidToKill = $null
if ($listeningPid) {
if ($null -ne $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."
WriteLog "Falling back to initial Edge process PID $pidToKill for termination."
}
if ($pidToKill) {
WriteLog "Attempting to terminate Edge process tree with PID: $pidToKill"
if ($null -ne $pidToKill) {
try {
taskkill /PID $pidToKill /T /F | Out-Null
WriteLog "Successfully issued termination command for Edge process tree with PID: $pidToKill."
WriteLog "Issued termination command for Edge process tree with PID: $pidToKill."
}
catch {
WriteLog "Failed to terminate Edge process tree with PID: $pidToKill. It may have already closed. Error: $($_.Exception.Message)"
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
}
}
}
return $token
}
@@ -156,7 +156,7 @@ 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']
@@ -164,6 +164,7 @@ function Invoke-ParallelProcessing {
OrchestrationPath = $localJobArgs['OrchestrationPath']
ProgressQueue = $localProgressQueue
WindowsArch = $localJobArgs['WindowsArch']
SkipWin32Json = [bool]$localJobArgs['SkipWin32Json']
}
$taskResult = Start-WingetAppDownloadTask @wingetTaskArgs
if ($null -ne $taskResult) {
@@ -269,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 {
+382 -34
View File
@@ -339,6 +339,344 @@ function Get-Application {
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 (
@@ -349,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
@@ -380,44 +721,51 @@ 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."
}
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 -ApplicationArch $appArch -OrchestrationPath $OrchestrationPath
}
catch {
WriteLog "Error occurred while processing $($wingetApp.Name): $_"
throw $_
}
}
}
# Process Store apps
if ($StoreApps) {
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 ($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 -ApplicationArch $appArch -WindowsArch $WindowsArch -OrchestrationPath $OrchestrationPath
}
catch {
WriteLog "Error occurred while processing $($storeApp.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
}
}
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
@@ -749,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,6 +67,7 @@ Description = 'Common functions shared between FFU Builder UI and the BuildFFUVM
# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess
NestedModules = @('FFU.Common.Drivers.psm1',
'FFU.Common.Drivers.Dell.psm1',
'FFU.Common.Winget.psm1',
'FFU.Common.Parallel.psm1',
'FFU.Common.Cleanup.psm1')
@@ -96,6 +96,7 @@ 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
@@ -113,8 +114,9 @@ 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
@@ -243,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)]
@@ -363,6 +414,7 @@ function Update-UIFromConfig {
Set-UIValue -ControlName 'txtShareName' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ShareName' -State $State
Set-UIValue -ControlName 'txtUsername' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Username' -State $State
Set-UIValue -ControlName 'txtThreads' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Threads' -State $State
Set-UIValue -ControlName '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
@@ -618,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 {
@@ -704,6 +757,7 @@ function Update-UIFromConfig {
WriteLog "LoadConfig: Error applying Additional FFU selections: $($_.Exception.Message)"
}
Update-BitsPrioritySetting -State $State
WriteLog "LoadConfig: Configuration loading process finished."
}
@@ -816,6 +870,7 @@ function Invoke-RestoreDefaults {
-CaptureISOPath $captureISOPath `
-DeployISOPath $deployISOPath `
-AppsISOPath $appsISOPath `
-KBPath (Join-Path $rootPath 'KB') `
-RemoveCaptureISO:$true `
-RemoveDeployISO:$true `
-RemoveAppsISO:$true `
@@ -1056,15 +1111,28 @@ function Import-ConfigSupplementalAssets {
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 = $modelName
DownloadStatus = if ($modelEntry.PSObject.Properties['DownloadStatus']) { $modelEntry.DownloadStatus } else { "" }
Link = 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 }
Id = if ($modelEntry.PSObject.Properties['Id']) { $modelEntry.Id } else { $null }
Model = $displayModel
DownloadStatus = $downloadStatus
Link = $linkValue
ProductName = $productName
MachineType = $machineType
SystemId = $systemId
Id = $idValue
}
$State.Data.allDriverModels.Add($driverObj)
}
@@ -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
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"
if (-not (Test-Path -Path $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()
$download = $true
if (Test-Path -Path $dellCatalogXML) {
if (((Get-Date) - (Get-Item $dellCatalogXML).CreationTime).TotalDays -lt 7) {
$download = $false
}
}
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"
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
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
Invoke-Process -FilePath Expand.exe -ArgumentList """$dellCabFile"" ""$dellCatalogXML""" | Out-Null
Remove-Item $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."
}
if (-not (Test-Path $dellCatalogXML)) { throw "Dell server catalog XML missing: $dellCatalogXML" }
# 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
$inDriver = $false
$inModel = $false
$depthModel = -1
$modelsHash = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
try {
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
}
}
# 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,552 +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()]
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null,
[Parameter()]
[bool]$CompressToWim = $false, # New parameter for compression
[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 $modelName
if ($sanitizedModelName -ne $modelName) { WriteLog "Sanitized model name: '$modelName' -> '$sanitizedModelName'" }
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $Make
$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 # Relative path for the driver folder
$driverRelativePath = Join-Path -Path $make -ChildPath $sanitizedModelName
try {
# Check for existing drivers
$existingDriver = Test-ExistingDriver -Make $make -Model $sanitizedModelName -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
# 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
}
# Special handling for existing folders that need compression
if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') {
$wimFilePath = Join-Path -Path $makeDriversPath -ChildPath "$($sanitizedModelName).wim"
$sourceFolderPath = Join-Path -Path $makeDriversPath -ChildPath $sanitizedModelName
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" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
$existingDriver.Status = "Already downloaded & Compressed"
$existingDriver.DriverPath = Join-Path -Path $make -ChildPath "$($sanitizedModelName).wim"
$existingDriver.Success = $true
WriteLog "Successfully compressed existing drivers for $modelName to $wimFilePath."
# Existing drivers shortcircuit
$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
}
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 {
$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 $existing
}
return $existingDriver
}
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 }
# 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"
# 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 }
# 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"
}
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")
$packages = @()
if ($WindowsRelease -le 11) {
# Client OS check
if ($osArch -eq $WindowsArch) {
$validOS = $osNode
break
$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 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
}
}
# 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" }
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()
}
}
WriteLog "Searching $($softwareComponents.Count) DRVR components in '$dellCatalogXML' for model '$modelName'..."
[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) {
# 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 }
$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 ($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
$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
$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]@{
$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 = $driverFileName
DriverFileName = $fileName
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
foreach ($cat in $latestDrivers.Keys) { foreach ($drv in $latestDrivers[$cat].Values) { $packages += $drv } }
}
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
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 "Downloading from: $($driver.DownloadUrl) to $driverFilePath"
try {
Start-BitsTransferWithRetry -Source $driver.DownloadUrl -Destination $driverFilePath
WriteLog "Driver downloaded: $($driver.DriverFileName)"
$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 }
$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
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 (-not (Test-Path $driverFilePath)) {
WriteLog "$status URL: $($pkg.DownloadUrl)"
try { Start-BitsTransferWithRetry -Source $pkg.DownloadUrl -Destination $driverFilePath }
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
$failureMessage = "Failed to download driver '$driverName' from $($pkg.DownloadUrl): $($_.Exception.Message)"
WriteLog $failureMessage
throw (New-Object System.Exception($failureMessage, $_.Exception))
}
}
$status = "$idx/$total Extracting $driverName"
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status $status }
# Extract the driver
$status = "Extracting $($driversProcessed)/$($totalDriversToProcess): $($driver.Name)"
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
if (-not (Test-Path $extractFolder)) { New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null }
# Ensure extraction folder exists before attempting extraction
if (-not (Test-Path -Path $extractFolder)) {
WriteLog "Creating extraction folder: $extractFolder"
$arg1 = "/s /e=`"$extractFolder`" /l=`"$extractFolder\log.log`""
$arg2 = "/s /drivers=`"$extractFolder`" /l=`"$extractFolder\log.log`""
$ok = $false
try {
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
}
# 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"
}
}
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 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
}
WriteLog "Extraction error: $($_.Exception.Message)"
}
# 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"
if ($ok) {
Remove-Item $driverFilePath -Force -ErrorAction SilentlyContinue
}
else {
WriteLog "Extraction failed for $($driver.DriverFileName). Downloaded file kept at $driverFilePath for inspection."
# Update status to indicate partial failure?
$failureMessage = "Failed to extract driver '$driverName'."
WriteLog $failureMessage
throw (New-Object System.Exception($failureMessage))
}
}
} # End foreach ($driver in $latestDrivers)
} # End foreach ($category in $latestDrivers)
# --- 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'..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Compressing...' }
$wimPath = Join-Path $makeDriversPath "$sanitizedModelName.wim"
try {
$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"
}
else {
WriteLog "Compression failed for '$modelName'. Check verbose/error output from Compress-DriverFolderToWim."
$status = "Completed (Compression Failed)"
}
$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 "Error during compression for '$modelName': $($_.Exception.Message)"
$status = "Completed (Compression Error)"
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') {
if ($platformReader.NodeType -eq [System.Xml.XmlNodeType]::Element) {
if ($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 (-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 {
@@ -123,11 +138,17 @@ function Save-HPDriversTask {
[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 = ConvertTo-SafeName -Name $modelName
if ($sanitizedModelName -ne $modelName) { WriteLog "Sanitized model name: '$modelName' -> '$sanitizedModelName'" }
$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
@@ -135,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
@@ -149,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" -PreserveSource:$PreserveSourceOnCompress -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."
}
@@ -190,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
@@ -289,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"
@@ -312,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)) {
@@ -330,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"
@@ -344,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`""
@@ -357,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) {
@@ -365,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" -PreserveSource:$PreserveSourceOnCompress -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
@@ -378,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 }
@@ -132,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" -PreserveSource:$PreserveSourceOnCompress -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."
}
@@ -207,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
@@ -278,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 {
@@ -286,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
@@ -317,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
@@ -325,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) ---
@@ -375,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)
@@ -415,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
@@ -451,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 {
@@ -106,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..." }
@@ -123,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" -PreserveSource:$PreserveSourceOnCompress -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."
}
@@ -226,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
@@ -234,10 +239,6 @@ function Save-MicrosoftDriversTask {
WriteLog "Creating Drivers folder: $DriversFolder"
New-Item -Path $DriversFolder -ItemType Directory -Force | Out-Null
}
$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
if (-Not (Test-Path -Path $modelPath)) {
WriteLog "Creating model folder: $modelPath"
New-Item -Path $modelPath -ItemType Directory -Force | Out-Null
@@ -257,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
@@ -286,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
}
@@ -351,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'
@@ -421,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
+376 -85
View File
@@ -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)"
@@ -582,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) {
@@ -648,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] })) {
@@ -669,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
$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."
}
}
}
@@ -718,42 +1022,16 @@ function Invoke-DownloadSelectedDrivers {
$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."
}
}
$modelObject = Convert-DriverItemToJsonModel -DriverItem $driverItem
if ($null -ne $modelObject) {
$modelsForThisMake += $modelObject
}
}
# Add the models array to the make-specific object
if ($modelsForThisMake.Count -gt 0) {
$outputJson[$makeName] = @{ Models = $modelsForThisMake }
}
}
# Ensure directory exists
$parentDir = Split-Path -Path $driversJsonPath -Parent
@@ -778,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")
}
}
@@ -152,6 +152,17 @@ 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)
@@ -118,6 +118,7 @@ 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')
@@ -234,6 +235,7 @@ 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
@@ -263,6 +265,7 @@ function Initialize-UIDefaults {
$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
@@ -322,12 +325,16 @@ function Initialize-UIDefaults {
# Drivers tab UI logic
$makeList = @('Microsoft', 'Dell', 'HP', 'Lenovo')
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
# Set initial state for driver checkbox interplay
@@ -648,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)
@@ -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,10 +587,11 @@ 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
@@ -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", "25H2")
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") {
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 {
@@ -378,324 +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
[string]$WindowsArch
)
$appName = $ApplicationItemData.Name
$appId = $ApplicationItemData.Id
$source = $ApplicationItemData.Source
$status = "Checking..." # Initial local status
$resultCode = -1 # Default to error/unknown
$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 # 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 $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; no WinGetWin32Apps.json dependency)
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."
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
$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 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 -ApplicationArch $ApplicationItemData.Architecture -WindowsArch $WindowsArch -OrchestrationPath $OrchestrationPath -SkipWin32Json -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(
@@ -721,11 +406,13 @@ 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
WindowsArch = $localWindowsArch
SkipWin32Json = $true
}
# Select only necessary properties before passing to Invoke-ParallelProcessing
+19 -2
View File
@@ -117,6 +117,7 @@ function Get-GeneralDefaults {
ShareName = "FFUCaptureShare"
Username = "ffu_user"
Threads = 5
BitsPriority = 'Normal'
MaxUSBDrives = 5
BuildUSBDriveEnable = $false
CompactOS = $true
@@ -183,16 +184,32 @@ function Get-GeneralDefaults {
}
# 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" }
# 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()
SerialNumber = $serialNumber
UniqueId = $uniqueId
Size = $size
DriveIndex = $_.Index
}
-7
View File
@@ -121,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.