Compare commits

..

191 Commits

Author SHA1 Message Date
rbalsleyMSFT 15ca246abd Updates documentation screenshots and UI descriptions
Refreshes numerous screenshot image references across the documentation to reflect the latest UI changes. Updates text descriptions in several documents (Build, Prerequisites, Quickstart, Windows Settings) to align with recent features, such as the new Windows Media Source options, explicitly mentioning PowerShell 7.6+ requirements, and clarifying the Device Naming and Copy Unattend.xml behavior.

Co-authored-by: Copilot <copilot@github.com>
2026-04-16 15:46:02 -07:00
rbalsleyMSFT 6b76f6b9a2 Renames serial mapping file path UI labels
Updates UI labels, tooltips, message boxes, dialog titles, and corresponding documentation to use "Serial Computer Names CSV Mapping File Path" instead of "SerialComputerNames.csv File Path" to improve clarity and presentation.

Co-authored-by: Copilot <copilot@github.com>
2026-04-16 14:25:47 -07:00
rbalsleyMSFT 944ec1e856 Merge branch 'Naming' into Fluent 2026-04-15 14:52:27 -07:00
rbalsleyMSFT 14811e3f9c Updates version to 2604.1
Increments the script version identifiers to reflect the new release cycle.
2026-04-15 14:42:23 -07:00
rbalsleyMSFT 38323e6be1 Add support for SerialComputerNames CSV mapping
Introduces a new `SerialComputerNames` device naming mode that allows automated device naming during deployment based on the BIOS serial number. The mapping is provided via a CSV file with `SerialNumber` and `ComputerName` columns.

This feature requires `CopyUnattend` and writes a `SerialComputerNames.csv` file to the USB deployment media, replacing the need for manual prompts or prefix selection when device serial numbers are known in advance. The UI has been updated to support creating, loading, and saving the CSV mapping content.
2026-04-15 14:39:14 -07:00
rbalsleyMSFT 24f10b89b0 Fixes DeviceNamingMode UI state tracking and defaults
Updates the FFU UI core to better track the loaded `DeviceNamingMode` configuration.
Introduces flags to detect explicit user changes versus default states.
Ensures that if the user does not explicitly set the device naming mode, it defaults gracefully and preserves legacy script behaviors.
Updates documentation to reflect the actual behavior of not writing the `DeviceNamingMode` key unless modified.
2026-04-15 13:04:12 -07:00
rbalsleyMSFT 0607cf5386 Updates package addition to use native cmdlet
Replaces the direct invocation of DISM via cmd with the native Windows PowerShell cmdlet to streamline and simplify the package installation process during WinPE media creation.
2026-04-14 18:18:53 -07:00
rbalsleyMSFT 28297f3a0e Switches to Windows ADK DISM CLI for adding Windows packages
Replaces the native PowerShell command for adding Windows packages with direct command-line invocations of the Deployment Image Servicing and Management (DISM) tool. This fixes an issue introduced by the March 31, 2026 OOB update that persists in the April 2026 2B update where Add-WindowsPackage fails due to issues with CBS. Using native dism fixes the issue.
2026-04-14 14:56:02 -07:00
rbalsleyMSFT 7bd5decc62 Adds custom unattend XML file selection paths
Adds parameters for x64 and arm64 unattend XML file paths.
Updates the BuildFFUVM_UI to let the user pick specific XML files to copy or inject instead of requiring them to exist in the `unattend` folder.
Adds validation to ensure the selected unattend XML files are not empty.
Updates documentation to reflect the new functionality.
2026-04-09 16:44:39 -07:00
rbalsleyMSFT f1f1957c43 Auto-generate ComputerName in Unattend XML
Replaces the strict validation for an existing ComputerName element with dynamic XML initialization. Automatically creates the specialize settings block, the Microsoft-Windows-Shell-Setup component, and the ComputerName element if they are missing from the provided Unattend XML file. This improves script robustness and simplifies the requirements for custom unattended setup templates by patching in the necessary device naming structure on the fly.
2026-04-09 15:14:00 -07:00
rbalsleyMSFT 82bac17b38 Adds Prompt option to device naming mode
Introduces an explicit "Prompt" option for device naming to allow technicians to enter the device name during deployment. This replaces the implicit legacy behavior, providing clear UI controls and validation to ensure that Unattend.xml is copied, which is required for prompt-based naming. Relevant documentation is updated to reflect this new workflow capability.
2026-04-09 13:33:37 -07:00
rbalsleyMSFT 1ea1ef6fd0 Improves FFU deployment menu selections and device naming
Refactors the `ApplyFFU.ps1` script to use a new `Read-MenuSelection` function for standardized user prompts, improving input validation and allowing users to skip optional prompts (like prefix selection, AP JSON, and PPKG files). Updates sample unattend XML files to use `*` for the computer name, falling back to default Windows naming if not specified. Updates the documentation to reflect the new UI and script behaviors.
2026-04-08 17:58:25 -07:00
rbalsleyMSFT 5ca5312c52 Allows Office installation on ARM64 VMs
Comments out the restriction that previously prevented Microsoft 365 Apps and Office from installing on ARM64 virtual machines. This enables users to attempt or perform Office installations on ARM64 architectures where it was previously blocked.
2026-04-07 10:50:47 -07:00
rbalsleyMSFT 4a2d8e63ea Add flexible device naming options to Unattend delivery
Introduces new parameters and UI controls to give users more choice over device naming when applying an Unattend.xml file.
Users can now specify a device name, use a static or template-based name with the `%serial%` variable, or continue using a list of prefixes.
The UI is updated with a new Device Naming expander to guide the user through the options and clearly indicate the requirements for each mode, ensuring that mutually exclusive options like Copy Unattend and Inject Unattend are not selected together.
Documentation is updated to reflect the new functionality.
2026-04-07 10:48:34 -07:00
rbalsleyMSFT 78212f06d7 Add experimental VM networking opt-in for Hyper-V builds
The Hyper-V switch selection in the UI previously did not connect the build VM to the network during provisioning. Since internet-connected Sysprep and capture flows are still experimental, this introduces an explicit "Enable VM Networking" checkbox to the Hyper-V Settings page that defaults to off.

- Adds the `-EnableVMNetworking` parameter to BuildFFUVM.ps1 to conditionally attach the Hyper-V network adapter during VM creation.
- Persists the setting through FFU config files and blocks UI execution if enabled without a valid switch.
- Refactors WPF event handlers in FFUUI.Core to fix dropdown scoping errors.
- Updates documentation and the sample configuration file to reflect the new behavior.
2026-03-30 16:37:53 -07:00
rbalsleyMSFT f838ef3779 Remove registry-based FFU file naming
Replace the Get-WindowsVersionInfo registry-interrogation step in the VHDX capture path with parameter-driven naming via Get-FFUCaptureNamingInfo. This eliminates the registry load/unload commands and two full minutes of registry sleep time during capture.

By deriving default file and DISM image names from already-resolved target state (WindowsRelease, WindowsVersion, and installationType), capture naming now naturally matches the rest of the script. This also unifies CustomFFUNameTemplate evaluation between live image servicing and cached VHDX reuse instances.
2026-03-30 14:49:17 -07:00
rbalsleyMSFT 5aaa1ad732 Refresh Windows SKU dynamically after fallback image selection
Previously, when a requested Windows SKU was not found in the provided ISO/ESD and the user manually selected a fallback image, the script kept the original (stale) `$WindowsSKU`. This caused downstream features like FFU file naming, VHDX cache metadata, and cumulative update planning to enforce logic against the wrong edition.

- Refactored `Get-Index` into `Get-WindowsImageSelection` to return rich image metadata (including EditionId and InstallationType) instead of just the image index.
- Added `Get-ResolvedWindowsSKUFromImage` to resolve raw image metadata back into the repository's native friendly SKU vocabulary.
- Added `Get-WindowsTargetRuntimeState` to centralize and recalculate dependent variables (`installationType`, `WindowsVersion`, LTSC flags) after the SKU updates mid-flight.
2026-03-30 12:57:15 -07:00
rbalsleyMSFT c135ad0fba Captures FFU directly from host-mounted VHDX
By optimizing and mounting the VHDX directly on the host for image capture, the build process no longer needs to boot the VM into WinPE, create SMB network shares, generate temporary local accounts, or rely on complex Hyper-V switch IP configurations. This streamlines the workflow and eliminates multiple networking and permission-related points of failure.

This change also removes the need to generate and attach WinPE capture media. All related parameters (`ShareName`, `Username`, `VMHostIPAddress`, `CreateCaptureMedia`, `CleanupCaptureISO`), UI controls, capture scripts, and documentation references have been removed or updated to reflect the simplified architecture.
2026-03-26 22:31:08 -07:00
rbalsleyMSFT 6db0f8c905 Reverts to using hardcoded Lenovo PSREF token
Switches back to utilizing the static cookie for Lenovo PSREF requests, as the token has remained unchanged for months. The dynamic retrieval function call is bypassed to streamline the process but remains in the codebase as a fallback in case the token expires or changes in the future.
2026-03-25 10:39:57 -07:00
rbalsleyMSFT b62a1a19b0 Merge pull request #450 from rbalsleyMSFT/main
Merge pull request #440 from rbalsleyMSFT/UI
2026-03-24 16:30:30 -07:00
rbalsleyMSFT d6361dac4d Adds automatic column resizing to ListViews
- Enables automatic horizontal and vertical scrollbars for ListViews in the UI to improve navigation.
- Introduces functions to dynamically calculate and apply column widths based on the visible content and header text.
- Triggers column auto-resizing across various modules whenever ListView data is updated or refreshed.
- Renames a path normalization function and updates an event handler parameter name for clarity.
2026-03-24 16:28:29 -07:00
rbalsleyMSFT bae29fd9c7 Updates Microsoft Surface driver model discovery
Transitions the Microsoft Surface driver model list retrieval to use a centralized Learn-based index. This change unifies the scraping logic between the CLI and UI components, ensuring consistency and simplified maintenance. Cached model lists now serve both interfaces efficiently, reducing unnecessary network requests while retaining fallback mechanisms.
2026-03-24 10:58:42 -07:00
rbalsleyMSFT aca968ccbd Updates UI margins and alignments for consistency
Adjusts margins, padding, and alignments across multiple UI components in the XAML file to ensure a consistent and polished layout. Values for spacing have been standardized to improve the visual spacing between controls, enhancing overall readability and user experience.
2026-03-24 10:46:12 -07:00
rbalsleyMSFT db044551cc Populates Home page with build and release status
Updates the Home page UI to display current build, latest release, and release status. Moves the resources section to the Home page and adds a new section to display the latest GitHub discussions. Add helper functions to query GitHub for the latest release and recent public discussions to keep users informed efficiently.
2026-03-23 18:07:23 -07:00
rbalsleyMSFT d6e6287b56 Updates UI styling for the shared page shell
Switches to the application background brush and removes the border to improve visual consistency and integrate more seamlessly with the overall application theme.
2026-03-23 12:53:14 -07:00
rbalsleyMSFT 98c1644d76 Improves UI structure with a shared page shell and dynamic titles
Wraps the application's content area in a stylized border and introduces a centralized, dynamic page header to provide a more cohesive look.

- Adds `Tag` attributes to navigation list items to define each view's display title
- Implements event handlers to automatically update the header text based on user navigation
- Removes redundant embedded titles from individual content pages to streamline the layouts
2026-03-23 11:52:04 -07:00
rbalsleyMSFT 002017e5e6 Updates layout margins across UI pages
Reduces the top margin to zero for the primary layout containers across multiple tabs and pages, improving content alignment and visual spacing within the scroll viewers.
2026-03-23 11:14:30 -07:00
rbalsleyMSFT 2d2ce49537 Increases width of threads input text box
Expands the text box width to improve visibility of the input value and enhance the user interface layout.
2026-03-20 16:35:10 -07:00
rbalsleyMSFT 42ed2819b8 Fixes header checkbox alignment in grid view
Wraps the "Select All" header checkbox in a custom container to mirror the padding and layout used by data rows. Removes default padding and enforces strict vertical and horizontal centering, ensuring consistent visual alignment between the header and cell content.
2026-03-20 16:29:09 -07:00
rbalsleyMSFT 80147ed61b Adds Windows Media Source selection options
Introduces radio buttons to choose between downloading a Windows ESD or providing a custom Windows ISO file, enhancing flexibility for media source selection. Updates configuration handling and event listeners to toggle visibility and appropriately populate combo boxes based on the selected media source.
2026-03-20 15:25:23 -07:00
rbalsleyMSFT b28344d272 Convert UI sections to expandable controls
Transitions the General Build Options, Build USB Drive Options, and Post-Build Cleanup settings panels into Expander controls to improve interface organization and space management. Removes dynamic visibility toggling for the USB settings section from the backend scripts, allowing the expander to remain visible while child settings are controlled by the primary enable checkbox.
2026-03-20 14:18:46 -07:00
rbalsleyMSFT 7678f61480 Updates UI layout to a modern navigation sidebar and adds Fluent theme support
Refactors the main window layout to use a sidebar navigation model instead of a standard tab control, improving the overall organization of the application.
Introduces support for the Fluent theme (Light, Dark, and System) for users running PowerShell 7.5+ (.NET 9+), gracefully falling back to standard styling for older versions.
Adds a new Settings page that allows users to configure the application theme and access helpful documentation and repository links.
Standardizes margins, font sizes, and layout choices across all forms to closer match Windows design guidelines.
Updates configuration handling to save and restore user theme preferences.
2026-03-20 13:07:40 -07:00
rbalsleyMSFT eac8be3d31 Allow custom BYO app list file paths in UI
Updates the FFU UI and orchestration scripts to allow users to specify custom file paths for their Bring Your Own (BYO) app lists, rather than forcing the use of `UserAppList.json` in a specific directory.

Also modifies the orchestration to sync this custom path via `AppInstallConfig.json` so that the runtime orchestration phase resolves the correct file name and path during installation. Refreshes the Apps ISO if the custom BYO app list is updated.
2026-03-18 18:59:28 -07:00
rbalsleyMSFT 3cb9bc9931 Merge pull request #440 from rbalsleyMSFT/UI
Update release notes and improve UI documentation and build process
2026-03-16 11:59:47 -07:00
rbalsleyMSFT b388eae439 Updates README with 2603.2 release notes
Documents the critical boot issue fix in the latest 2603.2 release. Modifies the getting started section to clarify guidance for users new to the UI version.
2026-03-16 11:58:14 -07:00
rbalsleyMSFT c1b81004be Clarifies zip extraction instructions
Updates the prerequisites documentation to explicitly instruct copying the folder contents rather than extracting the entire zip file. This helps prevent nested folder structures during setup. Also cleans up trailing whitespace.
2026-03-16 11:53:42 -07:00
rbalsleyMSFT 02e423c0f2 Updates changelog for release 2603.2 2026-03-16 11:51:04 -07:00
rbalsleyMSFT ce34e40a52 Merge pull request #439 from rbalsleyMSFT/main
Update release notes and improve UI documentation and build process
2026-03-16 11:46:46 -07:00
rbalsleyMSFT 4f701c4b1c Merge pull request #438 from rbalsleyMSFT/UI
Fix working directory handling and improve build processes
2026-03-16 11:23:38 -07:00
rbalsleyMSFT 6c0ee8abc5 Uses ADK BCDBoot to prevent issues with devices that have updated Secureboot certificates from using 2023 signed boot files 2026-03-16 11:19:08 -07:00
rbalsleyMSFT 9bacac8f3d Fixes working directory handling
Ensures build and cleanup processes run from the expected project location.

Prevents temporary state files from being created or removed in the wrong folder, which avoids stale markers and cleanup failures when launched from the UI or another directory.
2026-03-10 17:01:40 -07:00
rbalsleyMSFT a0dc5a6ae9 Cleans up README formatting and announcements
Removes transient release announcements and outdated progress notes to streamline the document. Improves overall readability by splitting paragraphs and properly formatting section headers for older content.
2026-03-09 19:16:32 -07:00
rbalsleyMSFT 6b548a34e6 Updates changelog for version 2603.1
Adds comprehensive release notes for version 2603.1. Documents the finalization of the UI preview, various servicing improvements, ESD and VHDX caching optimizations, and multiple bug fixes related to updates, driver downloads, and file cleanup mechanisms.
2026-03-09 19:14:03 -07:00
rbalsleyMSFT 8c5629c9ce Merge pull request #422 from rbalsleyMSFT/UI
Merge UI into Main
2026-03-09 17:28:16 -07:00
rbalsleyMSFT e7589f6ceb Merge branch 'main' into UI 2026-03-09 17:20:20 -07:00
rbalsleyMSFT 711582ae71 Updates script versions to 2603.1
Bumps the internal version strings from the 2602.1 preview to the 2603.1 release across deployment scripts.
2026-03-09 16:33:54 -07:00
rbalsleyMSFT db22c1801d Simplifies products catalog request to use amd64
Always requests the amd64 products catalog regardless of the target architecture, as it already contains media entries for both x64 and arm64. This removes unnecessary architecture-specific branching.
2026-03-06 18:32:17 -08:00
rbalsleyMSFT 422bc33da7 Enhances file backup and cleanup for current runs
Improves the current-run cleanup mechanism by tracking file downloads explicitly. This ensures that files downloaded via BITS or preserving older timestamps are correctly identified and removed during cleanup. Extends the run manifest schema to support file backups, allowing for safe restoration of pre-existing scripts and configuration files modified during a run. Additional cleanup logic now correctly prunes residual empty directories after tracked files are removed.
2026-03-05 19:10:17 -08:00
rbalsleyMSFT 96603f025a Includes disk size in VHDX cache validation
Prevents reusing cached images when the requested disk size changes. Ensures the disk size property is properly saved and verified against existing cache items to maintain configuration accuracy.
2026-03-03 18:22:32 -08:00
rbalsleyMSFT a8fecd133e Improves VHDX optimization and caching logic
Extends the volume optimization helper to safely handle already mounted drives, resolve partition letters, and optionally execute a volume retrim. Replaces inline defragmentation steps with the updated helper prior to caching to ensure a fully compacted and optimized image is stored.
2026-03-03 17:36:44 -08:00
rbalsleyMSFT 7f10811c05 Introduces an option to retain downloaded ESD files
Adds a new configuration parameter and UI toggle to control whether downloaded Windows ESD files are removed after application. Updates the download process to check for and reuse existing ESD files that match the latest metadata filename, saving bandwidth and time on subsequent executions. Integrates the conditional deletion logic into the shared cleanup module.
2026-03-03 16:47:31 -08:00
rbalsleyMSFT f09c98906a Scopes select-all to visible filtered list items
Enhances grid view selection to optionally target only visible items when a filter is applied, preventing accidental selection or deselection of hidden rows via the header checkbox. Updates the save logic to process the unfiltered master dataset, ensuring that previously selected but currently hidden items are safely preserved during save operations.
2026-03-03 15:16:01 -08:00
rbalsleyMSFT 04dfb5f327 Normalizes Windows LTSC releases for OEM drivers
Adds logic to normalize LTSC/LTSB release years (such as 2016, 2019, 2021, and 2024) to their corresponding base Windows client versions (10 or 11). This ensures correct model retrieval and driver downloading, as OEM catalogs typically evaluate against base Windows versions rather than LTSC-specific release years.
2026-03-03 14:37:48 -08:00
rbalsleyMSFT dc801e9cc9 Adds dependency validation for copying drivers
Prevents an invalid configuration state by ensuring the build USB drive option is explicitly requested whenever the copy drivers option is specified.
2026-03-03 14:16:06 -08:00
rbalsleyMSFT a8e2ab941f Updates ADK detection to use executable paths
Improves the reliability of locating Windows ADK and WinPE add-on installations.
Updates registry searches to locate installer executables in the bundle cache path instead of relying on display names, avoiding potential mismatches.
Expands the registry queries to check both the WOW6432Node and standard uninstall paths to better support different installation contexts.
Validates the Windows Deployment Tools feature by verifying the existence of expected script files rather than checking the registry.
2026-03-03 14:02:52 -08:00
rbalsleyMSFT c83bc8c769 Updates script parameter documentation
Adds missing descriptions for cleanup, file copying, and download priority parameters to ensure complete help documentation.

Expands the documented supported values for Windows architectures, releases, and SKUs to accurately reflect expanded capabilities for ARM64, Windows Server, and LTSC deployments.

Corrects minor typos, updates default values to match the current implementation, and reorders parameters alphabetically to improve readability.
2026-02-25 14:25:47 -08:00
rbalsleyMSFT 0eb7f66c2b Updates documentation links with correct paths
Corrects internal markdown links by adding the proper directory path. Prevents broken navigation and ensures users are directed to the correct documentation pages.
2026-02-23 14:01:46 -08:00
rbalsleyMSFT d70615a32d Added Helper Scripts section of FFU Builder docs site
Added new docs for Create-PEMedia.ps1 and USBImagingToolCreator.ps1 files to help explain how to use the scripts.
2026-02-23 13:59:00 -08:00
rbalsleyMSFT 26694f30e3 More fixes for docs site indexing 2026-02-19 17:52:42 -08:00
rbalsleyMSFT 3563639ce0 Fixes for docs site indexing 2026-02-19 17:09:40 -08:00
rbalsleyMSFT a42f49e1fa site verification for docs site 2026-02-15 09:58:43 -08:00
rbalsleyMSFT 42b0b0c350 Fix: Windows 10 LTSB/LTSC Cumulative Update Installation
Since Windows 10 is out of support and only allows ESU updates, LTSB/LTSC builds are impacted by this and are unable to be offline serviced.

This commit fixes that by installing the CU in the VM, staging the update in a LTSCUpdate folder in the Apps folder.
2026-02-13 17:25:26 -08:00
rbalsleyMSFT 6e6abfe833 Clarifies script list comment
Removes misleading guidance about script order to keep documentation accurate.
2026-02-12 14:03:07 -08:00
rbalsleyMSFT baa696b880 Standardizes .NET update cache layout
Always stores .NET MSUs under a dedicated NET folder to keep cache structure consistent across OS variants

Migrates expected legacy root-stored .NET MSUs into the NET folder and prunes stale files from both locations to prevent buildup and mismatches
2026-02-12 13:19:05 -08:00
rbalsleyMSFT 27eebeb9cb Adds OS-scoped update cache folders
Prevents update packages for different Windows targets from mixing in a single cache, improving reuse across builds while avoiding DISM picking up stale MSUs.

Keeps pruning, download destinations, and update discovery confined to the OS/version-specific cache, including shared-branch handling for Windows 11 25H2/24H2 and LTSC mappings.
2026-02-11 23:17:55 -08:00
rbalsleyMSFT d349e5e4fb Prunes stale MSUs before servicing
Removes older update packages from the local update cache so servicing tools don’t pick up multiple stale MSUs as sources.

Keeps only the MSUs expected for the current run across Windows, .NET (including LTSC folder layout), and Microcode updates, while logging failures and continuing.
2026-02-11 00:06:03 -08:00
rbalsleyMSFT 53a47511d8 Updates README to highlight Quick Start
Improves first-time onboarding by pointing to the Quick Start Guide and the latest UI-based quickstart video.

Reorganizes video resources by moving legacy walkthroughs into an “Older” section and removes the outdated deep-dive chapter list.

Adds a brief note on UI maturity and upcoming consolidation toward the UI branch.
2026-02-09 17:45:22 -08:00
rbalsleyMSFT 518b4d4e62 Adds quickstart video walkthrough embed
Improves onboarding by embedding a privacy-enhanced YouTube walkthrough so readers can follow the setup steps visually.
2026-02-09 17:07:13 -08:00
rbalsleyMSFT a771136761 Adds external link handling script
Improves documentation UX by enabling consistent behavior for outbound links across pages
2026-02-09 16:59:42 -08:00
rbalsleyMSFT 36ee6f64bc added monitor.md 2026-02-09 16:33:51 -08:00
rbalsleyMSFT 2257b72255 Updates build page nav order
Keeps the docs sidebar ordering consistent after navigation reshuffling.
2026-02-09 16:21:36 -08:00
rbalsleyMSFT a65c9b5a18 Improves list indentation for better readability
Aligns list spacing with Learn-style docs by increasing indentation, preventing bullets/numbers from feeling cramped against content.
2026-02-05 14:11:40 -08:00
rbalsleyMSFT d38e461246 Adds responsive inline TOC with safe scrollspy
Improves navigation on narrow viewports by moving the TOC into the article and collapsing long lists with a show more/less toggle.

Prevents duplicate TOCs and reduces scroll “fighting” by disposing/reinitializing scrollspy on resize and keeping auto-scrolling behavior limited to the right-side desktop panel.
2026-02-05 13:19:39 -08:00
rbalsleyMSFT edc9901e7e Improves TOC layouts for long content
Keeps heading permalink icons visible by allowing horizontal overflow.

Prevents long code blocks and tables from overlapping the page TOC by enabling horizontal scrolling where needed.
2026-02-05 12:59:27 -08:00
rbalsleyMSFT 48b55df18e Update release date in README 2026-02-04 14:29:06 -08:00
rbalsleyMSFT dc024c9d99 Updates changelog for 2602.1 UI Preview
Adds release notes highlighting improved Surface auto driver matching, more resilient USB driver injection with better logging, and correct Windows index selection for non-English media.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes key-value parsing to handle values containing equals signs.
2026-01-06 17:00:42 -08:00
rbalsleyMSFT d6e7fd314f Update release date in README.md 2026-01-05 14:25:38 -08:00
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 a193c283f3 Revise release notes and download instructions
Updated release information and recommendations in the README.
2025-12-09 17:57:11 -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
rbalsleyMSFT 477d51fbbb Update release date in README.md 2025-10-01 17:42:35 -07:00
rbalsleyMSFT 08c9214976 Adds 2509.1 release notes
Documents new preview release and bumps displayed version. Notes broadened artifact cleanup, larger default disk size, auto load/restore of environment, centralized cleanup, dynamic PE driver sourcing, driver model normalization, deferred driver folder cleanup, safer name sanitization, improved config import & JSON determinism, per‑app exit code controls (incl. winget), multi‑FFU USB inclusion, removal of obsolete ESD workaround, and added Windows 11 25H2 support to improve UX, reliability, and maintainability.
2025-10-01 17:40:29 -07:00
rbalsleyMSFT c110dcd40e Updates preview version to 2509.1
Increments internal preview version for build and deployment scripts to reflect next preview cycle, ensuring logs and artifacts show the correct release identifier for traceability.
2025-10-01 17:19:39 -07:00
rbalsleyMSFT eaa3e1e6af Adds Windows 11 25H2 mapping
Extends supported Windows 11 releases to include 25H2 to keep version resolution current for upcoming media and configuration scenarios.
2025-10-01 13:23:46 -07:00
rbalsleyMSFT 6562d16ce5 Standardizes JSON output: depth, UTF-8, key order
- Sorts top-level config keys before serialization for deterministic files and cleaner diffs.
- Increases JSON depth to 10 to retain nested settings.
- Writes JSON as UTF-8 via Set-Content for consistent encoding.
- Applies across config export and UI save flows.
2025-09-20 13:30:41 -07:00
rbalsleyMSFT 15a5b16b39 Adds UI/CLI to copy additional FFUs to USB build
- Enables selecting multiple existing FFU images to include on the deployment USB for easier distribution and testing.
- Adds a UI option with selectable, sortable list from the capture folder, refresh support, and persisted selections.
- Validates that selections exist when the option is enabled to prevent empty runs.
- Supports unattended/CLI flows by prompting early or accepting a preselected list for USB creation; deduplicates and logs chosen files.
- Always includes the just-built (or latest available) FFU as a base.
- Improves no-FFU handling and streamlines multi-FFU selection workflow.
2025-09-18 18:17:58 -07:00
rbalsleyMSFT d9c0c9c68e Adds exit-code overrides and UI for winget apps
Adds per-app control for additional accepted exit codes and ignoring non‑zero exit codes to improve handling of installers with nonstandard returns.

Exposes editable fields in the app list UI, persists them across search defaults, import/export, and pre-download save, and applies overrides during app resolution to honor configured behavior.
2025-09-18 15:15:00 -07:00
rbalsleyMSFT d1ca123104 Sanitizes app names for storage and paths
Applies name sanitization when persisting the app list and when building/checking Win32 and Store download directories.
Prevents invalid characters in folder names, aligns persisted names with on-disk structure, and improves detection of existing content to avoid redundant downloads and errors.
2025-09-17 13:22:17 -07:00
rbalsleyMSFT f37647599a Includes exit code fields when using Copy Apps button
Adds persistence of AdditionalExitCodes and IgnoreNonZeroExitCodes when exporting the UI list to prevent losing custom exit handling settings and maintain parity with the primary save routine.
2025-09-16 17:05:34 -07:00
rbalsleyMSFT cb14e84a26 Add robust sanitization for names used in paths
Introduces a new common function, `ConvertTo-SafeName`, to sanitize strings by removing characters that are invalid in Windows file paths.

This function is now used consistently when creating directory and file names for drivers (Dell, HP, Lenovo, Microsoft) and applications to prevent path-related errors. It replaces several ad-hoc sanitization methods with a single, more robust implementation.
2025-09-16 16:43:43 -07:00
rbalsleyMSFT 8d7e4d1066 Refactor config loading and improve error handling
Extracts the logic for importing supplemental assets (Winget, BYO, Drivers) into a new reusable function. This function is now called by both the manual and automatic configuration loaders, reducing code duplication.

Enhances the manual configuration loading process with more robust error handling. It now provides specific user-facing error messages for file read failures, empty files, and invalid JSON, improving the user experience when loading a malformed configuration.

When loading a configuration, if optional supplemental files like AppList.json are referenced but not found, an informational message is now displayed to the user instead of failing silently.
2025-09-15 18:10:39 -07:00
rbalsleyMSFT c30ed923b6 feat: Defer cleanup of compressed driver source folders
Implements a deferred cleanup mechanism for driver source folders when they are compressed to a WIM and also used for WinPE.

When drivers are compressed, the original source folders are now preserved if they are also needed for WinPE driver injection. A marker file is created in these preserved folders.

A new cleanup step is added after the WinPE media creation to remove these preserved folders, ensuring they are available when needed but not left behind permanently.
2025-09-12 15:08:48 -07:00
rbalsleyMSFT 50713188bf Refactor: Improve model name normalization for driver mapping
Enhances the model name normalization function to better handle variations in hardware model strings. This change introduces specific rules to canonicalize "All-in-One" and screen size variants (e.g., "-in" or "inch") for more reliable matching against driver mapping rules.

Additionally, optimizes performance by normalizing the system model once before the comparison loop. Logging is also added to show the original and normalized model strings for easier debugging.
2025-09-11 18:15:51 -07:00
rbalsleyMSFT e2ccd11f07 feat: Add option to dynamically build PE drivers
Introduces a new feature, `UseDriversAsPEDrivers`, that allows WinPE drivers to be sourced directly from the main driver repository.

When enabled, the script scans all available drivers, parses their INF files, and copies only the essential driver types (e.g., storage, mouse, keyboard, touchpad, system devices) needed for WinPE. This eliminates the need to maintain a separate, manually curated `PEDrivers` folder.

The UI is updated with a new checkbox that becomes visible when "Copy PE Drivers" is selected, making this a sub-option. Parameter validation is also adjusted to support this new workflow.
2025-09-11 12:13:06 -07:00
rbalsleyMSFT f3316a017b feat: Add restore defaults and centralize cleanup logic
Introduces a "Restore Defaults" feature in the UI to reset the environment. This action removes generated configuration files, ISOs, downloaded apps, updates, drivers, and FFUs.

The post-build cleanup logic is refactored from the main build script into a new common function. This new function is used by both the standard build process and the new restore defaults feature, promoting code reuse and simplifying maintenance.
2025-09-10 11:31:53 -07:00
rbalsleyMSFT bdf1b63833 Improves UI state after environment autoload
Updates the visibility of UI panels for Winget and drivers when a previous environment is automatically loaded.

This ensures that if Winget apps or driver models are present, their corresponding UI sections are made visible. Additionally, it updates the "select all" checkbox state for Winget results and attempts to pre-select the hardware make for loaded drivers.
2025-09-09 18:00:52 -07:00
rbalsleyMSFT 3ef26f2918 Adds auto-loading of previous configuration on startup
Implements a new feature to automatically load the previously saved environment when the UI is launched.

This improves user experience by restoring the last saved configuration, including selected applications and drivers, eliminating the need to manually reload them on each run.

The process loads the main `FFUConfig.json` and then proceeds to load associated Winget, BYO App, and Driver lists if they are defined. UI elements and checkboxes are updated accordingly to reflect the loaded state.
2025-09-09 17:49:36 -07:00
rbalsleyMSFT 372360d739 Update default disk size to 50GB in FFU scripts and UI
- Changed the default disk size parameter from 30GB to 50GB in BuildFFUVM.ps1 and FFUUI.Core.psm1 to accommodate larger virtual machines.
- Updated tooltip and default value in the UI XAML file to reflect the new disk size.
2025-09-03 12:06:11 -07:00
rbalsleyMSFT dc5877f398 Removes the VM workaround for MCT ESD builds
Comments out the logic that forces app installation when building from a downloaded ESD file. This workaround was implemented to prevent an OOBE reboot loop but is no longer required. This should speed up scenarios where you want to download the ESD media, install the latest CU and .NET CU, and capture the FFU.
2025-08-28 13:56:54 -07:00
rbalsleyMSFT 3e3492bbab Update latest release information in README 2025-08-27 15:48:02 -07:00
rbalsleyMSFT 49b2113fe1 Merge pull request #262 from jrollmann/UI_2508
Update USBImagingToolCreator.ps1
2025-08-27 15:38:12 -07:00
jrollmann 556cfa1ee3 Update USBImagingToolCreator.ps1 2025-08-27 17:07:52 -04:00
rbalsleyMSFT 1ab4093d54 Refactor: Enhance artifact cleanup for disabled features
Renames `Remove-DisabledUpdates` to `Remove-DisabledArtifacts` to better reflect its expanded scope.

This function now also removes Office installation scripts and downloaded content if the Office installation is disabled via the `$InstallOffice` flag.

The function call is moved to run before app installations to ensure artifacts are removed prior to the installation phase.
2025-08-27 12:40:11 -07:00
rbalsleyMSFT 97e1ec2be4 Update UI Preview release information in README 2025-08-26 17:30:36 -07:00
rbalsleyMSFT 6e95ff92b1 fix: update script version to 2505.2 in BuildFFUVM.ps1 and ApplyFFU.ps1 for consistency across files 2025-08-12 17:47:25 -07:00
rbalsleyMSFT d4274d54d2 fix: Microsoft Update Catalog now includes the windows version information in the KB article title. This caused an issue where parsing the KB article was failing. Fixed the regex to accomdate this. 2025-08-12 17:33:26 -07:00
rbalsleyMSFT aee33a6a4b Update README.md 2025-08-01 20:30:35 -07:00
rbalsleyMSFT 25a0928195 Update README.md 2025-07-31 18:17:43 -07:00
120 changed files with 17529 additions and 4699 deletions
+363
View File
@@ -1,5 +1,368 @@
# Change Log # Change Log
# 2603.2
## What's Changed
Highly recommended to upgrade to this release due to potential SecureBoot issues
### Fixes SecureBoot-related boot issues on newly deployed FFUs
Fixes an issue where some devices may not boot after an FFU was applied if the FFU was built from a machine that had been updated to the Windows UEFI CA 2023 SecureBoot certificates.
Sometime at the start of this calendar year (Either January or February), a change was made in Windows to how BCDBoot functioned. If you took a CU from either of these months, BCDBoot will now check to see if the device has the 2011 or 2023 CA certificates. If 2023, the local BCDBoot will use the 2023 signed boot files when creating the System partition and these boot files will be deployed to the target system when the FFU is deployed. If the target machine hasn't updated to the 2023 certificates, boot will fail.
To fix this, FFU Builder now uses the version of BCDBoot from the ADK instead of the locally installed version. The version of BCDBoot from the 10.1.26100.2454 ADK December 2024 version (which is what FFU Builder considers the latest), will provide the boot files signed with the 2011 certs.
The version of [BCDBoot from the 10.1.28000.1 ADK](https://learn.microsoft.com/en-us/windows-hardware/get-started/what-s-new-in-kits-and-tools#bcd-boot) from November 2025 will default to using the 2023 certs as long as the machine supports the 2023 CA. This has been documented since this ADK was released. The behavior of this version of BCDBoot is what we're seeing now in devices that have been recently updated.
I suspect that when 26H2 is released, there will be a new ADK around that time and at that point we'll move to using that version of the ADK, which that version of BCDBoot will default to using the 2023 signed boot files and I suspect WinPE will probably default to doing the same. By then, hopefully, most in-market devices should have the 2023 certificates in UEFI and those that don't will need to get the certs or downgrade their ADK version to use the 2011 signed boot files.
### Fixes working directory handling
Creation and deletion of the dirty.txt marker file now use an explicit path based on $FFUDevelopmentPath, avoiding ambiguity and potential issues with relative paths.
**Full Changelog**: https://github.com/rbalsleyMSFT/FFU/compare/v2603.1...v2603.2
# 2603.1
## What's Changed
### UI Out of Preview
The UI is finally out of preview and the code from the UI branch has been pushed to main.
### Remove old MSU files before servicing
Fixes an issue where older cumulative update MSU files were mixed with newer files. In Windows 11 24H2, dism treats all MSU files in the same folder as possible update sources, causing servicing issues.
This will primarily be an issue for those of you who use ISO files that don't have the latest updates, or those of you who create your FFUs with the ESD media shortly after patch Tuesday.
### Adds OS-scoped update KB folders
The KB folder where the CU and .NET updates downloads to will now have sub-folders for each Windows version. You may notice that if selecting 25H2, that the KB sub-folder for Windows 11 will show 24H2, this is to keep consistent with Windows 11 LTSC 2024 and Windows Server 2025, which all use the same 24H2 source OS (25H2 is just an eKB, not a full OS Swap Windows release like 24H2 was).
### .NET Updates stored under their own dedicated KB folder
This is more so to keep things clean, rather than fixing any sort of technical issue
### Reworked Windows 10 LTSB\LTSC Cumulative Update Installation
Since Windows 10 is out of support, offline servicing Windows 10 LTSC builds that are still supported fails due to extended security update requirements. The workaround to this is to install the updates in audit mode. FFU Builder now creates an Apps\LTSCUpdate folder and copies the LCU for Windows 10 LTSC builds still in support to the folder and installs the update in audit mode.
### Fixes an issue with Update ADK failing for non-English languages
Fixed a bug where updating the ADK would fail on non-English installations of Windows due to an assumption that the add/remove programs display information would be in English. Update ADK should correctly identify if the latest release of the ADK is installed and work as expected.
### Added dependency validation when selecting Build USB drive and Copy Drivers
Fixed an issue where a build would begin even though Copy Drivers to USB was set to true but no USB was inserted. FFU Builder will now check before the build gets started and inform the user if no USB drive is inserted.
### Normalizes Windows LTSC release versions to handle driver downloads
Driver downloads would fail if you were building certain LTSC releases due to FFU Builder incorrectly using the LTSC release year instead of the base Windows client version information. When build LTSC FFUs, the drivers should now download as expected.
### Scopes select-all to visible filtered list items in drivers listview
When filtering the drivers listview and selecting all driver models using the select all header checkbox, the select all behavior was selecting everything, even the hidden models in the list. If then selecting Download Selected, FFU Builder would download all models, even hidden ones. This now fixes that issue to only select all visible models and download those selected models.
### Retain downloaded ESD files
FFU Builder will now allow you to retain a downloaded ESD file. There's a new option on the Build tab to Remove Downloaded ESD File(s) which is checked by default to keep with the previous behavior. The intent here is to prevent from having to re-download the ESD file every time you're doing a build. This gives you another option along with Allow VHDX Caching to reduce the need of redownloading media.
### Reduce the size of cached VHDX files
Added some code to reduce the size of the cached VHDX files.
### Include disk size in VHDX cache validation
Prevents reusing cached images when the requested disk size changes. Ensures the disk size property is properly saved and verified against existing cache items to maintain configuration accuracy. This makes it so that if you create a new build with a larger disk size and have Allow VHDX caching selected, it won't use a cached VHDX with a smaller size.
### Enhances file backup and cleanup for cancelled builds
Improves the current-run cleanup mechanism by tracking file downloads explicitly. This ensures that files downloaded via BITS or preserving older timestamps are correctly identified and removed during cleanup. Extends the run manifest schema to support file backups, allowing for safe restoration of pre-existing scripts and configuration files modified during a run. Additional cleanup logic now correctly prunes residual empty directories after tracked files are removed.
### Fixed an issue with arm64 ESD downloads
With the change to how ESD downloads work with 25H2 and the Media Creation Tool, arm64 was broken. This was fixed.
# 2602.1 UI Preview
## What's Changed
### Improved Automatic Matching for Surface devices
To keep inline with HP, Dell, and Lenovo, added support for Surface devices to leverage the SystemSKU values from WMI when doing automatic driver matching during deployment. Check https://github.com/rbalsleyMSFT/FFU/pull/394 for more information. Long story short, there's a new `SurfaceDriverIndex.json` file that is created when getting the models which gathers the WMI information per model as well as the download links for each model. This info is used to generate the DriverMapping.json file for Surface to allow for better matching.
There'll be deeper documentation on the new [docs site](https://rbalsleymsft.github.io/FFU/)
### Improved driver injection error handling when deploying drivers via USB
When drivers failed to be added from the USB drive during deployment, ApplyFFU.ps1 would fail with an error message and the deployment wouldn't complete. ApplyFFU.ps1 will now continue on failure and log the error and capture the setupapi.offline.log to the USB drive for troubleshooting if needed.
### Fixed an issue with Windows image index for non-English media
In some cases non-English media would cause the end-user to have to select which Windows SKU to select due to parsing the image name output and assuming the output was in English. BuildFFUVM.ps1 will now parse the edition metadata for each index. This should improve the experience for those that are creating FFUs from non-English media.
### Run builds in separate pwsh process instead of background jobs
In https://github.com/rbalsleyMSFT/FFU/pull/393, by changing the deprecated Get-WmiObject calls to Get-CimInstance, this actually broke console output. Still don't fully understand why GWMI was allowing background jobs to output console output to the calling pwsh Window but get-ciminstance wouldn't (WinRM, PowerShell Remoting, etc), but this required changing to running the build in a separate pwsh process. Between this and https://github.com/rbalsleyMSFT/FFU/pull/393, this should fix those that might build their FFUs on Servers and still expect to see console output.
### Fixed an issue with USB drive selection for same-model USB drives
When using the UI and selecting specific USB drives to create, the UI would allow you to select multiple of the same name, but would only create one of the drives. You should now be able to multi-select multiple USB drives with the same name and they should build as expected.
### Created new docs site
[FFU Builder docs](https://rbalsleymsft.github.io/FFU/) are now available! I'm still working on adding more documentation, but the layout of the site, the prereqs, quick start, and UI overview are done. I still have some stuff to migrate from the old docx file and some deep dive stuff to write up (Drivers, Apps, FAQs, Troubleshooting, etc). It should work well on both mobile and desktop. It also has built-in search capabilities to make it easy to find what you're interested in.
## New Contributors
* @JGehl99 made their first contribution in https://github.com/rbalsleyMSFT/FFU/pull/393
**Full Changelog**: https://github.com/rbalsleyMSFT/FFU/compare/v2601.1Preview...v2602.1Preview
# 2601.1 UI Preview
## What's Changed
### Improved WinPE driver copy reliability and logging
Fixed a bug where some drivers weren't being copied into WinPE media when using **Use Drivers Folder as PE Drivers Source** option (`UseDriversAsPEDrivers` parameter)
### Improved driver injection for long driver folder paths
In some cases some drivers weren't being copied to the FFU or WinPE deployment media due to long paths. This required some significant refactoring. [See this post](https://github.com/rbalsleyMSFT/FFU/discussions/375) for more details on the changes that were made and the reasoning behind them.
### Fixed an issue with WingetWin32Apps.Json file corruption during parallel app updates
A code refactor that was done to consolidate some of the winget application download work that both the UI and BuildFFUVM.ps1 script caused an issue where parallel writes to the WingetWin32Apps.json file was causing the file to corrupt, resulting in apps not installing as expected.
### Winget App installs now follow Applist.json order
Winget application installs were installing in an indeterministic way when the WingetWin32Apps.json file was created. The order will now follow the order listed in the AppList.json file.
### Support added for Winget Win32 app dependency handling
Some apps (Camtasia) require dependency apps to be installed first. Winget will download said dependency apps. Dependent applications will now install before the calling application. There is also deduplication support added in the event multiple applications have the same dependencies.
**Full Changelog**: [https://github.com/rbalsleyMSFT/FFU/compare/v2512.1Preview...v2601.1Preview](https://github.com/rbalsleyMSFT/FFU/compare/v2512.1Preview...v2601.1Preview)
# 2512.1 UI Preview
## What's Changed
### Refactored Cleanup logic into a shared module
Consolidates duplicated cleanup code by moving logic into a shared function, eliminating redundant implementations across multiple locations.
Removes standalone cleanup functions (Remove-FFU, Remove-Apps, Remove-Updates) and replaces scattered cleanup calls with a single invocation of Invoke-FFUPostBuildCleanup.
### Add 30 second delay to allow for Windows Security Platform to install
There was an issue where the Windows Security Platform would attempt to install in the VM during the build via `Update-Defender.ps1` however the install didn't always happen and on deployment of the FFU, Windows Update would show that the Windows Security Platform needed an update. I suspect this is related to the AppxSVC not being ready during Audit Mode. Adding a 30 second delay appears to work more reliably.
### Windows and .NET CU's now persist across builds
Content in the FFUDevelopment\KB folder was always deleted once it was used. Since the Windows CU is so large now, it doesn't make sense to delete it if a user wants it again and may not be using cached VHDX files.
Deletion of the KB folder is now correctly handled via the **Remove Downloaded Update Files** option on the Build tab.
### Skip CU downloads if the Windows ESD version is current or newer
Now that the Windows ESD media is kept up to date, there rarely will be a need to download the latest CU. There will always be a slight gap when the latest CU comes out and the updated media is available, but that's generally just a few days to a week.
The script will now do some parsing of the windows version of the ESD file and the latest CU and if the ESD is newer, the CU will not be downloaded.
### Fixes an issue with WingetWin32Apps.json file not being created if applications were pre-downloaded via the UI
Fixed a bug due to some code consolidation that broke scenarios where applications that were downloaded via the UI, but were not installing in the VM.
**Full Changelog**: https://github.com/rbalsleyMSFT/FFU/compare/v2511.1preview...v2511.2
# 2511.1 UI Preview
## What's Changed
### Major changes to drivers
A few weeks ago I wrote a [lengthy post](https://github.com/rbalsleyMSFT/FFU/discussions/350) asking for some help testing some changes that were added.
The summary of that post is that there have been significant changes for both Dell and HP driver downloads to leverage the SystemID for each model. This increases the total number of driver models that are exposed in the UI. This also requires the `DriverMapping.json` to be modified to require the SystemID and query the SystemID from WMI when doing automatic matching.
#### Driver folder structure changes on the USB drive - breaking change
Driver folder structure on the USB drive has also changed. The new structure is `Drivers\Make\Model` (e.g. `D:\Drivers\Lenovo\Lenovo 300w`). This structure is consistent with how the UI and `BuildFFUVM.ps1` script download and store drivers and automatically copy them. So if you've been following that, then no changes are required.
Please read [the post](https://github.com/rbalsleyMSFT/FFU/discussions/350) for more details on these changes to drivers.
### Windows 11 25H2 is now the default option for MCT/ESD downloads
For MCT/ESD downloads: Adds dynamic products.cab download functionality for Windows 11 using Windows Update service API instead of static MCT links. This is due to a change in how the MCT pulls the products.cab file. In other words, the Windows 11 25H2 ESD media is now updated each month (usually shortly after patch Tuesday)
### Added 8 new hardware manufactures for automatic driver matching during deployment
Extends hardware detection and driver mapping capabilities to support Panasonic, Viglen, AZW, Fujitsu, Getac, ByteSpeed, and Intel devices when applying the FFU to a device. This does not mean FFU Builder supports downloading drivers from these manufacturers. You'll still need to download the drivers for them manually. You can now create your own `DriverMapping.json` file to include these manufacturers.
Thanks to @arwidmark and the [Modern Driver Management](https://msendpointmgr.com/modern-driver-management/) team for the WMI queries.
### Fixed an issue with long paths when applying drivers from USB
Implemented SUBST drive mappings to shorten driver file paths within WinPE as some paths were causing dism to error when servicing drivers. You should see a Z:\ drive when applying drivers from the USB drive.
### Added an option to skip driver selection when multiple driver models are detected during deployment
Allows users to bypass driver installation by entering 0 at the selection prompt, providing flexibility for deployments that don't require driver updates.
### Add HTTP fallback for BITS transfer network authentication errors
Fixes an issue with standard users elevating PowerShell as Admin and getting BITS errors when trying to download content.
### Add -BitsPriority script parameter
Introduces a new parameter `-BitsPriority` with options `(Foreground, High, Normal, Low)` to control BITS download priority across the build system and UI, allowing users to optimize transfer speeds when needed.
The feature adds a priority selector to the UI with four options (Foreground, High, Normal, Low) and propagates the selection through the build script and common modules. Priority can be set via UI or command-line parameter with Normal as the default.
### BYO Apps: Add MSI path quoting to handle spaces in msiexec arguments
When specifying Build Your Own Apps msiexec arguments, if there were spaces in the argument list that weren't quoted properly, you'd get an error. This should now automatically add missing spaces in case you forget to add them or there are spaces in your application name.
### Misc Fixes
* Fixed some reliability issues when trying to download Lenovo drivers
* Fixed an issue with PPKG files with spaces
* Replaced SerialNumber with UniqueID for USB drive identification when building USB drives. USB drive manufacturers may use the same serial number for different drives, potentially causing data loss if the wrong drive is chosen.
* `-Threads` parameter has been added to `BuildFFUVM.ps1` which defaults to 5, matching the UI behavior. This value can be 1-64.
* ESD media downloads now use BITS by default
* Fixed an issue with multi-disk devices. Prior, if multiple disks were detected, ApplyFFU.ps1 would fail. Now a menu pops up asking the end user to select the disk they want to deploy the FFU to
## New Contributors
* @arwidmark made their first contribution in https://github.com/rbalsleyMSFT/FFU/pull/325
**Full Changelog**: https://github.com/rbalsleyMSFT/FFU/compare/v2509.1preview...v2511.1preview
# 2509.1 UI Preview
## What's Changed
### [Refactor: Enhance artifact cleanup for disabled features](https://github.com/rbalsleyMSFT/FFU/commit/1ab4093d54b7d9bda9f47d7819694e66ae8de357)
Renames `Remove-DisabledUpdates` to `Remove-DisabledArtifacts` to better reflect its expanded scope.
This function now also removes Office installation scripts and downloaded content if the Office installation is disabled via the `$InstallOffice` flag.
The function call is moved to run before app installations to ensure artifacts are removed prior to the installation phase.
### [Removes the VM workaround for MCT ESD builds](https://github.com/rbalsleyMSFT/FFU/commit/dc5877f398316969299ee03800f3d07c7d98a9ab)
Comments out the logic that forces app installation when building from a downloaded ESD file. This workaround was implemented to prevent an OOBE reboot loop but is no longer required. This should speed up scenarios where you want to download the ESD media, install the latest CU and .NET CU, and capture the FFU.
### [Update default disk size to 50GB in FFU scripts and UI](https://github.com/rbalsleyMSFT/FFU/commit/372360d7392ad945be0db889a68e1fff0ed3b5d6)
Changed the default disk size parameter from 30GB to 50GB in BuildFFUVM.ps1 and FFUUI.Core.psm1 to accommodate larger virtual machines.
Updated tooltip and default value in the UI XAML file to reflect the new disk size.
### [Adds auto-loading of previous configuration on startup](https://github.com/rbalsleyMSFT/FFU/commit/3ef26f2918977906ebe14e328f015ce4f1941dc3)
Implements a new feature to automatically load the previously saved environment when the UI is launched.
This improves user experience by restoring the last saved configuration, including selected applications and drivers, eliminating the need to manually reload them on each run.
The process loads the main `FFUConfig.json` and then proceeds to load associated Winget, BYO App, and Driver lists if they are defined. UI elements and checkboxes are updated accordingly to reflect the loaded state.
### [Improves UI state after environment autoload](https://github.com/rbalsleyMSFT/FFU/commit/bdf1b63833c83171aed63e8fc16702078ccd577b)
Updates the visibility of UI panels for Winget and drivers when a previous environment is automatically loaded.
This ensures that if Winget apps or driver models are present, their corresponding UI sections are made visible. Additionally, it updates the "select all" checkbox state for Winget results and attempts to pre-select the hardware make for loaded drivers.
### [Add restore defaults and centralize cleanup logic](https://github.com/rbalsleyMSFT/FFU/commit/f3316a017b73bf12cf1a66e3d03a63e29c437cb1)
Introduces a "Restore Defaults" feature in the UI to reset the environment. This action removes generated configuration files, ISOs, downloaded apps, updates, drivers, and FFUs.
The post-build cleanup logic is refactored from the main build script into a new common function. This new function is used by both the standard build process and the new restore defaults feature, promoting code reuse and simplifying maintenance.
### [Add option to dynamically build PE drivers](https://github.com/rbalsleyMSFT/FFU/commit/e2ccd11f07217b389f1622a69794224412e046e1)
Thanks to @JonasKloseBW for the original code for this in https://github.com/rbalsleyMSFT/FFU/pull/115
Introduces a new parameter, `UseDriversAsPEDrivers`, that allows WinPE drivers to be sourced directly from the main driver repository.
When enabled, the script scans all available drivers, parses their INF files, and copies only the essential driver types (e.g., storage, mouse, keyboard, touchpad, system devices) needed for WinPE. This eliminates the need to maintain a separate, manually curated `PEDrivers` folder.
The UI is updated with a new checkbox that becomes visible when "Copy PE Drivers" is selected, making this a sub-option. Parameter validation is also adjusted to support this new workflow.
### [Improve model name normalization for driver mapping](https://github.com/rbalsleyMSFT/FFU/commit/50713188bffcb64f1b0c1f9eb89e02a300e3de98)
Enhances the model name normalization function to better handle variations in hardware model strings. This change introduces specific rules to canonicalize "All-in-One" and screen size variants (e.g., "-in" or "inch") for more reliable matching against driver mapping rules.
Additionally, optimizes performance by normalizing the system model once before the comparison loop. Logging is also added to show the original and normalized model strings for easier debugging.
### [Defer cleanup of compressed driver source folders](https://github.com/rbalsleyMSFT/FFU/commit/c30ed923b68b933f719b9a2941043b813bf4fd3f)
Implements a deferred cleanup mechanism for driver source folders when they are compressed to a WIM and also used for WinPE.
When drivers are compressed, the original source folders are now preserved if they are also needed for WinPE driver injection. A marker file is created in these preserved folders.
A new cleanup step is added after the WinPE media creation to remove these preserved folders, ensuring they are available when needed but not left behind permanently.
### [Refactor config loading and improve error handling](https://github.com/rbalsleyMSFT/FFU/commit/8d7e4d106620761d0ae1a5133f6d6ba301131471)
Extracts the logic for importing supplemental assets (Winget, BYO, Drivers) into a new reusable function. This function is now called by both the manual and automatic configuration loaders, reducing code duplication.
Enhances the manual configuration loading process with more robust error handling. It now provides specific user-facing error messages for file read failures, empty files, and invalid JSON, improving the user experience when loading a malformed configuration.
When loading a configuration, if optional supplemental files like AppList.json are referenced but not found, an informational message is now displayed to the user instead of failing silently.
### [Add robust sanitization for names used in paths](https://github.com/rbalsleyMSFT/FFU/commit/cb14e84a26acaf5863aa3bb094dbf18424798875)
Introduces a new common function, `ConvertTo-SafeName`, to sanitize strings by removing characters that are invalid in Windows file paths.
This function is now used consistently when creating directory and file names for drivers (Dell, HP, Lenovo, Microsoft) and applications to prevent path-related errors. It replaces several ad-hoc sanitization methods with a single, more robust implementation.
### [Includes exit code fields when using Copy Apps button](https://github.com/rbalsleyMSFT/FFU/commit/f37647599a318da29b62154bebff8c8a857d3002)
Adds persistence of AdditionalExitCodes and IgnoreNonZeroExitCodes when exporting the UI list to prevent losing custom exit handling settings and maintain parity with the primary save routine.
### [Sanitizes app names for storage and paths](https://github.com/rbalsleyMSFT/FFU/commit/d1ca1231045e38316733495e1fdb8590a225be67)
Applies name sanitization when persisting the app list and when building/checking Win32 and Store download directories.
Prevents invalid characters in folder names, aligns persisted names with on-disk structure, and improves detection of existing content to avoid redundant downloads and errors.
### [Adds exit-code overrides and UI for winget apps](https://github.com/rbalsleyMSFT/FFU/commit/d9c0c9c68ee1769230c9789b5c7cb84bcff4d642)
Adds per-app control for additional accepted exit codes and ignoring nonzero exit codes to improve handling of installers with nonstandard returns.
Exposes editable fields in the app list UI, persists them across search defaults, import/export, and pre-download save, and applies overrides during app resolution to honor configured behavior.
### [Adds UI/CLI to copy additional FFUs to USB build](https://github.com/rbalsleyMSFT/FFU/commit/15a5b16b39887b71ae545c638d57183c97bdf629)
- Enables selecting multiple existing FFU images to include on the deployment USB for easier distribution and testing.
- Adds a UI option with selectable, sortable list from the capture folder, refresh support, and persisted selections.
- Validates that selections exist when the option is enabled to prevent empty runs.
- Supports unattended/CLI flows by prompting early or accepting a preselected list for USB creation; deduplicates and logs chosen files.
- Always includes the just-built (or latest available) FFU as a base.
- Improves no-FFU handling and streamlines multi-FFU selection workflow.
### [Standardizes JSON output: depth, UTF-8, key order](https://github.com/rbalsleyMSFT/FFU/commit/6562d16ce500197b428b51915332c6649df302df)
- Sorts top-level config keys before serialization for deterministic files and cleaner diffs.
- Increases JSON depth to 10 to retain nested settings.
- Writes JSON as UTF-8 via Set-Content for consistent encoding.
- Applies across config export and UI save flows.
### [Adds Windows 11 25H2 mapping](https://github.com/rbalsleyMSFT/FFU/commit/eaa3e1e6af5c25e0f8b185f8107e017782b0f00f)
Extends supported Windows 11 releases to include 25H2. Default is still 24H2.
* Update USBImagingToolCreator.ps1 by @jrollmann in https://github.com/rbalsleyMSFT/FFU/pull/262
## New Contributors
* @jrollmann made their first contribution in https://github.com/rbalsleyMSFT/FFU/pull/262
# 2507.1 UI Preview # 2507.1 UI Preview
Waaay too many to list. Just watch the Youtube video in the Readme :) Waaay too many to list. Just watch the Youtube video in the Readme :)
@@ -2,16 +2,6 @@
<Add SourcePath="C:\FFUDevelopment\Apps\Office" OfficeClientEdition="64" Channel="Current"> <Add SourcePath="C:\FFUDevelopment\Apps\Office" OfficeClientEdition="64" Channel="Current">
<Product ID="O365ProPlusRetail"> <Product ID="O365ProPlusRetail">
<Language ID="MatchOS" /> <Language ID="MatchOS" />
<ExcludeApp ID="Access" />
<ExcludeApp ID="Lync" />
<ExcludeApp ID="Publisher" />
<ExcludeApp ID="Bing" />
</Product> </Product>
</Add> </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> </Configuration>
@@ -1,3 +1,11 @@
# Allow Orchestrator.ps1 to override the app list file paths while preserving legacy defaults.
param(
[Parameter()]
[string]$wingetAppsJsonFile = (Join-Path -Path $PSScriptRoot -ChildPath "WinGetWin32Apps.json"),
[Parameter()]
[string]$userAppsJsonFile = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath "UserAppList.json")
)
function Invoke-Process { function Invoke-Process {
[CmdletBinding(SupportsShouldProcess)] [CmdletBinding(SupportsShouldProcess)]
param param
@@ -92,6 +100,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 { function Install-Applications {
param( param(
[Parameter(Mandatory)] [Parameter(Mandatory)]
@@ -177,6 +228,15 @@ function Install-Applications {
$ignoreNonZeroExitCodes = $app.IgnoreNonZeroExitCodes $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) { if ($null -eq $argumentsToPass -or $argumentsToPass.Count -eq 0) {
Write-Host "Running command: $($app.CommandLine) (no arguments)" Write-Host "Running command: $($app.CommandLine) (no arguments)"
$result = Invoke-Process -FilePath $app.CommandLine -AdditionalSuccessCodes $additionalSuccessCodes -IgnoreNonZeroExitCodes $ignoreNonZeroExitCodes $result = Invoke-Process -FilePath $app.CommandLine -AdditionalSuccessCodes $additionalSuccessCodes -IgnoreNonZeroExitCodes $ignoreNonZeroExitCodes
@@ -195,11 +255,6 @@ function Install-Applications {
} }
} }
# Define paths for the JSON files
$wingetAppsJsonFile = "$PSScriptRoot\WinGetWin32Apps.json"
# Look for UserAppList.json one directory level up from the script's location. This keeps the user specific json files (AppList.json and UserAppList.json in the Apps dir)
$userAppsJsonFile = Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath "UserAppList.json"
# Initialize empty arrays for apps from each source # Initialize empty arrays for apps from each source
$wingetApps = @() $wingetApps = @()
$userApps = @() $userApps = @()
@@ -234,9 +289,9 @@ if ($wingetApps.Count -gt 0) {
Install-Applications -apps $wingetApps Install-Applications -apps $wingetApps
} }
# Read the UserAppList.json file if it exists # Read the configured BYO app list file if it exists
if (Test-Path -Path $userAppsJsonFile) { if (Test-Path -Path $userAppsJsonFile) {
Write-Host "Processing UserAppList.json..." Write-Host "Processing $(Split-Path -Path $userAppsJsonFile -Leaf)..."
try { try {
$userContent = Get-Content -Path $userAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json $userContent = Get-Content -Path $userAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json
if ($userContent -is [array]) { if ($userContent -is [array]) {
@@ -244,19 +299,19 @@ if (Test-Path -Path $userAppsJsonFile) {
Write-Host "Found $(($userApps | Measure-Object).Count) user-defined apps." Write-Host "Found $(($userApps | Measure-Object).Count) user-defined apps."
} }
elseif ($userContent) { elseif ($userContent) {
$userApps = @($userContent) # Ensure it's an array $userApps = @($userContent)
Write-Host "Found 1 user-defined app." Write-Host "Found 1 user-defined app."
} }
else { else {
Write-Host "UserAppList.json is empty or invalid." Write-Host "$(Split-Path -Path $userAppsJsonFile -Leaf) is empty or invalid."
} }
} }
catch { catch {
Write-Error "Failed to read or parse UserAppList.json file: $_" Write-Error "Failed to read or parse BYO app list file '$userAppsJsonFile': $_"
} }
} }
else { else {
Write-Host "UserAppList.json file not found. Skipping." Write-Host "BYO app list file not found at $userAppsJsonFile. Skipping."
} }
# Install User apps if any were found # Install User apps if any were found
@@ -28,8 +28,26 @@ Write-Host "---------------------------------------------------" -ForegroundColo
# Define the path to the scripts # Define the path to the scripts
$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition $scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
# Define the list of scripts to run, order doesn't matter - if you have a custom script, add it here # Resolve the configured BYO app list path for runtime orchestration.
$appInstallConfigPath = Join-Path -Path $scriptPath -ChildPath "AppInstallConfig.json"
$userAppsJsonFile = Join-Path -Path (Split-Path -Parent $scriptPath) -ChildPath "UserAppList.json"
if (Test-Path -Path $appInstallConfigPath) {
try {
$appInstallConfig = Get-Content -Path $appInstallConfigPath -Raw | ConvertFrom-Json
if ($null -ne $appInstallConfig -and $appInstallConfig.PSObject.Properties.Match('UserAppListPath').Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($appInstallConfig.UserAppListPath)) {
$userAppsJsonFile = $appInstallConfig.UserAppListPath
Write-Host "Using BYO app list path from AppInstallConfig.json: $userAppsJsonFile"
}
}
catch {
Write-Host "Failed to parse AppInstallConfig.json. Falling back to default BYO app list path."
}
}
# Define the list of scripts to run
$scriptList = @( $scriptList = @(
"Install-LTSCUpdate.ps1",
"Update-Defender.ps1", "Update-Defender.ps1",
"Install-Office.ps1", "Install-Office.ps1",
"Update-MSRT.ps1", "Update-MSRT.ps1",
@@ -50,7 +68,6 @@ foreach ($script in $scriptList) {
switch ($script) { switch ($script) {
"Install-Win32Apps.ps1" { "Install-Win32Apps.ps1" {
$wingetAppsJsonFile = Join-Path -Path $scriptPath -ChildPath "WinGetWin32Apps.json" $wingetAppsJsonFile = Join-Path -Path $scriptPath -ChildPath "WinGetWin32Apps.json"
$userAppsJsonFile = Join-Path -Path (Split-Path -Parent $scriptPath) -ChildPath "UserAppList.json"
if (-not (Test-Path -Path $wingetAppsJsonFile) -and -not (Test-Path -Path $userAppsJsonFile)) { if (-not (Test-Path -Path $wingetAppsJsonFile) -and -not (Test-Path -Path $userAppsJsonFile)) {
$shouldRun = $false $shouldRun = $false
} }
@@ -68,8 +85,13 @@ foreach ($script in $scriptList) {
Write-Host "---------------------------------------------------" -ForegroundColor Yellow Write-Host "---------------------------------------------------" -ForegroundColor Yellow
Write-Host " Running script: $script " -ForegroundColor Yellow Write-Host " Running script: $script " -ForegroundColor Yellow
Write-Host "---------------------------------------------------" -ForegroundColor Yellow Write-Host "---------------------------------------------------" -ForegroundColor Yellow
# Run script and wait for it to finish # Run script and wait for it to finish.
& $scriptFile if ($script -eq "Install-Win32Apps.ps1") {
& $scriptFile -UserAppsJsonFile $userAppsJsonFile
}
else {
& $scriptFile
}
} }
} }
+3459 -1392
View File
File diff suppressed because it is too large Load Diff
+314 -151
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. This script acts as the primary host for the UI, connecting the user interface with the underlying build and logic modules.
#> #>
#Requires -RunAsAdministrator
[CmdletBinding()] [CmdletBinding()]
[System.STAThread()] [System.STAThread()]
@@ -44,7 +45,9 @@ $script:uiState = [PSCustomObject]@{
logData = $null; logData = $null;
logStreamReader = $null; logStreamReader = $null;
pollTimer = $null; pollTimer = $null;
lastConfigFilePath = $null currentBuildProcess = $null;
lastConfigFilePath = $null;
loadedDeviceNamingMode = $null
}; };
Flags = @{ Flags = @{
installAppsForcedByUpdates = $false; installAppsForcedByUpdates = $false;
@@ -53,7 +56,10 @@ $script:uiState = [PSCustomObject]@{
lastSortProperty = $null; lastSortProperty = $null;
lastSortAscending = $true; lastSortAscending = $true;
isBuilding = $false; isBuilding = $false;
isCleanupRunning = $false isCleanupRunning = $false;
isFluentSupported = $false;
deviceNamingModeWasExplicitlyChanged = $false;
suppressDeviceNamingChangeTracking = $false
}; };
Defaults = @{}; Defaults = @{};
LogFilePath = "$FFUDevelopmentPath\FFUDevelopment_UI.log" LogFilePath = "$FFUDevelopmentPath\FFUDevelopment_UI.log"
@@ -118,6 +124,9 @@ $reader = New-Object System.IO.StringReader($xamlString)
$xmlReader = [System.Xml.XmlReader]::Create($reader) $xmlReader = [System.Xml.XmlReader]::Create($reader)
$window = [Windows.Markup.XamlReader]::Load($xmlReader) $window = [Windows.Markup.XamlReader]::Load($xmlReader)
# Apply Fluent theme before the window renders (requires PowerShell 7.5+ / .NET 9+)
Initialize-FluentTheme -Window $window -ThemeMode "System" -State $script:uiState
$window.Add_Loaded({ $window.Add_Loaded({
# Pass the state object to all initialization functions # Pass the state object to all initialization functions
$script:uiState.Window = $window $script:uiState.Window = $window
@@ -126,6 +135,17 @@ $window.Add_Loaded({
Initialize-UIDefaults -State $script:uiState Initialize-UIDefaults -State $script:uiState
Initialize-DynamicUIElements -State $script:uiState Initialize-DynamicUIElements -State $script:uiState
Register-EventHandlers -State $script:uiState Register-EventHandlers -State $script:uiState
# Populate the Home page build and release status after the window initializes
Start-HomeStatusRefresh -State $script:uiState
# Attempt automatic load of previous environment (silent)
try {
Invoke-AutoLoadPreviousEnvironment -State $script:uiState
}
catch {
WriteLog "Auto-load previous environment failed: $($_.Exception.Message)"
}
}) })
@@ -139,7 +159,7 @@ $script:uiState.Controls.btnRun.Add_Click({
if ($script:uiState.Flags.isBuilding -and -not $script:uiState.Flags.isCleanupRunning) { if ($script:uiState.Flags.isBuilding -and -not $script:uiState.Flags.isCleanupRunning) {
$btnRun.IsEnabled = $false $btnRun.IsEnabled = $false
$script:uiState.Controls.txtStatus.Text = "Cancel requested. Stopping build..." $script:uiState.Controls.txtStatus.Text = "Cancel requested. Stopping build..."
WriteLog "Cancel requested by user. Stopping background build job." WriteLog "Cancel requested by user. Stopping background build process."
# Stop the timer # Stop the timer
if ($null -ne $script:uiState.Data.pollTimer) { if ($null -ne $script:uiState.Data.pollTimer) {
@@ -154,92 +174,71 @@ $script:uiState.Controls.btnRun.Add_Click({
$script:uiState.Data.logStreamReader = $null $script:uiState.Data.logStreamReader = $null
} }
# Stop and remove the running build job # Stop the running build process
$jobToStop = $script:uiState.Data.currentBuildJob $processToStop = $script:uiState.Data.currentBuildProcess
$script:uiState.Data.currentBuildJob = $null $script:uiState.Data.currentBuildProcess = $null
if ($null -ne $jobToStop) {
try { if ($null -ne $processToStop) {
# Attempt graceful stop first # Recursively terminate the build process and any children (DISM, setup tools, etc.)
Stop-Job -Job $jobToStop -ErrorAction SilentlyContinue function Stop-ProcessTree {
Wait-Job -Job $jobToStop -Timeout 5 -ErrorAction SilentlyContinue | Out-Null param([int]$parentPid)
} $children = Get-CimInstance Win32_Process -Filter "ParentProcessId=$parentPid" -ErrorAction SilentlyContinue
catch { foreach ($child in $children) {
WriteLog "Stop-Job threw: $($_.Exception.Message)" Stop-ProcessTree -parentPid $child.ProcessId
}
try { Stop-Process -Id $parentPid -Force -ErrorAction SilentlyContinue } catch {}
} }
# If the job's hosting process is still alive, kill its process tree to stop child tools like DISM
try { try {
$jobProcId = $null Stop-ProcessTree -parentPid $processToStop.Id
if ($null -ne $jobToStop.ChildJobs -and $jobToStop.ChildJobs.Count -gt 0) { WriteLog "Background build process stopped (PID: $($processToStop.Id))."
$jobProcId = $jobToStop.ChildJobs[0].ProcessId }
} catch {
if ($jobProcId) { WriteLog "Error terminating build process tree: $($_.Exception.Message)"
# Recursively terminate the job process and any children }
function Stop-ProcessTree { }
param([int]$parentPid)
$children = Get-CimInstance Win32_Process -Filter "ParentProcessId=$parentPid" -ErrorAction SilentlyContinue # Safety net: kill any active DISM capture still running
foreach ($child in $children) { try {
Stop-ProcessTree -parentPid $child.ProcessId $dismCaptures = Get-CimInstance Win32_Process -Filter "Name='DISM.EXE'" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match '/Capture-FFU' }
} foreach ($p in $dismCaptures) {
try { Stop-Process -Id $parentPid -Force -ErrorAction SilentlyContinue } catch {} try { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } catch {}
}
}
catch {
WriteLog "Error stopping DISM capture processes: $($_.Exception.Message)"
}
# Also stop Office ODT setup.exe if running (to avoid recreating files after cleanup)
try {
$officePathForKill = $null
# Prefer explicit UI path
$uiOfficePath = $script:uiState.Controls.txtOfficePath.Text
if (-not [string]::IsNullOrWhiteSpace($uiOfficePath)) {
$officePathForKill = $uiOfficePath
}
else {
# Fall back to the last config path only if known
$lastConfigPathLocal = $script:uiState.Data.lastConfigFilePath
if (-not [string]::IsNullOrWhiteSpace($lastConfigPathLocal)) {
$ffuDevRoot = Split-Path (Split-Path $lastConfigPathLocal -Parent) -Parent
if (-not [string]::IsNullOrWhiteSpace($ffuDevRoot)) {
$officePathForKill = Join-Path $ffuDevRoot 'Apps\Office'
} }
Stop-ProcessTree -parentPid $jobProcId
} }
} }
catch {
WriteLog "Error terminating job process tree: $($_.Exception.Message)"
}
# Safety net: kill any active DISM capture still running # Only proceed when a valid Office folder exists
try { if ($officePathForKill -and (Test-Path -LiteralPath $officePathForKill -PathType Container)) {
$dismCaptures = Get-CimInstance Win32_Process -Filter "Name='DISM.EXE'" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match '/Capture-FFU' } $setupProcs = Get-CimInstance Win32_Process -Filter "Name='setup.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.ExecutablePath -like "$officePathForKill*" }
foreach ($p in $dismCaptures) { foreach ($p in $setupProcs) {
try { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } catch {} try { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } catch {}
} }
} }
catch { }
WriteLog "Error stopping DISM capture processes: $($_.Exception.Message)" catch {
} WriteLog "Error stopping Office setup.exe processes: $($_.Exception.Message)"
# Also stop Office ODT setup.exe if running (to avoid recreating files after cleanup)
try {
$officePathForKill = $null
# Prefer explicit UI path
$uiOfficePath = $script:uiState.Controls.txtOfficePath.Text
if (-not [string]::IsNullOrWhiteSpace($uiOfficePath)) {
$officePathForKill = $uiOfficePath
}
else {
# Fall back to the last config path only if known
$lastConfigPathLocal = $script:uiState.Data.lastConfigFilePath
if (-not [string]::IsNullOrWhiteSpace($lastConfigPathLocal)) {
$ffuDevRoot = Split-Path (Split-Path $lastConfigPathLocal -Parent) -Parent
if (-not [string]::IsNullOrWhiteSpace($ffuDevRoot)) {
$officePathForKill = Join-Path $ffuDevRoot 'Apps\Office'
}
}
}
# Only proceed when a valid Office folder exists
if ($officePathForKill -and (Test-Path -LiteralPath $officePathForKill -PathType Container)) {
$setupProcs = Get-CimInstance Win32_Process -Filter "Name='setup.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.ExecutablePath -like "$officePathForKill*" }
foreach ($p in $setupProcs) {
try { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } catch {}
}
}
}
catch {
WriteLog "Error stopping Office setup.exe processes: $($_.Exception.Message)"
}
try {
Remove-Job -Job $jobToStop -Force -ErrorAction SilentlyContinue
WriteLog "Background build job stopped and removed."
}
catch {
WriteLog "Error removing background build job: $($_.Exception.Message)"
}
} }
# Start cleanup using the same BuildFFUVM.ps1 via -Cleanup short-circuit # Start cleanup using the same BuildFFUVM.ps1 via -Cleanup short-circuit
@@ -280,13 +279,40 @@ $script:uiState.Controls.btnRun.Add_Click({
CleanupCurrentRunDownloads = $removeCurrentRunToo CleanupCurrentRunDownloads = $removeCurrentRunToo
} }
$cleanupScriptBlock = { # Start cleanup in a separate pwsh process so the UI stays responsive
param($buildParams, $PSScriptRoot) $pwshPath = Join-Path -Path $PSHOME -ChildPath 'pwsh.exe'
& "$PSScriptRoot\BuildFFUVM.ps1" @buildParams if (-not (Test-Path -Path $pwshPath)) {
$pwshPath = 'pwsh'
} }
# Start cleanup job $cleanupScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'BuildFFUVM.ps1'
$script:uiState.Data.currentBuildJob = Start-Job -ScriptBlock $cleanupScriptBlock -ArgumentList @($cleanupParams, $PSScriptRoot)
# Build argument list for cleanup.
# -Cleanup is a [switch] in BuildFFUVM.ps1, so do not pass a value after it.
# Use -Param:$true/$false syntax for boolean parameters to avoid argument transformation errors.
$cleanupArgs = @(
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-File', $cleanupScriptPath,
'-ConfigFile', $cleanupParams.ConfigFile,
'-Cleanup',
"-RemoveApps:$($cleanupParams.RemoveApps)",
"-RemoveUpdates:$($cleanupParams.RemoveUpdates)",
"-CleanupDrivers:$($cleanupParams.CleanupDrivers)",
"-CleanupCurrentRunDownloads:$($cleanupParams.CleanupCurrentRunDownloads)"
)
$startCleanupParams = @{
FilePath = $pwshPath
ArgumentList = $cleanupArgs
WorkingDirectory = $ffuDevPath
PassThru = $true
}
if ($Host.Name -eq 'ConsoleHost') {
$startCleanupParams['NoNewWindow'] = $true
}
$script:uiState.Data.currentBuildProcess = Start-Process @startCleanupParams
# Wait for log file to appear (or open immediately if it exists) # Wait for log file to appear (or open immediately if it exists)
$logWaitTimeout = 60 $logWaitTimeout = 60
@@ -306,14 +332,14 @@ $script:uiState.Controls.btnRun.Add_Click({
WriteLog "Warning: Main log file not found at $mainLogPath after waiting. Monitor tab will not update during cleanup." WriteLog "Warning: Main log file not found at $mainLogPath after waiting. Monitor tab will not update during cleanup."
} }
# Create a timer to poll the cleanup job # Create a timer to poll the cleanup process
$script:uiState.Data.pollTimer = New-Object System.Windows.Threading.DispatcherTimer $script:uiState.Data.pollTimer = New-Object System.Windows.Threading.DispatcherTimer
$script:uiState.Data.pollTimer.Interval = [TimeSpan]::FromSeconds(1) $script:uiState.Data.pollTimer.Interval = [TimeSpan]::FromSeconds(1)
$script:uiState.Flags.isCleanupRunning = $true $script:uiState.Flags.isCleanupRunning = $true
$script:uiState.Data.pollTimer.Add_Tick({ $script:uiState.Data.pollTimer.Add_Tick({
param($sender, $e) param($sender, $e)
$currentJob = $script:uiState.Data.currentBuildJob $currentProcess = $script:uiState.Data.currentBuildProcess
# Read new lines from log # Read new lines from log
if ($null -ne $script:uiState.Data.logStreamReader) { if ($null -ne $script:uiState.Data.logStreamReader) {
@@ -326,13 +352,13 @@ $script:uiState.Controls.btnRun.Add_Click({
} }
} }
if ($null -eq $currentJob -or $null -eq $script:uiState.Data.pollTimer) { if ($null -eq $currentProcess -or $null -eq $script:uiState.Data.pollTimer) {
if ($null -ne $sender) { $sender.Stop() } if ($null -ne $sender) { $sender.Stop() }
$script:uiState.Data.pollTimer = $null $script:uiState.Data.pollTimer = $null
return return
} }
if ($currentJob.State -in 'Completed', 'Failed', 'Stopped') { if ($currentProcess.HasExited) {
if ($null -ne $sender) { $sender.Stop() } if ($null -ne $sender) { $sender.Stop() }
$script:uiState.Data.pollTimer = $null $script:uiState.Data.pollTimer = $null
@@ -355,10 +381,8 @@ $script:uiState.Controls.btnRun.Add_Click({
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed' $script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
$script:uiState.Controls.pbOverallProgress.Value = 0 $script:uiState.Controls.pbOverallProgress.Value = 0
# Receive and remove cleanup job # Clear cleanup process state
$currentJob | Receive-Job -ErrorAction SilentlyContinue | Out-Null $script:uiState.Data.currentBuildProcess = $null
Remove-Job -Job $currentJob -Force
$script:uiState.Data.currentBuildJob = $null
# Reset flags and button # Reset flags and button
$script:uiState.Flags.isCleanupRunning = $false $script:uiState.Flags.isCleanupRunning = $false
@@ -376,8 +400,11 @@ $script:uiState.Controls.btnRun.Add_Click({
# Not currently building: start a new build # Not currently building: start a new build
$btnRun.IsEnabled = $false $btnRun.IsEnabled = $false
# Switch to Monitor Tab # Switch to Monitor page via navigation
$script:uiState.Controls.MainTabControl.SelectedItem = $script:uiState.Controls.MonitorTab $monitorIndex = 8 # Monitor is the 9th item (index 8) in the navigation list
if ($null -ne $script:uiState.Controls.lstNavigation) {
$script:uiState.Controls.lstNavigation.SelectedIndex = $monitorIndex
}
# Clear previous log data and reset autoscroll # Clear previous log data and reset autoscroll
if ($null -ne $script:uiState.Data.logData) { if ($null -ne $script:uiState.Data.logData) {
@@ -392,8 +419,137 @@ $script:uiState.Controls.btnRun.Add_Click({
# Gather config on the UI thread before starting the job # Gather config on the UI thread before starting the job
$config = Get-UIConfig -State $script:uiState $config = Get-UIConfig -State $script:uiState
# Validate Additional FFU selection if enabled
if ($config.BuildUSBDrive -and $config.CopyAdditionalFFUFiles -and (($null -eq $config.AdditionalFFUFiles) -or ($config.AdditionalFFUFiles.Count -eq 0))) {
[System.Windows.MessageBox]::Show("Please select at least one additional FFU file to copy, or uncheck 'Copy Additional FFU Files'.", "Selection Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: Additional FFU selection required."
return
}
if ($config.EnableVMNetworking -and $config.InstallApps -and [string]::IsNullOrWhiteSpace([string]$config.VMSwitchName)) {
[System.Windows.MessageBox]::Show("Select or enter a VM Switch Name before enabling VM networking.", "VM Switch Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: VM switch required for experimental networking."
return
}
if ($config.CopyUnattend -and $config.InjectUnattend) {
[System.Windows.MessageBox]::Show("Copy Unattend.xml and Inject Unattend.xml cannot both be selected. Choose only one unattend delivery method.", "Unattend Selection Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: choose only one unattend delivery method."
return
}
if ($config.CopyUnattend -or $config.InjectUnattend) {
$selectedUnattendArch = if ($config.WindowsArch -ieq 'arm64') { 'arm64' } else { 'x64' }
$selectedUnattendSourcePath = if ($selectedUnattendArch -eq 'arm64') {
[string]$config.UnattendArm64FilePath
}
else {
[string]$config.UnattendX64FilePath
}
if ([string]::IsNullOrWhiteSpace($selectedUnattendSourcePath)) {
[System.Windows.MessageBox]::Show("Select a valid $selectedUnattendArch unattend XML file before using Copy Unattend.xml or Inject Unattend.xml.", "Unattend File Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: unattend file path required."
return
}
if (-not (Test-Path -Path $selectedUnattendSourcePath -PathType Leaf)) {
[System.Windows.MessageBox]::Show("The selected $selectedUnattendArch unattend XML file was not found:`n$selectedUnattendSourcePath", "Unattend File Missing", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: unattend file missing."
return
}
$selectedUnattendFileInfo = Get-Item -Path $selectedUnattendSourcePath -ErrorAction SilentlyContinue
if (($null -eq $selectedUnattendFileInfo) -or ($selectedUnattendFileInfo.Length -le 0)) {
[System.Windows.MessageBox]::Show("The selected $selectedUnattendArch unattend XML file is empty:`n$selectedUnattendSourcePath", "Unattend File Empty", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: unattend file empty."
return
}
}
if ($config.DeviceNamingMode -eq 'Prompt') {
if (-not $config.CopyUnattend) {
[System.Windows.MessageBox]::Show("Select Copy Unattend.xml before using 'Prompt for Device Name'.", "Copy Unattend Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: prompt naming requires Copy Unattend.xml."
return
}
}
elseif ($config.DeviceNamingMode -eq 'Template') {
if ([string]::IsNullOrWhiteSpace([string]$config.DeviceNameTemplate)) {
[System.Windows.MessageBox]::Show("Specify a device name before using 'Specify Device Name'.", "Device Name Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: device name required."
return
}
if (-not ($config.CopyUnattend -or $config.InjectUnattend)) {
[System.Windows.MessageBox]::Show("Select Copy Unattend.xml or Inject Unattend.xml before using 'Specify Device Name'.", "Unattend Selection Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: unattend delivery method required for device naming."
return
}
$templateWithoutSupportedVariables = ([string]$config.DeviceNameTemplate) -replace '(?i)%serial%', ''
if ($templateWithoutSupportedVariables -match '%') {
[System.Windows.MessageBox]::Show("Only the %serial% device name variable is supported.", "Unsupported Device Name Variable", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: unsupported device name variable."
return
}
if ($config.InjectUnattend -and (-not $config.CopyUnattend) -and ([string]$config.DeviceNameTemplate -match '(?i)%serial%')) {
[System.Windows.MessageBox]::Show("The %serial% device name variable is only supported when Copy Unattend.xml is selected.", "Unsupported Inject Unattend Setting", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: %serial% requires Copy Unattend.xml."
return
}
}
elseif ($config.DeviceNamingMode -eq 'Prefixes') {
if (-not $config.CopyUnattend) {
[System.Windows.MessageBox]::Show("Select Copy Unattend.xml before using 'Specify a list of Prefixes'.", "Copy Unattend Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: prefixes require Copy Unattend.xml."
return
}
$hasSavedPrefixesPath = -not [string]::IsNullOrWhiteSpace([string]$config.DeviceNamePrefixesPath) -and (Test-Path -Path $config.DeviceNamePrefixesPath -PathType Leaf)
if ((($null -eq $config.DeviceNamePrefixes) -or ($config.DeviceNamePrefixes.Count -eq 0)) -and -not $hasSavedPrefixesPath) {
[System.Windows.MessageBox]::Show("Enter at least one prefix or choose a valid prefixes file before using 'Specify a list of Prefixes'.", "Prefixes Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: prefixes required."
return
}
}
elseif ($config.DeviceNamingMode -eq 'SerialComputerNames') {
if (-not $config.CopyUnattend) {
[System.Windows.MessageBox]::Show("Select Copy Unattend.xml before using 'Specify Serial to Device Name Mapping'.", "Copy Unattend Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: serial computer-name mapping requires Copy Unattend.xml."
return
}
$hasSavedSerialComputerNamesPath = -not [string]::IsNullOrWhiteSpace([string]$config.DeviceNameSerialComputerNamesPath) -and (Test-Path -Path $config.DeviceNameSerialComputerNamesPath -PathType Leaf)
if ((($null -eq $config.DeviceNameSerialComputerNames) -or ($config.DeviceNameSerialComputerNames.Count -eq 0)) -and -not $hasSavedSerialComputerNamesPath) {
[System.Windows.MessageBox]::Show("Enter CSV content or choose a valid Serial Computer Names CSV Mapping File Path before using 'Specify Serial to Device Name Mapping'.", "Serial Mapping Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: serial computer-name mapping required."
return
}
}
$configFilePath = Join-Path $config.FFUDevelopmentPath "\config\FFUConfig.json" $configFilePath = Join-Path $config.FFUDevelopmentPath "\config\FFUConfig.json"
$config | ConvertTo-Json -Depth 10 | Set-Content -Path $configFilePath -Encoding UTF8 # Sort top-level keys alphabetically for consistent output
$sortedConfig = [ordered]@{}
foreach ($k in ($config.Keys | Sort-Object)) { $sortedConfig[$k] = $config[$k] }
$sortedConfig | ConvertTo-Json -Depth 10 | Set-Content -Path $configFilePath -Encoding UTF8
$script:uiState.Data.lastConfigFilePath = $configFilePath $script:uiState.Data.lastConfigFilePath = $configFilePath
if ($config.InstallOffice -and $config.OfficeConfigXMLFile) { if ($config.InstallOffice -and $config.OfficeConfigXMLFile) {
@@ -404,33 +560,45 @@ $script:uiState.Controls.btnRun.Add_Click({
$txtStatus.Text = "Executing BuildFFUVM.ps1 in the background..." $txtStatus.Text = "Executing BuildFFUVM.ps1 in the background..."
WriteLog "Executing BuildFFUVM.ps1 in the background..." WriteLog "Executing BuildFFUVM.ps1 in the background..."
# Prepare parameters for splatting # Start BuildFFUVM.ps1 in a separate pwsh process.
$buildParams = @{ # This keeps the UI responsive and restores console interaction (Write-Host / Read-Host) when available.
ConfigFile = $configFilePath $pwshPath = Join-Path -Path $PSHOME -ChildPath 'pwsh.exe'
if (-not (Test-Path -Path $pwshPath)) {
$pwshPath = 'pwsh'
} }
$buildScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'BuildFFUVM.ps1'
$pwshArgs = @(
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-File', $buildScriptPath,
'-ConfigFile', $configFilePath
)
if ($config.Verbose) { if ($config.Verbose) {
$buildParams['Verbose'] = $true $pwshArgs += '-Verbose'
} }
# Define the script block to run in the background job # Delete the old log file before starting the build process to ensure we don't read stale content.
$scriptBlock = {
param($buildParams, $PSScriptRoot)
# This script runs in a new process. BuildFFUVM.ps1 is expected to handle its own module imports.
& "$PSScriptRoot\BuildFFUVM.ps1" @buildParams
}
# Delete the old log file before starting the build job to ensure we don't read stale content.
$mainLogPath = Join-Path $config.FFUDevelopmentPath "FFUDevelopment.log" $mainLogPath = Join-Path $config.FFUDevelopmentPath "FFUDevelopment.log"
if (Test-Path $mainLogPath) { if (Test-Path $mainLogPath) {
WriteLog "Removing old FFUDevelopment.log file." WriteLog "Removing old FFUDevelopment.log file."
Remove-Item -Path $mainLogPath -Force Remove-Item -Path $mainLogPath -Force
} }
# Start the job and store it in the shared state object $startBuildParams = @{
$script:uiState.Data.currentBuildJob = Start-Job -ScriptBlock $scriptBlock -ArgumentList @($buildParams, $PSScriptRoot) FilePath = $pwshPath
ArgumentList = $pwshArgs
WorkingDirectory = $config.FFUDevelopmentPath
PassThru = $true
}
if ($Host.Name -eq 'ConsoleHost') {
$startBuildParams['NoNewWindow'] = $true
}
# Wait for the new log file to be created by the background job. # Start the build process and store it in the shared state object
$script:uiState.Data.currentBuildProcess = Start-Process @startBuildParams
# Wait for the new log file to be created by the background process.
$logWaitTimeout = 15 # seconds $logWaitTimeout = 15 # seconds
$watch = [System.Diagnostics.Stopwatch]::StartNew() $watch = [System.Diagnostics.Stopwatch]::StartNew()
while (-not (Test-Path $mainLogPath) -and $watch.Elapsed.TotalSeconds -lt $logWaitTimeout) { while (-not (Test-Path $mainLogPath) -and $watch.Elapsed.TotalSeconds -lt $logWaitTimeout) {
@@ -455,7 +623,7 @@ $script:uiState.Controls.btnRun.Add_Click({
$script:uiState.Data.pollTimer.Add_Tick({ $script:uiState.Data.pollTimer.Add_Tick({
param($sender, $e) param($sender, $e)
# This scriptblock runs on the UI thread, so it can safely access script-scoped variables # This scriptblock runs on the UI thread, so it can safely access script-scoped variables
$currentJob = $script:uiState.Data.currentBuildJob $currentProcess = $script:uiState.Data.currentBuildProcess
# Read from log stream # Read from log stream
if ($null -ne $script:uiState.Data.logStreamReader) { if ($null -ne $script:uiState.Data.logStreamReader) {
@@ -479,8 +647,8 @@ $script:uiState.Controls.btnRun.Add_Click({
} }
} }
# If job is somehow null or the timer has been nulled out, stop the timer # If process is somehow null or the timer has been nulled out, stop the timer
if ($null -eq $currentJob -or $null -eq $script:uiState.Data.pollTimer) { if ($null -eq $currentProcess -or $null -eq $script:uiState.Data.pollTimer) {
if ($null -ne $sender) { if ($null -ne $sender) {
$sender.Stop() $sender.Stop()
} }
@@ -488,8 +656,8 @@ $script:uiState.Controls.btnRun.Add_Click({
return return
} }
# Check if the job has reached a terminal state # Check if the build process has exited
if ($currentJob.State -in 'Completed', 'Failed', 'Stopped') { if ($currentProcess.HasExited) {
# Stop the timer, we're done polling # Stop the timer, we're done polling
if ($null -ne $sender) { if ($null -ne $sender) {
$sender.Stop() $sender.Stop()
@@ -525,42 +693,26 @@ $script:uiState.Controls.btnRun.Add_Click({
$script:uiState.Data.logStreamReader = $null $script:uiState.Data.logStreamReader = $null
} }
# Determine final status based on job result and whether cleanup was running (should be false here) $exitCode = $currentProcess.ExitCode
# Determine final status based on process exit code
$finalStatusText = "FFU build completed successfully." $finalStatusText = "FFU build completed successfully."
if ($currentJob.State -eq 'Failed') { if ($exitCode -ne 0) {
$reason = $null
Receive-Job -Job $currentJob -Keep -ErrorVariable jobErrors -ErrorAction SilentlyContinue | Out-Null
if ($null -ne $jobErrors -and $jobErrors.Count -gt 0) {
$reason = ($jobErrors | Select-Object -Last 1).ToString()
}
if ([string]::IsNullOrWhiteSpace($reason) -and $currentJob.JobStateInfo.Reason) {
$reason = $currentJob.JobStateInfo.Reason.Message
}
if ([string]::IsNullOrWhiteSpace($reason)) {
$reason = "An unknown error occurred. The job failed without a specific reason."
}
$finalStatusText = "FFU build failed. Check FFUDevelopment.log for details." $finalStatusText = "FFU build failed. Check FFUDevelopment.log for details."
WriteLog "BuildFFUVM.ps1 job failed. Reason: $reason" WriteLog "BuildFFUVM.ps1 process failed with exit code: $exitCode"
[System.Windows.MessageBox]::Show("The build process failed. Please check the $FFUDevelopmentPath\FFUDevelopment.log file for details.`n`nError: $reason", "Build Error", "OK", "Error") | Out-Null [System.Windows.MessageBox]::Show("The build process failed. Please check the $FFUDevelopmentPath\FFUDevelopment.log file for details.`n`nExit code: $exitCode", "Build Error", "OK", "Error") | Out-Null
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed' $script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
} }
else { else {
WriteLog "BuildFFUVM.ps1 job completed successfully." WriteLog "BuildFFUVM.ps1 process completed successfully."
$script:uiState.Controls.pbOverallProgress.Value = 100 $script:uiState.Controls.pbOverallProgress.Value = 100
} }
# Update UI elements # Update UI elements
$script:uiState.Controls.txtStatus.Text = $finalStatusText $script:uiState.Controls.txtStatus.Text = $finalStatusText
# Receive & remove job and clear state # Clear process state
$currentJob | Receive-Job -ErrorAction SilentlyContinue | Out-Null $script:uiState.Data.currentBuildProcess = $null
Remove-Job -Job $currentJob -Force
$script:uiState.Data.currentBuildJob = $null
# Reset button and flags for next run # Reset button and flags for next run
$script:uiState.Flags.isBuilding = $false $script:uiState.Flags.isBuilding = $false
@@ -619,9 +771,9 @@ $window.Add_SourceInitialized({
# Register cleanup to reclaim memory and revert LongPathsEnabled setting when the UI window closes # Register cleanup to reclaim memory and revert LongPathsEnabled setting when the UI window closes
$window.Add_Closed({ $window.Add_Closed({
# Stop any running build job if the window is closed # Stop any running build process if the window is closed
if ($null -ne $script:uiState.Data.currentBuildJob) { if ($null -ne $script:uiState.Data.currentBuildProcess) {
WriteLog "UI closing, stopping background build job." WriteLog "UI closing, stopping background build process."
# Stop the timer # Stop the timer
if ($null -ne $script:uiState.Data.pollTimer) { if ($null -ne $script:uiState.Data.pollTimer) {
@@ -636,17 +788,28 @@ $window.Add_Closed({
$script:uiState.Data.logStreamReader = $null $script:uiState.Data.logStreamReader = $null
} }
# Stop and remove the job $processToStop = $script:uiState.Data.currentBuildProcess
$jobToStop = $script:uiState.Data.currentBuildJob $script:uiState.Data.currentBuildProcess = $null
$script:uiState.Data.currentBuildJob = $null # Clear it from state first
try { try {
Stop-Job -Job $jobToStop # Terminate the build process and any children
Remove-Job -Job $jobToStop function Stop-ProcessTree {
WriteLog "Background job stopped and removed." param([int]$parentPid)
$children = Get-CimInstance Win32_Process -Filter "ParentProcessId=$parentPid" -ErrorAction SilentlyContinue
foreach ($child in $children) {
Stop-ProcessTree -parentPid $child.ProcessId
}
try { Stop-Process -Id $parentPid -Force -ErrorAction SilentlyContinue } catch {}
}
if ($null -ne $processToStop -and -not $processToStop.HasExited) {
Stop-ProcessTree -parentPid $processToStop.Id
}
WriteLog "Background process stopped."
} }
catch { catch {
WriteLog "Error stopping or removing background job: $($_.Exception.Message)" WriteLog "Error stopping background build process: $($_.Exception.Message)"
} }
} }
File diff suppressed because it is too large Load Diff
+18 -57
View File
@@ -3,11 +3,8 @@ param (
[string]$adkPath = 'C:\Program Files (x86)\Windows Kits\10\', [string]$adkPath = 'C:\Program Files (x86)\Windows Kits\10\',
[string]$WindowsArch = 'x64', [string]$WindowsArch = 'x64',
[bool]$CopyPEDrivers = $false, [bool]$CopyPEDrivers = $false,
[string]$CaptureISO = "$PSScriptRoot\WinPE_FFU_Capture_x64.iso",
[string]$DeployISO = "$PSScriptRoot\WinPE_FFU_Deploy_x64.iso", [string]$DeployISO = "$PSScriptRoot\WinPE_FFU_Deploy_x64.iso",
[string]$LogFile = "$PSScriptRoot\Create-PEMedia.log", [string]$LogFile = "$PSScriptRoot\Create-PEMedia.log"
[bool]$Capture,
[bool]$Deploy = $true
) )
function WriteLog($LogText) { function WriteLog($LogText) {
@@ -77,12 +74,7 @@ function Invoke-Process {
} }
function New-PEMedia { function New-PEMedia {
param ( param ()
[Parameter()]
[bool]$Capture,
[Parameter()]
[bool]$Deploy
)
#Need to use the Demployment and Imaging tools environment to create winPE media #Need to use the Demployment and Imaging tools environment to create winPE media
$DandIEnv = "$adkPath`Assessment and Deployment Kit\Deployment Tools\DandISetEnv.bat" $DandIEnv = "$adkPath`Assessment and Deployment Kit\Deployment Tools\DandISetEnv.bat"
$WinPEFFUPath = "$FFUDevelopmentPath\WinPE" $WinPEFFUPath = "$FFUDevelopmentPath\WinPE"
@@ -135,36 +127,21 @@ function New-PEMedia {
Add-WindowsPackage -Path "$WinPEFFUPath\mount" -PackagePath $PackagePath | Out-Null Add-WindowsPackage -Path "$WinPEFFUPath\mount" -PackagePath $PackagePath | Out-Null
WriteLog "Adding package complete" WriteLog "Adding package complete"
} }
If ($Capture) { WriteLog "Copying $FFUDevelopmentPath\WinPEDeployFFUFiles\* to WinPE deploy media"
WriteLog "Copying $FFUDevelopmentPath\WinPECaptureFFUFiles\* to WinPE capture media" Copy-Item -Path "$FFUDevelopmentPath\WinPEDeployFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | Out-Null
Copy-Item -Path "$FFUDevelopmentPath\WinPECaptureFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | out-null WriteLog 'Copy complete'
WriteLog "Copy complete" #If $CopyPEDrivers = $true, add drivers to WinPE media using dism
#Remove Bootfix.bin - for BIOS systems, shouldn't be needed, but doesn't hurt to remove for our purposes if ($CopyPEDrivers) {
#Remove-Item -Path "$WinPEFFUPath\media\boot\bootfix.bin" -Force | Out-null WriteLog "Adding drivers to WinPE media"
# $WinPEISOName = 'WinPE_FFU_Capture.iso' try {
$WinPEISOFile = $CaptureISO Add-WindowsDriver -Path "$WinPEFFUPath\Mount" -Driver "$FFUDevelopmentPath\PEDrivers" -Recurse -ErrorAction SilentlyContinue | Out-null
# $Capture = $false
}
If ($Deploy) {
WriteLog "Copying $FFUDevelopmentPath\WinPEDeployFFUFiles\* to WinPE deploy media"
Copy-Item -Path "$FFUDevelopmentPath\WinPEDeployFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | Out-Null
WriteLog 'Copy complete'
#If $CopyPEDrivers = $true, add drivers to WinPE media using dism
if ($CopyPEDrivers) {
WriteLog "Adding drivers to WinPE media"
try {
Add-WindowsDriver -Path "$WinPEFFUPath\Mount" -Driver "$FFUDevelopmentPath\PEDrivers" -Recurse -ErrorAction SilentlyContinue | Out-null
}
catch {
WriteLog 'Some drivers failed to be added to the FFU. This can be expected. Continuing.'
}
WriteLog "Adding drivers complete"
} }
# $WinPEISOName = 'WinPE_FFU_Deploy.iso' catch {
$WinPEISOFile = $DeployISO WriteLog 'Some drivers failed to be added to the FFU. This can be expected. Continuing.'
}
# $Deploy = $false WriteLog "Adding drivers complete"
} }
$WinPEISOFile = $DeployISO
WriteLog 'Dismounting WinPE media' WriteLog 'Dismounting WinPE media'
Dismount-WindowsImage -Path "$WinPEFFUPath\mount" -Save | Out-Null Dismount-WindowsImage -Path "$WinPEFFUPath\mount" -Save | Out-Null
WriteLog 'Dismount complete' WriteLog 'Dismount complete'
@@ -179,21 +156,10 @@ function New-PEMedia {
WriteLog "Creating WinPE ISO at $WinPEISOFile" WriteLog "Creating WinPE ISO at $WinPEISOFile"
# & "$OSCDIMG" -m -o -u2 -udfver102 -bootdata:2`#p0,e,b$OSCDIMGPath\etfsboot.com`#pEF,e,b$OSCDIMGPath\Efisys_noprompt.bin $WinPEFFUPath\media $FFUDevelopmentPath\$WinPEISOName | Out-null # & "$OSCDIMG" -m -o -u2 -udfver102 -bootdata:2`#p0,e,b$OSCDIMGPath\etfsboot.com`#pEF,e,b$OSCDIMGPath\Efisys_noprompt.bin $WinPEFFUPath\media $FFUDevelopmentPath\$WinPEISOName | Out-null
if($WindowsArch -eq 'x64'){ if($WindowsArch -eq 'x64'){
if($Capture){ $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys_noprompt.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
}
if($Deploy){
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
}
} }
elseif($WindowsArch -eq 'arm64'){ elseif($WindowsArch -eq 'arm64'){
if($Capture){ $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys_noprompt.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
}
if($Deploy){
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
}
} }
Invoke-Process $OSCDIMG $OSCDIMGArgs Invoke-Process $OSCDIMG $OSCDIMGArgs
WriteLog "ISO created successfully" WriteLog "ISO created successfully"
@@ -201,9 +167,4 @@ function New-PEMedia {
Remove-Item -Path "$WinPEFFUPath" -Recurse -Force Remove-Item -Path "$WinPEFFUPath" -Recurse -Force
WriteLog 'Cleanup complete' WriteLog 'Cleanup complete'
} }
if($Capture){ New-PEMedia
New-PEMedia -Capture $Capture
}
if($Deploy){
New-PEMedia -Deploy $Deploy
}
+3 -5
View File
@@ -85,12 +85,10 @@ graph TD
subgraph "VM-Based Capture (-InstallApps)" subgraph "VM-Based Capture (-InstallApps)"
direction LR direction LR
BB[Create Hyper-V VM from VHDX]; BB[Create Hyper-V VM from VHDX];
BB --> BC["Create WinPE Capture Media iso"]; BB --> BE["Start VM: Boots to Audit Mode"];
BC --> BD[Configure network share for capture];
BD --> BE["Start VM: Boots to Audit Mode"];
BE --> BF[Orchestrator runs: Installs apps, syspreps, shuts down]; BE --> BF[Orchestrator runs: Installs apps, syspreps, shuts down];
BF --> BG[VM reboots from Capture Media]; BF --> BG[Host optimizes and remounts VHDX];
BG --> BH["CaptureFFU.ps1 runs, saves FFU to share, shuts down"]; BG --> BH["DISM captures FFU directly from host-mounted VHDX"];
end end
subgraph "Direct VHDX Capture" subgraph "Direct VHDX Capture"
@@ -0,0 +1,140 @@
# Provides shared cleanup functionality for both UI and build script.
function Invoke-FFUPostBuildCleanup {
param(
[string]$RootPath,
[string]$AppsPath,
[string]$DriversPath,
[string]$FFUCapturePath,
[string]$DeployISOPath,
[string]$AppsISOPath,
[string]$KBPath,
[bool]$RemoveDeployISO = $false,
[bool]$RemoveAppsISO = $false,
[bool]$RemoveDrivers = $false,
[bool]$RemoveFFU = $false,
[bool]$RemoveApps = $false,
[bool]$RemoveUpdates = $false,
[bool]$RemoveDownloadedESD = $false
)
$originalProgressPreference = $ProgressPreference
$ProgressPreference = 'SilentlyContinue'
try {
WriteLog "CommonCleanup: Starting cleanup (DeployISO=$RemoveDeployISO AppsISO=$RemoveAppsISO Drivers=$RemoveDrivers FFU=$RemoveFFU Apps=$RemoveApps Updates=$RemoveUpdates RemoveDownloadedESD=$RemoveDownloadedESD KBPath=$KBPath)."
# Primary ISO paths (new naming/location)
if ($RemoveDeployISO -and -not [string]::IsNullOrWhiteSpace($DeployISOPath) -and (Test-Path -LiteralPath $DeployISOPath)) {
WriteLog "CommonCleanup: Removing $DeployISOPath"
try { Remove-Item -LiteralPath $DeployISOPath -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $DeployISOPath : $($_.Exception.Message)" }
}
if ($RemoveAppsISO -and -not [string]::IsNullOrWhiteSpace($AppsISOPath) -and (Test-Path -LiteralPath $AppsISOPath)) {
WriteLog "CommonCleanup: Removing $AppsISOPath"
try { Remove-Item -LiteralPath $AppsISOPath -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $AppsISOPath : $($_.Exception.Message)" }
}
# Legacy / root-level WinPE ISOs (pattern-based)
if ($RemoveDeployISO) {
Get-ChildItem -LiteralPath $RootPath -Filter 'WinPE_FFU_Deploy*.iso' -ErrorAction SilentlyContinue | ForEach-Object {
try { WriteLog "CommonCleanup: Removing legacy deploy ISO $($_.FullName)"; Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing legacy deploy ISO $($_.FullName) : $($_.Exception.Message)" }
}
}
if ($RemoveDrivers -and -not [string]::IsNullOrWhiteSpace($DriversPath) -and (Test-Path -LiteralPath $DriversPath -PathType Container)) {
WriteLog "CommonCleanup: Removing contents of $DriversPath (preserving Drivers.json and DriverMapping.json)"
try {
# Preserve drivers json files
$driverItems = Get-ChildItem -LiteralPath $DriversPath -Force -ErrorAction SilentlyContinue | Where-Object { @('Drivers.json', 'DriverMapping.json') -notcontains $_.Name }
if ($driverItems) {
$driverItems | Remove-Item -Force -Recurse -ErrorAction SilentlyContinue
}
}
catch { WriteLog "CommonCleanup: Driver content cleanup issue: $($_.Exception.Message)" }
}
if ($RemoveFFU -and -not [string]::IsNullOrWhiteSpace($FFUCapturePath) -and (Test-Path -LiteralPath $FFUCapturePath -PathType Container)) {
WriteLog "CommonCleanup: Removing FFU files in $FFUCapturePath"
Get-ChildItem -LiteralPath $FFUCapturePath -Filter *.ffu -ErrorAction SilentlyContinue | ForEach-Object {
try { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing FFU $($_.FullName) : $($_.Exception.Message)" }
}
}
if ($RemoveApps -and -not [string]::IsNullOrWhiteSpace($AppsPath) -and (Test-Path -LiteralPath $AppsPath -PathType Container)) {
$win32 = Join-Path $AppsPath 'Win32'
$store = Join-Path $AppsPath 'MSStore'
if (Test-Path -LiteralPath $win32) {
WriteLog "CommonCleanup: Removing $win32"
try { Remove-Item -LiteralPath $win32 -Recurse -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $win32 : $($_.Exception.Message)" }
}
if (Test-Path -LiteralPath $store) {
WriteLog "CommonCleanup: Removing $store"
try { Remove-Item -LiteralPath $store -Recurse -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $store : $($_.Exception.Message)" }
}
$office = Join-Path $AppsPath 'Office'
if ((Test-Path -LiteralPath $office) -and $InstallOffice) {
WriteLog "CommonCleanup: Checking for Office artifacts in $office"
$officeSub = Join-Path $office 'Office'
if (Test-Path -LiteralPath $officeSub) {
WriteLog "CommonCleanup: Removing $officeSub"
try { Remove-Item -LiteralPath $officeSub -Recurse -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $officeSub : $($_.Exception.Message)" }
}
$setupExe = Join-Path $office 'setup.exe'
if (Test-Path -LiteralPath $setupExe) {
WriteLog "CommonCleanup: Removing $setupExe"
try { Remove-Item -LiteralPath $setupExe -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $setupExe : $($_.Exception.Message)" }
}
}
}
# Always remove LTSC update staging folder (out-of-band cleanup exception)
if (-not [string]::IsNullOrWhiteSpace($AppsPath) -and (Test-Path -LiteralPath $AppsPath -PathType Container)) {
$ltscUpdateFolder = Join-Path $AppsPath 'LTSCUpdate'
if (Test-Path -LiteralPath $ltscUpdateFolder) {
WriteLog "CommonCleanup: Removing LTSC update staging folder $ltscUpdateFolder"
try { Remove-Item -LiteralPath $ltscUpdateFolder -Recurse -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $ltscUpdateFolder : $($_.Exception.Message)" }
}
}
if ($RemoveUpdates) {
if (-not [string]::IsNullOrWhiteSpace($AppsPath) -and (Test-Path -LiteralPath $AppsPath)) {
# Remove per-run app update payloads stored under Apps
$appUpdateDirs = @('Defender', 'Edge', 'MSRT', 'OneDrive')
foreach ($d in $appUpdateDirs) {
$target = Join-Path $AppsPath $d
if (Test-Path -LiteralPath $target) {
WriteLog "CommonCleanup: Removing update folder $target"
try { Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $target : $($_.Exception.Message)" }
}
}
}
if (-not [string]::IsNullOrWhiteSpace($KBPath) -and (Test-Path -LiteralPath $KBPath)) {
# Remove Windows/.NET CU downloads stored under KB
WriteLog "CommonCleanup: Removing downloaded updates in $KBPath"
try { Remove-Item -LiteralPath $KBPath -Recurse -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $KBPath : $($_.Exception.Message)" }
}
}
# Remove downloaded ESD files from the root path when requested
if ($RemoveDownloadedESD -and -not [string]::IsNullOrWhiteSpace($RootPath) -and (Test-Path -LiteralPath $RootPath -PathType Container)) {
WriteLog "CommonCleanup: Removing downloaded ESD files in $RootPath"
Get-ChildItem -LiteralPath $RootPath -Filter *.esd -File -ErrorAction SilentlyContinue | ForEach-Object {
try {
WriteLog "CommonCleanup: Removing ESD $($_.FullName)"
Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop
}
catch {
WriteLog "CommonCleanup: Failed removing ESD $($_.FullName) : $($_.Exception.Message)"
}
}
}
WriteLog "CommonCleanup: Completed."
}
catch {
WriteLog "CommonCleanup: Fatal cleanup error $($_.Exception.Message)"
}
finally {
$ProgressPreference = $originalProgressPreference
}
}
Export-ModuleMember -Function Invoke-FFUPostBuildCleanup
+188 -4
View File
@@ -12,6 +12,10 @@ $script:CommonCoreLogFilePath = $null
# Mutex for log file access # Mutex for log file access
$script:commonCoreLogMutexName = "Global\FFUCommonCoreLogMutex" # Unique name $script:commonCoreLogMutexName = "Global\FFUCommonCoreLogMutex" # Unique name
$script:commonCoreLogMutex = New-Object System.Threading.Mutex($false, $script:commonCoreLogMutexName) $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 to set the log file path for this module
function Set-CommonCoreLogPath { 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 # Centralized WriteLog function
function WriteLog { function WriteLog {
[CmdletBinding()] [CmdletBinding()]
@@ -136,6 +157,79 @@ function Invoke-Process {
return $cmd return $cmd
} }
function Get-RunManifestPathForDownloadTarget {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Destination
)
try {
$currentPath = Split-Path -Path $Destination -Parent
if ([string]::IsNullOrWhiteSpace($currentPath)) { return $null }
while ($currentPath) {
$manifestPath = Join-Path -Path $currentPath -ChildPath '.session\currentRun.json'
if (Test-Path -LiteralPath $manifestPath -PathType Leaf) {
return $manifestPath
}
$parentPath = Split-Path -Path $currentPath -Parent
if ([string]::IsNullOrWhiteSpace($parentPath) -or $parentPath -eq $currentPath) {
break
}
$currentPath = $parentPath
}
}
catch {
WriteLog "Get-RunManifestPathForDownloadTarget failed for '$Destination': $($_.Exception.Message)"
}
return $null
}
function Register-CurrentRunDownloadTarget {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Destination
)
if ([string]::IsNullOrWhiteSpace($Destination)) { return }
$manifestPath = Get-RunManifestPathForDownloadTarget -Destination $Destination
if ([string]::IsNullOrWhiteSpace($manifestPath)) { return }
$mutexName = 'Global\FFUCurrentRunDownloadTargetsMutex'
$mutex = New-Object System.Threading.Mutex($false, $mutexName)
try {
$null = $mutex.WaitOne()
$manifest = Get-Content -LiteralPath $manifestPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
if ($null -eq $manifest) { return }
if ($null -eq $manifest.PSObject.Properties['DownloadTargets']) {
Add-Member -InputObject $manifest -MemberType NoteProperty -Name DownloadTargets -Value @()
}
$downloadTargets = @($manifest.DownloadTargets)
if ($Destination -notin $downloadTargets) {
$downloadTargets += $Destination
$manifest.DownloadTargets = $downloadTargets
$manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $manifestPath -Encoding UTF8
WriteLog "Registered current-run download target: $Destination"
}
}
catch {
WriteLog "Register-CurrentRunDownloadTarget failed for '$Destination': $($_.Exception.Message)"
}
finally {
try { $mutex.ReleaseMutex() | Out-Null } catch {}
$mutex.Dispose()
}
}
# Function to download a file using BITS with retry and error handling # Function to download a file using BITS with retry and error handling
function Start-BitsTransferWithRetry { function Start-BitsTransferWithRetry {
param ( param (
@@ -143,20 +237,40 @@ function Start-BitsTransferWithRetry {
[string]$Source, [string]$Source,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$Destination, [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'
}
}
# Register destination so cancel cleanup can remove this run's downloaded files
# even when file timestamps are inherited from the source.
Register-CurrentRunDownloadTarget -Destination $Destination
$attempt = 0 $attempt = 0
$lastError = $null $lastError = $null
$notLoggedOnHResult = [int]0x800704dd
$fallbackTriggered = $false
while ($attempt -lt $Retries) { while ($attempt -lt $Retries -and -not $fallbackTriggered) {
$OriginalVerbosePreference = $VerbosePreference $OriginalVerbosePreference = $VerbosePreference
$OriginalProgressPreference = $ProgressPreference $OriginalProgressPreference = $ProgressPreference
try { try {
$VerbosePreference = 'SilentlyContinue' $VerbosePreference = 'SilentlyContinue'
$ProgressPreference = 'SilentlyContinue' $ProgressPreference = 'SilentlyContinue'
Start-BitsTransfer -Source $Source -Destination $Destination -ErrorAction Stop Start-BitsTransfer -Source $Source -Destination $Destination -Priority $Priority -ErrorAction Stop
$ProgressPreference = $OriginalProgressPreference $ProgressPreference = $OriginalProgressPreference
$VerbosePreference = $OriginalVerbosePreference $VerbosePreference = $OriginalVerbosePreference
@@ -166,7 +280,24 @@ function Start-BitsTransferWithRetry {
catch { catch {
$lastError = $_ $lastError = $_
$attempt++ $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) Start-Sleep -Seconds (1 * $attempt)
} }
finally { finally {
@@ -179,6 +310,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)" WriteLog "Failed to download $Source after $Retries attempts. Last Error: $($lastError.Exception.Message)"
throw $lastError throw $lastError
} }
@@ -194,4 +360,22 @@ function Set-Progress {
WriteLog "[PROGRESS] $Percentage | $Message" WriteLog "[PROGRESS] $Percentage | $Message"
} }
function ConvertTo-SafeName {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Name
)
# Replace invalid Windows filename characters (<>:"/\|?* and control chars) with a dash
$sanitized = $Name -replace '[<>:\"/\\|?*\x00-\x1F]', '-'
# Collapse multiple consecutive dashes
$sanitized = $sanitized -replace '-{2,}', '-'
# Trim leading/trailing spaces, periods, and dashes
$sanitized = $sanitized.Trim(' ', '.', '-')
if ([string]::IsNullOrWhiteSpace($sanitized)) {
$sanitized = 'Unnamed'
}
return $sanitized
}
Export-ModuleMember -Function * Export-ModuleMember -Function *
@@ -0,0 +1,300 @@
<#
.SYNOPSIS
Common Dell driver helpers (catalog index, model listing, latest package selection).
#>
function Convert-DellVendorVersion {
param([Parameter(Mandatory=$true)][string]$VendorVersion)
$segments = $VendorVersion.Split('.') | ForEach-Object {
if ($_ -match '^\d+$') { [int]$_ } else { 0 }
}
return ,$segments
}
function Compare-DellVendorVersion {
param(
[int[]]$Left,
[int[]]$Right
)
$len = [Math]::Max($Left.Length,$Right.Length)
for ($i=0; $i -lt $len; $i++) {
$l = if ($i -lt $Left.Length) { $Left[$i] } else { 0 }
$r = if ($i -lt $Right.Length) { $Right[$i] } else { 0 }
if ($l -gt $r) { return 1 }
if ($l -lt $r) { return -1 }
}
return 0
}
function Get-DellCatalogIndex {
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][string]$DriversFolder
)
$dellFolder = Join-Path $DriversFolder 'Dell'
if (-not (Test-Path $dellFolder)) { New-Item -Path $dellFolder -ItemType Directory -Force | Out-Null }
$cabPath = Join-Path $dellFolder 'CatalogIndexPC.cab'
$xmlPath = Join-Path $dellFolder 'CatalogIndexPC.xml'
$url = 'https://downloads.dell.com/catalog/CatalogIndexPC.cab'
$need = $true
if (Test-Path $xmlPath) {
$ageDays = ((Get-Date) - (Get-Item $xmlPath).CreationTime).TotalDays
if ($ageDays -lt 7) { $need = $false }
}
if ($need) {
if (Test-Path $cabPath) { Remove-Item $cabPath -Force -ErrorAction SilentlyContinue }
if (Test-Path $xmlPath) { Remove-Item $xmlPath -Force -ErrorAction SilentlyContinue }
Start-BitsTransferWithRetry -Source $url -Destination $cabPath
Invoke-Process -FilePath Expand.exe -ArgumentList """$cabPath"" ""$xmlPath""" | Out-Null
Remove-Item $cabPath -Force -ErrorAction SilentlyContinue
}
if (-not (Test-Path $xmlPath)) { throw "Dell CatalogIndexPC XML missing: $xmlPath" }
return $xmlPath
}
function Get-DellClientModels {
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][string]$CatalogIndexXmlPath
)
$settings = New-Object System.Xml.XmlReaderSettings
$settings.IgnoreWhitespace = $true
$settings.IgnoreComments = $true
$reader = [System.Xml.XmlReader]::Create($CatalogIndexXmlPath,$settings)
$models = [System.Collections.Generic.List[pscustomobject]]::new()
try {
while ($reader.Read()) {
if ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq 'GroupManifest') {
# Read subtree to pick out brand/model/systemID + path
$sub = $reader.ReadSubtree()
$doc = New-Object System.Xml.XmlDocument
$doc.Load($sub)
$sub.Dispose()
# Use local-name() to ignore namespaces
$brandNode = $doc.SelectSingleNode("//*[local-name()='SupportedSystems']/*[local-name()='Brand']")
if (-not $brandNode) { continue }
$brandDisplay = ($brandNode.SelectSingleNode("*[local-name()='Display']")?.InnerText).Trim()
$modelNode = $brandNode.SelectSingleNode("*[local-name()='Model']")
if (-not $modelNode) { continue }
$modelNumber = ($modelNode.SelectSingleNode("*[local-name()='Display']")?.InnerText).Trim()
$systemId = $modelNode.GetAttribute('systemID')
$manifestInfo = $doc.SelectSingleNode("//*[local-name()='ManifestInformation']")
if (-not $manifestInfo) { continue }
$pathAttr = $manifestInfo.GetAttribute('path')
if (-not $pathAttr) { continue }
$cabUrl = 'https://downloads.dell.com/' + $pathAttr
# Normalize model display using GroupManifest Display CDATA if available (strip 'PDK Catalog for')
$gmDisplayNode = $doc.SelectSingleNode("/*[local-name()='GroupManifest']/*[local-name()='Display']")
$modelFull = $null
if ($gmDisplayNode -and $gmDisplayNode.InnerText) {
$rawDisplay = $gmDisplayNode.InnerText.Trim()
$modelFull = ($rawDisplay -replace '^\s*PDK Catalog for\s+','').Trim()
}
if ([string]::IsNullOrWhiteSpace($modelFull)) {
# Fallback: assemble from brand/model nodes (legacy heuristic)
$prefixedModelNumber = $modelNumber
if ($modelNumber -and $brandDisplay) {
if ($modelNumber.StartsWith($brandDisplay,[System.StringComparison]::OrdinalIgnoreCase)) {
$prefixedModelNumber = $modelNumber
}
else {
$prefixedModelNumber = "$brandDisplay $modelNumber"
}
}
elseif ($brandDisplay -and -not $modelNumber) {
$prefixedModelNumber = $brandDisplay
}
$modelFull = $prefixedModelNumber
}
$modelDisplay = "$modelFull ($systemId)"
$models.Add([pscustomobject]@{
Brand = $brandDisplay
ModelNumber = $modelNumber
SystemId = $systemId
CabRelativePath = $pathAttr
CabUrl = $cabUrl
ModelDisplay = $modelDisplay
})
}
}
}
finally {
$reader.Dispose()
}
return $models
}
function Get-DellLatestDriverPackages {
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][string]$ModelXmlPath,
[Parameter(Mandatory=$true)][string]$WindowsArch,
[Parameter(Mandatory=$true)][int]$WindowsRelease
)
if (-not (Test-Path $ModelXmlPath)) { throw "Model XML not found: $ModelXmlPath" }
$xml = [xml](Get-Content -Path $ModelXmlPath -Raw)
# Collect all SoftwareComponent nodes
$components = $xml.SelectNodes("//*[local-name()='SoftwareComponent']")
if (-not $components) { return @() }
$rawPackages = [System.Collections.Generic.List[pscustomobject]]::new()
foreach ($comp in $components) {
$ctype = $comp.SelectSingleNode("*[local-name()='ComponentType']")
if (-not $ctype) { continue }
if ($ctype.GetAttribute('value') -ne 'DRVR') { continue }
# OS filtering (arch only release filtering intentionally minimal for now)
$osNodes = @($comp.SelectNodes("*[local-name()='SupportedOperatingSystems']/*[local-name()='OperatingSystem']"))
if (-not $osNodes) { continue }
$validOS = $osNodes | Where-Object { $_.GetAttribute('osArch') -eq $WindowsArch } | Select-Object -First 1
if (-not $validOS) { continue }
$path = $comp.GetAttribute('path')
if (-not $path) { continue }
$downloadUrl = "https://downloads.dell.com/$path"
$fileName = [IO.Path]::GetFileName($path)
$vendorVersion = $comp.GetAttribute('vendorVersion')
$versionArr = if ($vendorVersion) { Convert-DellVendorVersion $vendorVersion } else { @(0) }
$dateTimeAttr = $comp.GetAttribute('dateTime')
$dt = Get-Date
if ($dateTimeAttr) {
try { $dt = [DateTime]::Parse($dateTimeAttr) } catch { }
}
$categoryNode = $comp.SelectSingleNode("*[local-name()='Category']/*[local-name()='Display']")
$category = if ($categoryNode) { $categoryNode.InnerText.Trim() } else { 'Uncategorized' }
# Collect componentIDs (SupportedDevices + SupportedDCHDevices)
$compIds = [System.Collections.Generic.List[string]]::new()
$devNodes = @($comp.SelectNodes(".//*[local-name()='Device']"))
foreach ($dn in $devNodes) {
$id = $dn.GetAttribute('componentID')
if ($id) { [void]$compIds.Add($id) }
}
if ($compIds.Count -eq 0) { continue }
# Build a deterministic sortable key: zero-pad each numeric segment to 6 digits
$versionSortable = ($versionArr | ForEach-Object { $_.ToString('D6') }) -join '-'
# Capture a 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
@@ -0,0 +1,763 @@
<#
.SYNOPSIS
Common Microsoft/Surface driver helpers (cache index, SKU mapping).
.DESCRIPTION
This module contains Microsoft/Surface-specific functions used by the UI and scripts
to map Surface driver packs to System SKU values using:
- Source A: Surface System SKU reference (Learn)
- Source B: Support page model list
- Source C: Download Center details (window.__DLCDetails__)
#>
# --------------------------------------------------------------------------
# SECTION: Microsoft Surface Driver Index Cache (Sources A/B/C)
# --------------------------------------------------------------------------
function Get-SurfaceDriverIndexCachePath {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder
)
# Store the cache under Drivers\Microsoft so it travels with the driver content
$microsoftDriversFolder = Join-Path -Path $DriversFolder -ChildPath 'Microsoft'
if (-not (Test-Path -Path $microsoftDriversFolder -PathType Container)) {
New-Item -Path $microsoftDriversFolder -ItemType Directory -Force | Out-Null
}
return (Join-Path -Path $microsoftDriversFolder -ChildPath 'SurfaceDriverIndex.json')
}
function Import-SurfaceDriverIndexCache {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder
)
$cachePath = Get-SurfaceDriverIndexCachePath -DriversFolder $DriversFolder
# Surface cache TTL (7 days): treat stale caches as missing so we re-download Sources A/B/C as needed.
$cacheTtlDays = 7
if (-not (Test-Path -Path $cachePath -PathType Leaf)) {
return [pscustomobject]@{
ModelIndex = @()
SkuIndex = @()
DownloadCenterDetails = @()
}
}
try {
$cacheAgeDays = ((Get-Date) - (Get-Item -Path $cachePath -ErrorAction Stop).LastWriteTime).TotalDays
if ($cacheAgeDays -ge $cacheTtlDays) {
WriteLog "Surface cache: Cache file '$cachePath' is older than $cacheTtlDays days ($([math]::Round($cacheAgeDays, 1)) days). Refreshing."
return [pscustomobject]@{
ModelIndex = @()
SkuIndex = @()
DownloadCenterDetails = @()
}
}
WriteLog "Surface cache: Loading cached SurfaceDriverIndex.json from '$cachePath' (age: $([math]::Round($cacheAgeDays, 1)) days)."
}
catch {
WriteLog "Surface cache: Failed to read cache timestamp for '$cachePath'. Refreshing. Error: $($_.Exception.Message)"
return [pscustomobject]@{
ModelIndex = @()
SkuIndex = @()
DownloadCenterDetails = @()
}
}
try {
$cache = Get-Content -Path $cachePath -Raw | ConvertFrom-Json -ErrorAction Stop
}
catch {
WriteLog "Warning: Could not read Surface driver cache '$cachePath'. Creating a new cache. Error: $($_.Exception.Message)"
return [pscustomobject]@{
ModelIndex = @()
SkuIndex = @()
DownloadCenterDetails = @()
}
}
if ($null -eq $cache) {
return [pscustomobject]@{
ModelIndex = @()
SkuIndex = @()
DownloadCenterDetails = @()
}
}
# Ensure expected properties exist (backward compatible with earlier cache shapes)
if (-not $cache.PSObject.Properties['ModelIndex']) {
$cache | Add-Member -NotePropertyName ModelIndex -NotePropertyValue @()
}
if (-not $cache.PSObject.Properties['SkuIndex']) {
$cache | Add-Member -NotePropertyName SkuIndex -NotePropertyValue @()
}
if (-not $cache.PSObject.Properties['DownloadCenterDetails']) {
$cache | Add-Member -NotePropertyName DownloadCenterDetails -NotePropertyValue @()
}
return $cache
}
function Save-SurfaceDriverIndexCache {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[psobject]$Cache,
[Parameter(Mandatory = $true)]
[string]$DriversFolder
)
$cachePath = Get-SurfaceDriverIndexCachePath -DriversFolder $DriversFolder
$Cache | ConvertTo-Json -Depth 10 | Set-Content -Path $cachePath -Encoding UTF8
}
function ConvertTo-SurfaceComparableName {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Text
)
# Normalize Surface marketing strings into a comparable family key.
# This intentionally strips consumer/commercial/processor qualifiers so we can join Sources A/B/C.
$value = [System.Net.WebUtility]::HtmlDecode($Text)
if ([string]::IsNullOrWhiteSpace($value)) {
return $null
}
$value = $value.Trim()
$value = $value -replace '\(', ' '
$value = $value -replace '\)', ' '
$value = $value -replace ',', ' '
# Normalize punctuation that frequently differs between Support/Learn pages
# (e.g. WiFi unicode hyphen, AT&T, Y!mobile)
$value = $value -replace '[-\u2010\u2011\u2012\u2013\u2014\u2212]', ' '
$value = $value -replace '&', ' '
$value = $value -replace '!', ' '
$value = $value -replace '™', ' '
$value = $value -replace '(?i)\bMicrosoft\b', ''
$value = $value -replace '(?i)\bfor\s+Business\b', ''
$value = $value -replace '(?i)\bConsumer\b', ''
$value = $value -replace '(?i)\bCommercial\b', ''
# Strip processor/connection qualifiers that cause mismatches between WMI, Learn, and Support naming.
$value = $value -replace '(?i)\bwith\s+Intel\b', ''
$value = $value -replace '(?i)\bIntel\s+processor\b', ''
$value = $value -replace '(?i)\bIntel\b', ''
$value = $value -replace '(?i)\bSnapdragon\s+processor\b', ''
$value = $value -replace '(?i)\bSnapdragon\b', ''
$value = $value -replace '(?i)\bwith\s+5G\b', ''
$value = $value -replace '(?i)\bLTE\b', ''
$value = $value -replace '(?i)\b4G\b', ''
$value = $value -replace '(?i)\bprocessor\b', ''
# Cleanup: remove orphaned "with" left behind by earlier removals (e.g., "Surface Pro 9 with Intel Processor")
$value = $value -replace '(?i)\bwith\b', ''
$value = $value -replace '\s+', ' '
return $value.Trim().ToUpperInvariant()
}
function ConvertTo-SurfaceHtmlText {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$HtmlFragment
)
# Normalize HTML fragments from the Learn table into plain text values.
$textValue = $HtmlFragment -replace '<br\s*/?>', ' '
$textValue = $textValue -replace '<[^>]+>', ' '
$textValue = [System.Net.WebUtility]::HtmlDecode($textValue)
$textValue = $textValue -replace '\s+', ' '
return $textValue.Trim()
}
function ConvertTo-SurfaceDownloadCenterLink {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$LinkValue
)
# Normalize Learn links down to the canonical Download Center details URL.
$decodedLink = [System.Net.WebUtility]::HtmlDecode($LinkValue).Trim()
if ([string]::IsNullOrWhiteSpace($decodedLink)) {
return $null
}
if ($decodedLink.StartsWith('/')) {
$decodedLink = "https://www.microsoft.com$decodedLink"
}
$downloadCenterMatch = [regex]::Match(
$decodedLink,
'https://www\.microsoft\.com(?:/en-us)?/download/details\.aspx\?id=\d+',
[System.Text.RegularExpressions.RegexOptions]::IgnoreCase
)
if (-not $downloadCenterMatch.Success) {
return $null
}
return ($downloadCenterMatch.Value -replace '/en-us/', '/')
}
function Get-SurfaceDriverModelIndex {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder
)
$url = 'https://learn.microsoft.com/en-us/surface/manage-surface-driver-and-firmware-updates'
$minimumExpectedModelCount = 10
# Load the cached model list first to keep Microsoft model discovery fast.
try {
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
if (@($cache.ModelIndex).Count -gt 0) {
WriteLog "Surface cache: Using cached Microsoft model list ($(@($cache.ModelIndex).Count) models)."
return @($cache.ModelIndex)
}
}
catch {
WriteLog "Surface cache: Failed to load cached Microsoft model list. Falling back to the Learn source. Error: $($_.Exception.Message)"
}
try {
# Download the Learn article that now contains the authoritative Surface package table.
WriteLog "Surface cache: Downloading Microsoft model index from $url"
$headers = @{
'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
}
$webContent = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $headers
$html = $webContent.Content
# Parse each table row and keep only Download Center package links.
$rowMatches = [regex]::Matches($html, '<tr[^>]*>(.*?)</tr>', [System.Text.RegularExpressions.RegexOptions]::Singleline)
$models = [System.Collections.Generic.List[pscustomobject]]::new()
$seenModelKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($rowMatch in $rowMatches) {
$rowContent = $rowMatch.Groups[1].Value
$cellMatches = [regex]::Matches($rowContent, '<td[^>]*>\s*(.*?)\s*</td>', [System.Text.RegularExpressions.RegexOptions]::Singleline)
if ($cellMatches.Count -lt 2) {
continue
}
$rowLabel = ConvertTo-SurfaceHtmlText -HtmlFragment $cellMatches[0].Groups[1].Value
if ([string]::IsNullOrWhiteSpace($rowLabel) -or $rowLabel -notmatch '(?i)^Surface') {
continue
}
$downloadCellContent = $cellMatches[1].Groups[1].Value
$linkMatches = [regex]::Matches(
$downloadCellContent,
'<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>',
[System.Text.RegularExpressions.RegexOptions]::Singleline -bor [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
)
foreach ($linkMatch in $linkMatches) {
$modelName = ConvertTo-SurfaceHtmlText -HtmlFragment $linkMatch.Groups[2].Value
$modelLink = ConvertTo-SurfaceDownloadCenterLink -LinkValue $linkMatch.Groups[1].Value
if ([string]::IsNullOrWhiteSpace($modelName) -or [string]::IsNullOrWhiteSpace($modelLink)) {
continue
}
$modelKey = "$modelName`n$modelLink"
if (-not $seenModelKeys.Add($modelKey)) {
continue
}
$models.Add([pscustomobject]@{
Make = 'Microsoft'
Model = $modelName
Link = $modelLink
})
}
}
if ($models.Count -eq 0) {
throw "No Microsoft driver models were found in the Learn table."
}
if ($models.Count -lt $minimumExpectedModelCount) {
WriteLog "Surface cache: Warning - Learn parsing returned only $($models.Count) Microsoft model entries."
}
else {
WriteLog "Surface cache: Parsed $($models.Count) Microsoft model entries from Learn."
}
# Save the refreshed model list into the shared cache for both UI and CLI use.
try {
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
$cache.ModelIndex = @($models)
Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder
WriteLog "Surface cache: Saved Microsoft model list to cache."
}
catch {
WriteLog "Surface cache: Failed to save Microsoft model list. Error: $($_.Exception.Message)"
}
return @($models)
}
catch {
WriteLog "Surface cache: Failed to build Microsoft model list from Learn. Error: $($_.Exception.Message)"
# Fall back to the last cached model list even if it is stale when the live request fails.
try {
$cachePath = Get-SurfaceDriverIndexCachePath -DriversFolder $DriversFolder
if (Test-Path -Path $cachePath -PathType Leaf) {
$staleCache = Get-Content -Path $cachePath -Raw | ConvertFrom-Json -ErrorAction Stop
if (@($staleCache.ModelIndex).Count -gt 0) {
WriteLog "Surface cache: Using stale Microsoft model list ($(@($staleCache.ModelIndex).Count) models) because the live Learn request failed."
return @($staleCache.ModelIndex)
}
}
}
catch {
WriteLog "Surface cache: Failed to load stale Microsoft model list fallback. Error: $($_.Exception.Message)"
}
throw "Failed to retrieve Microsoft Surface models."
}
}
function Get-SurfaceSystemSkuReferenceIndex {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder
)
# Source A: Learn page with authoritative Device / System Model / System SKU table
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
if ($cache.SkuIndex -and $cache.SkuIndex.Count -gt 0) {
return @($cache.SkuIndex)
}
$url = 'https://learn.microsoft.com/en-us/surface/surface-system-sku-reference'
WriteLog "Surface cache: Downloading System SKU reference table from $url"
$headers = @{ 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }
$webContent = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $headers
$html = $webContent.Content
$skuRows = [System.Collections.Generic.List[pscustomobject]]::new()
$rowMatches = [regex]::Matches($html, '<tr[^>]*>(.*?)</tr>', [System.Text.RegularExpressions.RegexOptions]::Singleline)
foreach ($rowMatch in $rowMatches) {
$rowContent = $rowMatch.Groups[1].Value
$cellMatches = [regex]::Matches($rowContent, '<td[^>]*>\s*(?:<p[^>]*>)?(.*?)(?:</p>)?\s*</td>', [System.Text.RegularExpressions.RegexOptions]::Singleline)
if ($cellMatches.Count -lt 3) { continue }
$device = ([System.Net.WebUtility]::HtmlDecode(($cellMatches[0].Groups[1].Value).Trim()))
$systemModel = ([System.Net.WebUtility]::HtmlDecode(($cellMatches[1].Groups[1].Value).Trim()))
$systemSkuRaw = ([System.Net.WebUtility]::HtmlDecode(($cellMatches[2].Groups[1].Value).Trim()))
if ([string]::IsNullOrWhiteSpace($device) -or [string]::IsNullOrWhiteSpace($systemSkuRaw)) { continue }
$skuList = @($systemSkuRaw)
foreach ($sku in $skuList) {
if ([string]::IsNullOrWhiteSpace($sku)) { continue }
$skuRows.Add([pscustomobject]@{
Device = $device
SystemModel = $systemModel
SystemSku = $sku.Trim().ToUpperInvariant()
})
}
}
$cache.SkuIndex = @($skuRows)
Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder
WriteLog "Surface cache: Stored $($skuRows.Count) SKU entries."
return @($skuRows)
}
function Get-SurfaceDownloadCenterDetails {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder,
[Parameter(Mandatory = $true)]
[string]$ModelLink,
[Parameter()]
[string]$ModelName = $null
)
# Source C: Download Center details page (window.__DLCDetails__) containing file names + direct URLs
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
$existing = @($cache.DownloadCenterDetails | Where-Object { $_.Link -eq $ModelLink } | Select-Object -First 1)
if ($existing.Count -gt 0 -and $existing[0].Files -and $existing[0].Files.Count -gt 0) {
# Backfill Model into cache when available
if (-not [string]::IsNullOrWhiteSpace($ModelName)) {
if (-not $existing[0].PSObject.Properties['Model'] -or [string]::IsNullOrWhiteSpace($existing[0].Model)) {
try {
$existing[0] | Add-Member -NotePropertyName Model -NotePropertyValue $ModelName -Force
$newDetails = [System.Collections.Generic.List[pscustomobject]]::new()
foreach ($item in @($cache.DownloadCenterDetails)) {
if ($null -ne $item -and $item.PSObject.Properties['Link'] -and $item.Link -ne $ModelLink) {
$newDetails.Add($item)
}
}
$newDetails.Add($existing[0])
$cache.DownloadCenterDetails = @($newDetails)
Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder
}
catch {
WriteLog "Surface cache: Failed to backfill Model for DownloadCenterDetails entry '$ModelLink'. Error: $($_.Exception.Message)"
}
}
}
return @($existing[0].Files)
}
WriteLog "Surface cache: Downloading Download Center details from $ModelLink"
$headers = @{ 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }
$downloadPageContent = Invoke-WebRequest -Uri $ModelLink -UseBasicParsing -Headers $headers
$scriptPattern = '<script>window.__DLCDetails__={(.*?)}<\/script>'
$scriptMatch = [regex]::Match($downloadPageContent.Content, $scriptPattern)
if (-not $scriptMatch.Success) {
WriteLog "Surface cache: Could not find window.__DLCDetails__ on $ModelLink"
return @()
}
$scriptContent = $scriptMatch.Groups[1].Value
$downloadFilePattern = '"name":"([^"]+\.(?:msi|zip))",[^}]*?"url":"(.*?)"'
$downloadFileMatches = [regex]::Matches($scriptContent, $downloadFilePattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
$files = [System.Collections.Generic.List[pscustomobject]]::new()
foreach ($downloadFile in $downloadFileMatches) {
$currentFileName = $downloadFile.Groups[1].Value
$fileUrl = $downloadFile.Groups[2].Value
if ([string]::IsNullOrWhiteSpace($currentFileName) -or [string]::IsNullOrWhiteSpace($fileUrl)) { continue }
$files.Add([pscustomobject]@{
Name = $currentFileName
Url = $fileUrl
})
}
# Persist into cache
if ($files.Count -gt 0) {
$detailsEntry = [pscustomobject][ordered]@{
Model = $ModelName
Link = $ModelLink
Files = @($files)
}
$newDetails = [System.Collections.Generic.List[pscustomobject]]::new()
foreach ($item in @($cache.DownloadCenterDetails)) {
if ($null -ne $item -and $item.PSObject.Properties['Link'] -and $item.Link -ne $ModelLink) {
$newDetails.Add($item)
}
}
$newDetails.Add($detailsEntry)
$cache.DownloadCenterDetails = @($newDetails)
Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder
}
return @($files)
}
function Get-SurfaceSystemSkuListForMicrosoftDriver {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder,
[Parameter(Mandatory = $true)]
[string]$ModelName,
[Parameter(Mandatory = $true)]
[string]$ModelLink
)
$skuIndex = Get-SurfaceSystemSkuReferenceIndex -DriversFolder $DriversFolder
if ($null -eq $skuIndex -or $skuIndex.Count -eq 0) {
return @()
}
$files = Get-SurfaceDownloadCenterDetails -DriversFolder $DriversFolder -ModelLink $ModelLink -ModelName $ModelName
$fileNames = @($files | ForEach-Object { $_.Name })
# Infer architecture hints from the MSI naming convention (best-effort)
$archHint = $null
if ($fileNames -match '(?i)_ARM_') {
$archHint = 'ARM64'
}
elseif ($fileNames -match '(?i)withIntel|_Intel_|Intel') {
$archHint = 'x64'
}
elseif ($ModelName -match '(?i)\bSQ3\b|\bSnapdragon\b') {
$archHint = 'ARM64'
}
elseif ($ModelName -match '(?i)with Intel') {
$archHint = 'x64'
}
# Surface Pro (generic) is ambiguous in the SKU table because Surface Pro (5th Gen) and
# Surface Pro with LTE Advanced (5th Gen) both reuse SystemModel="Surface Pro".
# The "Surface Pro" driver pack does not have a unique SystemSKU value on the Learn page.
if ($ModelName.Trim() -match '(?i)^Surface\s+Pro$') {
return @()
}
# Build multiple candidate keys for models that contain multiple variants in one string
# Example: "Surface Pro 7+ and Surface Pro 7+ LTE"
$familyKeyCandidates = [System.Collections.Generic.List[string]]::new()
$familyKeySet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$primaryKey = ConvertTo-SurfaceComparableName -Text $ModelName
if (-not [string]::IsNullOrWhiteSpace($primaryKey) -and $familyKeySet.Add($primaryKey)) {
$familyKeyCandidates.Add($primaryKey) | Out-Null
}
$parts = [regex]::Split($ModelName, '(?i)\s+and\s+')
# Track when the model text contains both LTE and non-LTE variants (e.g. "Surface Go 2 and Surface Go 2 LTE")
$hasLtePart = (@($parts | Where-Object { $_ -match '(?i)\bLTE\b' }).Count -gt 0)
$hasNonLtePart = (@($parts | Where-Object { $_ -notmatch '(?i)\bLTE\b' }).Count -gt 0)
foreach ($part in @($parts)) {
if ([string]::IsNullOrWhiteSpace($part)) { continue }
$candidate = ConvertTo-SurfaceComparableName -Text $part
if (-not [string]::IsNullOrWhiteSpace($candidate) -and $familyKeySet.Add($candidate)) {
$familyKeyCandidates.Add($candidate) | Out-Null
}
}
if ($familyKeyCandidates.Count -eq 0) {
return @()
}
# Surface 3 has multiple carrier/region variants that share the same SystemModel ("Surface 3").
# Add a base key so we can match all Surface 3 SKU rows, then refine down to the correct variant.
if ($ModelName -match '(?i)^Surface\s+3\b') {
$surface3BaseKey = 'SURFACE 3'
if ($familyKeySet.Add($surface3BaseKey)) {
$familyKeyCandidates.Add($surface3BaseKey) | Out-Null
}
}
# Surface Go variants share the same SystemModel ("Surface Go") in the SKU table.
# Use a generation-aware base key so we don't cross-match Go vs Go 2/3/4 SKU rows.
if ($ModelName -match '(?i)^Surface\s+Go\s+2\b') {
$surfaceGoBaseKey = 'SURFACE GO 2'
if ($familyKeySet.Add($surfaceGoBaseKey)) {
$familyKeyCandidates.Add($surfaceGoBaseKey) | Out-Null
}
}
elseif ($ModelName -match '(?i)^Surface\s+Go\s+3\b') {
$surfaceGoBaseKey = 'SURFACE GO 3'
if ($familyKeySet.Add($surfaceGoBaseKey)) {
$familyKeyCandidates.Add($surfaceGoBaseKey) | Out-Null
}
}
elseif ($ModelName -match '(?i)^Surface\s+Go\s+4\b') {
$surfaceGoBaseKey = 'SURFACE GO 4'
if ($familyKeySet.Add($surfaceGoBaseKey)) {
$familyKeyCandidates.Add($surfaceGoBaseKey) | Out-Null
}
}
elseif ($ModelName -match '(?i)^Surface\s+Go\b') {
$surfaceGoBaseKey = 'SURFACE GO'
if ($familyKeySet.Add($surfaceGoBaseKey)) {
$familyKeyCandidates.Add($surfaceGoBaseKey) | Out-Null
}
}
# Surface Pro 9 with 5G: the SKU table rows use SystemModel "Surface Pro 9".
# Add a base key so we can match the Pro 9 SKU rows, then refine down to the 5G rows.
if (($ModelName -match '(?i)^Surface\s+Pro\s+9\b') -and ($ModelName -match '(?i)\b5G\b')) {
$surfacePro9BaseKey = 'SURFACE PRO 9'
if ($familyKeySet.Add($surfacePro9BaseKey)) {
$familyKeyCandidates.Add($surfacePro9BaseKey) | Out-Null
}
}
# Surface Pro with LTE Advanced maps to the "Surface Pro with LTE Advanced (5th Gen)" SKU table row.
# Add a base key so we can match Surface Pro rows, then refine to the LTE Advanced SKU.
if ($ModelName -match '(?i)^Surface\s+Pro\s+with\s+LTE\s+Advanced\b') {
$surfaceProBaseKey = 'SURFACE PRO'
if ($familyKeySet.Add($surfaceProBaseKey)) {
$familyKeyCandidates.Add($surfaceProBaseKey) | Out-Null
}
}
# Surface Laptop (1st Gen) maps to the base "Surface Laptop" SKU table row.
if (($ModelName -match '(?i)^Surface\s+Laptop\b') -and ($ModelName -match '(?i)\bGen\b')) {
$surfaceLaptopBaseKey = 'SURFACE LAPTOP'
if ($familyKeySet.Add($surfaceLaptopBaseKey)) {
$familyKeyCandidates.Add($surfaceLaptopBaseKey) | Out-Null
}
}
# Surface Studio (1st Gen) maps to the base "Surface Studio" SKU table row.
if (($ModelName -match '(?i)^Surface\s+Studio\b') -and ($ModelName -match '(?i)\bGen\b')) {
$surfaceStudioBaseKey = 'SURFACE STUDIO'
if ($familyKeySet.Add($surfaceStudioBaseKey)) {
$familyKeyCandidates.Add($surfaceStudioBaseKey) | Out-Null
}
}
# Surface Laptop 3/4 AMD/Intel packs map to the "Surface Laptop 3/4" SystemModel rows in the SKU table.
if ($ModelName -match '(?i)^Surface\s+Laptop\s+(3|4)\b' -and $ModelName -match '(?i)\b(AMD|Intel)\b') {
$generationMatch = [regex]::Match($ModelName, '(?i)^Surface\s+Laptop\s+(3|4)\b')
if ($generationMatch.Success) {
$surfaceLaptopGenBaseKey = "SURFACE LAPTOP $($generationMatch.Groups[1].Value)"
if ($familyKeySet.Add($surfaceLaptopGenBaseKey)) {
$familyKeyCandidates.Add($surfaceLaptopGenBaseKey) | Out-Null
}
}
}
# Match by any candidate key against the SKU table
$skuMatches = @($skuIndex | Where-Object {
$deviceKey = ConvertTo-SurfaceComparableName -Text $_.Device
$modelKey = ConvertTo-SurfaceComparableName -Text $_.SystemModel
foreach ($candidateKey in $familyKeyCandidates) {
if ($deviceKey -eq $candidateKey -or $modelKey -eq $candidateKey) {
return $true
}
}
return $false
})
# Surface Hub 2 driver packs cover Surface Hub 2S + Surface Hub 3 devices.
# The System SKU table does not have a "Surface Hub 2" row, so map Hub 2 to all Hub SKUs.
if ($ModelName -match '(?i)^Surface\s+Hub\s+2\b') {
$hubSkuRows = @($skuIndex | Where-Object { $_.Device -match '(?i)^Surface\s+Hub' })
if ($hubSkuRows.Count -gt 0) {
$skuMatches = @($hubSkuRows)
}
}
# Surface 3: refine down to the correct SKU row based on the model variant text
# Use normalized text so punctuation/Unicode differences don't drop matches to zero.
if ($ModelName -match '(?i)^Surface\s+3\b') {
$modelNorm = ConvertTo-SurfaceComparableName -Text $ModelName
if ($modelNorm -match '(?i)\bWI\s+FI\b') {
$skuMatches = @($skuMatches | Where-Object { (ConvertTo-SurfaceComparableName -Text $_.Device) -match '(?i)\bWI\s+FI\b' })
}
elseif ($modelNorm -match '(?i)\bVERIZON\b') {
$skuMatches = @($skuMatches | Where-Object { (ConvertTo-SurfaceComparableName -Text $_.Device) -match '(?i)\bVERIZON\b' })
}
elseif ($modelNorm -match '(?i)\bOUTSIDE\s+OF\s+NORTH\s+AMERICA\b|\bY\s+MOBILE\b') {
$skuMatches = @($skuMatches | Where-Object { (ConvertTo-SurfaceComparableName -Text $_.Device) -match '(?i)\bOUTSIDE\s+OF\s+NORTH\s+AMERICA\b|\bY\s+MOBILE\b' })
}
elseif ($modelNorm -match '(?i)\bNORTH\s+AMERICA\b') {
# "North America (non-AT&T)" should map to the North America row (not AT&T/Verizon/outside-of-North-America)
$skuMatches = @($skuMatches | Where-Object {
$deviceNorm = ConvertTo-SurfaceComparableName -Text $_.Device
($deviceNorm -match '(?i)\bNORTH\s+AMERICA\b') -and
($deviceNorm -notmatch '(?i)\bOUTSIDE\b|\bY\s+MOBILE\b') -and
($deviceNorm -notmatch '(?i)\bAT\s+T\b|\bVERIZON\b')
})
}
elseif (($modelNorm -match '(?i)\bAT\s+T\b') -and ($modelNorm -notmatch '(?i)\bNON\s+AT\s+T\b')) {
$skuMatches = @($skuMatches | Where-Object { (ConvertTo-SurfaceComparableName -Text $_.Device) -match '(?i)\bAT\s+T\b' })
}
}
# Surface Go: keep LTE SKU only for LTE-only models; exclude LTE SKU for non-LTE-only models.
# If the model name includes BOTH LTE and non-LTE variants (joined with "and"), do not filter.
# Surface Go 3 driver packs are treated as covering LTE + non-LTE unless explicitly labeled otherwise.
if ($ModelName -match '(?i)^Surface\s+Go\b') {
$isSurfaceGo3Base = ($ModelName -match '(?i)^Surface\s+Go\s+3\b') -and ($ModelName -notmatch '(?i)\bLTE\b')
if (-not $isSurfaceGo3Base) {
if (-not ($hasLtePart -and $hasNonLtePart)) {
if ($ModelName -match '(?i)\bLTE\b') {
$skuMatches = @($skuMatches | Where-Object { $_.Device -match '(?i)\bLTE\b' })
}
else {
$skuMatches = @($skuMatches | Where-Object { $_.Device -notmatch '(?i)\bLTE\b' })
}
}
}
}
# Surface Pro 9 with 5G (SQ3): keep only the 5G SKU rows (U.S. + outside of U.S.).
if (($ModelName -match '(?i)^Surface\s+Pro\s+9\b') -and ($ModelName -match '(?i)\b5G\b')) {
$skuMatches = @($skuMatches | Where-Object { $_.Device -match '(?i)\b5G\b' })
}
# Surface Pro 10: split non-5G vs 5G SKU rows so the two driver packs don't share the same SystemSKUs.
if ($ModelName -match '(?i)^Surface\s+Pro\s+10\b') {
if ($ModelName -match '(?i)\b5G\b') {
$skuMatches = @($skuMatches | Where-Object {
($_.SystemSku -match '^SURFACE_PRO_10_WITH_5G_FOR_BUSINESS_') -or
($_.Device -match '(?i)\bwith\s+5G\b')
})
}
else {
$skuMatches = @($skuMatches | Where-Object { $_.SystemSku -eq 'SURFACE_PRO_10_FOR_BUSINESS_2079' })
}
}
# Surface Pro with LTE Advanced: restrict to the LTE Advanced (5th Gen) SKU.
if ($ModelName -match '(?i)^Surface\s+Pro\s+with\s+LTE\s+Advanced\b') {
$skuMatches = @($skuMatches | Where-Object { $_.SystemSku -eq 'SURFACE_PRO_1807' })
}
# Surface Laptop 3/4: filter to AMD vs Intel rows (prevents AMD packs from inheriting Intel SKUs and vice-versa).
if ($ModelName -match '(?i)^Surface\s+Laptop\s+(3|4)\b') {
if ($ModelName -match '(?i)\bAMD\b') {
$skuMatches = @($skuMatches | Where-Object { $_.Device -match '(?i)\bAMD\b' })
}
elseif ($ModelName -match '(?i)\bIntel\b') {
$skuMatches = @($skuMatches | Where-Object { $_.Device -match '(?i)\bIntel\b' })
}
}
# Apply architecture filtering when we can infer it
if ($archHint -eq 'ARM64') {
# ARM variants are typically called out as Snapdragon / SQ3 / 5G in the Learn table
$skuMatches = @($skuMatches | Where-Object {
($_.Device -match '(?i)Snapdragon|SQ3|with 5G') -or
($_.SystemModel -match '(?i)Snapdragon|SQ3|with 5G')
})
}
elseif ($archHint -eq 'x64') {
# x64 variants are often NOT labeled "Intel" in the Learn table (e.g. Surface Pro 9).
# Treat "not Snapdragon/SQ3/5G" as the x64 bucket.
$skuMatches = @($skuMatches | Where-Object {
($_.Device -notmatch '(?i)Snapdragon|SQ3|with 5G') -and
($_.SystemModel -notmatch '(?i)Snapdragon|SQ3|with 5G')
})
}
$skus = @($skuMatches | ForEach-Object { $_.SystemSku } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
return $skus
}
Export-ModuleMember -Function `
Get-SurfaceDriverIndexCachePath, `
Import-SurfaceDriverIndexCache, `
Save-SurfaceDriverIndexCache, `
ConvertTo-SurfaceComparableName, `
ConvertTo-SurfaceHtmlText, `
ConvertTo-SurfaceDownloadCenterLink, `
Get-SurfaceDriverModelIndex, `
Get-SurfaceSystemSkuReferenceIndex, `
Get-SurfaceDownloadCenterDetails, `
Get-SurfaceSystemSkuListForMicrosoftDriver
+535 -105
View File
@@ -22,7 +22,10 @@ function Compress-DriverFolderToWim {
[string]$WimName, # Optional, defaults to folder name [string]$WimName, # Optional, defaults to folder name
[Parameter()] [Parameter()]
[string]$WimDescription # Optional, defaults to folder name [string]$WimDescription, # Optional, defaults to folder name
[Parameter()]
[bool]$PreserveSource = $false # When $true, do not delete source folder; create marker for deferred cleanup
) )
WriteLog "Starting compression of folder '$SourceFolderPath' to '$DestinationWimPath'." WriteLog "Starting compression of folder '$SourceFolderPath' to '$DestinationWimPath'."
@@ -66,14 +69,29 @@ function Compress-DriverFolderToWim {
WriteLog "Successfully compressed '$SourceFolderPath' to '$DestinationWimPath' using dism.exe." WriteLog "Successfully compressed '$SourceFolderPath' to '$DestinationWimPath' using dism.exe."
# Remove the source folder after successful compression # Remove the source folder after successful compression
WriteLog "Removing source driver folder: $SourceFolderPath" if ($PreserveSource) {
try { WriteLog "Preserving source driver folder for deferred WinPE driver harvesting: $SourceFolderPath"
Remove-Item -Path $SourceFolderPath -Recurse -Force -ErrorAction Stop try {
WriteLog "Successfully removed source folder '$SourceFolderPath'." $markerFile = Join-Path -Path $SourceFolderPath -ChildPath '__PreservedForPEDrivers.txt'
if (-not (Test-Path -Path $markerFile -PathType Leaf)) {
New-Item -Path $markerFile -ItemType File -Force | Out-Null
WriteLog "Created preservation marker file: $markerFile"
}
}
catch {
WriteLog "Warning: Failed to create preservation marker in $SourceFolderPath. Error: $($_.Exception.Message)"
}
} }
catch { else {
WriteLog "Warning: Failed to remove source folder '$SourceFolderPath'. Error: $($_.Exception.Message)" WriteLog "Removing source driver folder: $SourceFolderPath"
# Do not fail the whole operation, just log a warning. try {
Remove-Item -Path $SourceFolderPath -Recurse -Force -ErrorAction Stop
WriteLog "Successfully removed source folder '$SourceFolderPath'."
}
catch {
WriteLog "Warning: Failed to remove source folder '$SourceFolderPath'. Error: $($_.Exception.Message)"
# Do not fail the whole operation, just log a warning.
}
} }
return $true # Indicate success return $true # Indicate success
@@ -137,31 +155,222 @@ function Update-DriverMappingJson {
$updatedCount = 0 $updatedCount = 0
$addedCount = 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) { 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)) { 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))" WriteLog "Skipping driver entry due to missing or empty Make, Model, or DriverPath. Details: $(($driver | ConvertTo-Json -Compress -Depth 3))"
continue 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()
}
}
}
# Microsoft Surface: resolve System SKU list (best-effort) using Sources A + C and cached results
$surfaceSystemSkuList = @()
if ($driver.Make -eq 'Microsoft') {
if ($driver.PSObject.Properties['Link'] -and -not [string]::IsNullOrWhiteSpace($driver.Link)) {
try {
$surfaceSystemSkuList = Get-SurfaceSystemSkuListForMicrosoftDriver -DriversFolder $DriversFolder -ModelName $driver.Model -ModelLink $driver.Link
}
catch {
WriteLog "Warning: Failed to resolve Surface SystemSku list for '$($driver.Model)'. Error: $($_.Exception.Message)"
$surfaceSystemSkuList = @()
}
}
}
$existingEntry = $mappingList | Where-Object { $_.Manufacturer -eq $driver.Make -and $_.Model -eq $driver.Model } | Select-Object -First 1 $existingEntry = $mappingList | Where-Object { $_.Manufacturer -eq $driver.Make -and $_.Model -eq $driver.Model } | Select-Object -First 1
if ($null -ne $existingEntry) { if ($null -ne $existingEntry) {
# Update existing entry if the path is different $entryUpdated = $false
if ($existingEntry.DriverPath -ne $driver.DriverPath) { if ($existingEntry.DriverPath -ne $driver.DriverPath) {
WriteLog "Updating driver path for '$($driver.Make) - $($driver.Model)' from '$($existingEntry.DriverPath)' to '$($driver.DriverPath)'." WriteLog "Updating driver path for '$($driver.Make) - $($driver.Model)' from '$($existingEntry.DriverPath)' to '$($driver.DriverPath)'."
$existingEntry.DriverPath = $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 ($driver.Make -eq 'Microsoft' -and $surfaceSystemSkuList -and $surfaceSystemSkuList.Count -gt 0) {
$desiredSkus = @($surfaceSystemSkuList | Sort-Object -Unique)
if ($existingEntry.PSObject.Properties['SystemSku']) {
$currentSkus = @($existingEntry.SystemSku)
$currentNormalized = @($currentSkus | ForEach-Object { if ($null -ne $_) { $_.ToString().Trim().ToUpperInvariant() } }) | Sort-Object -Unique
$desiredNormalized = @($desiredSkus | ForEach-Object { if ($null -ne $_) { $_.ToString().Trim().ToUpperInvariant() } }) | Sort-Object -Unique
if (($currentNormalized -join '|') -ne ($desiredNormalized -join '|')) {
WriteLog "Updating SystemSku list for 'Microsoft - $($driver.Model)'."
$existingEntry.SystemSku = $desiredSkus
$entryUpdated = $true
}
}
else {
WriteLog "Adding SystemSku list for 'Microsoft - $($driver.Model)'."
$existingEntry | Add-Member -NotePropertyName SystemSku -NotePropertyValue $desiredSkus
$entryUpdated = $true
}
}
if ($entryUpdated) {
$updatedCount++ $updatedCount++
} }
} }
else { else {
# Add new entry
$newEntry = [PSCustomObject]@{ $newEntry = [PSCustomObject]@{
Manufacturer = $driver.Make Manufacturer = $driver.Make
Model = $driver.Model Model = $driver.Model
DriverPath = $driver.DriverPath 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
}
if ($driver.Make -eq 'Microsoft' -and $surfaceSystemSkuList -and $surfaceSystemSkuList.Count -gt 0) {
$newEntry | Add-Member -NotePropertyName SystemSku -NotePropertyValue @($surfaceSystemSkuList | Sort-Object -Unique)
}
$mappingList.Add($newEntry) $mappingList.Add($newEntry)
WriteLog "Adding new mapping for '$($driver.Make) - $($driver.Model)' with path '$($driver.DriverPath)'." WriteLog "Adding new mapping for '$($driver.Make) - $($driver.Model)' with path '$($driver.DriverPath)'."
$addedCount++ $addedCount++
@@ -274,111 +483,328 @@ function Get-LenovoPSREFToken {
if your alternative works is to see if you can retrieve 100e, 300w, 500w, etc. These don't show up in catalogv2.xml, but they do in PSREF. 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 $token = $null
$edgeExe = "$Env:ProgramFiles (x86)\Microsoft\Edge\Application\msedge.exe" $socket = $null
$edgeProcess = $null
$tempProfile = $null
$port = $null
# Any free port works. 9222 is common. function Get-FreeLocalTcpPort {
$port = 9222 $listener = $null
$uri = 'https://psref.lenovo.com' try {
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
# Headless run with remote debugging. $listener.Start()
$flags = "--headless=new --disable-gpu --remote-debugging-port=$port $uri" $endpoint = [System.Net.IPEndPoint]$listener.LocalEndpoint
$edge = Start-Process -FilePath $edgeExe -ArgumentList $flags -PassThru return $endpoint.Port
Writelog "Edge process started with PID: $($edge.Id)." }
finally {
# Wait a short moment so the target appears. if ($null -ne $listener) {
Start-Sleep -Seconds 3 $listener.Stop()
}
# 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']. function Get-EdgeDevToolsPageTarget {
Send-DevToolsCommand -id 1 -method 'Runtime.evaluate' -params @{ param(
expression = "localStorage.getItem('asut')" [Parameter(Mandatory = $true)][int]$Port,
[int]$MaxAttempts = 20,
[int]$DelayMilliseconds = 500,
[string]$UrlContains
)
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
try {
$targets = Invoke-RestMethod -Uri "http://localhost:$Port/json" -ErrorAction Stop
if ($null -ne $targets) {
if ($targets -isnot [System.Array]) { $targets = @($targets) }
$pageTargets = $targets | Where-Object { $_.type -eq 'page' }
if (-not [string]::IsNullOrWhiteSpace($UrlContains)) {
$pageTargets = $pageTargets | Where-Object {
-not [string]::IsNullOrWhiteSpace($_.url) -and $_.url -like "*$UrlContains*"
}
}
$target = $pageTargets | Select-Object -First 1
if ($null -ne $target) {
return $target
}
WriteLog "DevTools endpoint on port $Port returned targets but no page matched the criteria (attempt $attempt of $MaxAttempts)."
}
else {
WriteLog "DevTools endpoint on port $Port returned no targets (attempt $attempt of $MaxAttempts)."
}
}
catch {
WriteLog "DevTools endpoint on port $Port not ready (attempt $attempt of $MaxAttempts). Error: $($_.Exception.Message)"
}
Start-Sleep -Milliseconds $DelayMilliseconds
}
throw "Edge DevTools endpoint on port $Port did not expose a matching page target after $MaxAttempts attempts."
} }
# Receive frames until the whole message arrives.
$ms = New-Object System.IO.MemoryStream
$buf = New-Object byte[] 8192
do {
$seg = [ArraySegment[byte]]::new($buf)
$res = $socket.ReceiveAsync($seg,
[Threading.CancellationToken]::None).Result
$ms.Write($buf, 0, $res.Count)
} until ($res.EndOfMessage)
$ms.Position = 0
$json = ([System.IO.StreamReader]::new($ms, [Text.Encoding]::UTF8)).ReadToEnd() |
ConvertFrom-Json
$token = $json.result.result.value
# Concatenate the token value with X-PSREF-USER-TOKEN=
$token = "X-PSREF-USER-TOKEN=$token"
WriteLog "Retrieved Lenovo PSREF token: $token"
# Clean up.
$socket.Dispose()
if ($null -ne $socket) {
$socket.Dispose()
}
# Find the PID listening on the debugging port for reliable termination.
$listeningPid = $null
try { try {
# Find the process listening on the specific port. The regex now looks for the local address and port, followed by anything, then LISTENING. $ffuDevelopmentRoot = Split-Path -Path $PSScriptRoot -Parent
# Dots are escaped for literal matching. WriteLog "Derived FFUDevelopmentPath from module path: $ffuDevelopmentRoot"
$netstatOutput = netstat -ano -p TCP | Where-Object { $_ -match "127\.0\.0\.1:$port.*LISTENING" }
if ($netstatOutput) { if ([string]::IsNullOrWhiteSpace($ffuDevelopmentRoot)) {
# The last number in the line is the PID throw "FFUDevelopmentPath could not be resolved. Unable to create Edge profile."
$listeningPid = ($netstatOutput -split '\s+')[-1]
WriteLog "Found Edge process PID $listeningPid listening on port $port. This is the process we will terminate."
} }
else {
WriteLog "Could not find any process listening on port $port." if (-not (Test-Path -Path $ffuDevelopmentRoot -PathType Container)) {
throw "Resolved FFUDevelopmentPath '$ffuDevelopmentRoot' does not exist."
} }
$tempProfile = Join-Path -Path $ffuDevelopmentRoot -ChildPath ("edge-psref-" + [guid]::NewGuid())
WriteLog "Creating temporary Edge profile at $tempProfile."
New-Item -ItemType Directory -Path $tempProfile -Force | Out-Null
$edgeExe = "$Env:ProgramFiles (x86)\Microsoft\Edge\Application\msedge.exe"
$uri = 'https://psref.lenovo.com'
$port = Get-FreeLocalTcpPort
WriteLog "Using Edge DevTools port $port for Lenovo PSREF token retrieval."
$flags = "--headless=new --disable-gpu --remote-debugging-port=$port $uri --user-data-dir=`"$tempProfile`""
$edgeProcess = Start-Process -FilePath $edgeExe -ArgumentList $flags -PassThru
WriteLog "Edge process started with PID: $($edgeProcess.Id)."
$pageTarget = Get-EdgeDevToolsPageTarget -Port $port -MaxAttempts 40 -DelayMilliseconds 500 -UrlContains 'psref.lenovo.com'
if (-not [string]::IsNullOrWhiteSpace($pageTarget.url)) {
WriteLog "Selected DevTools target URL: $($pageTarget.url)"
}
$wsUrl = $pageTarget.webSocketDebuggerUrl
if ([string]::IsNullOrWhiteSpace($wsUrl)) {
throw "Edge DevTools page target on port $port did not provide a WebSocket URL."
}
$socket = [System.Net.WebSockets.ClientWebSocket]::new()
$socket.ConnectAsync($wsUrl, [Threading.CancellationToken]::None).Wait()
function Send-DevToolsCommand {
param([int]$id, [string]$method, [hashtable]$params = @{})
$cmd = @{ id = $id; method = $method; params = $params } | ConvertTo-Json -Compress
$data = [Text.Encoding]::UTF8.GetBytes($cmd)
$socket.SendAsync([ArraySegment[byte]]$data, 'Text', $true, [Threading.CancellationToken]::None).Wait()
}
$buffer = New-Object byte[] 8192
function Invoke-DevToolsValue {
param(
[Parameter(Mandatory = $true)][int]$CommandId,
[Parameter(Mandatory = $true)][string]$Expression,
[int]$MaxPolls = 25
)
Send-DevToolsCommand -id $CommandId -method 'Runtime.evaluate' -params @{
expression = $Expression
returnByValue = $true
awaitPromise = $true
}
for ($poll = 1; $poll -le $MaxPolls; $poll++) {
$localStream = $null
try {
$localStream = New-Object System.IO.MemoryStream
do {
$segment = [ArraySegment[byte]]::new($buffer)
$result = $socket.ReceiveAsync($segment, [Threading.CancellationToken]::None).Result
$localStream.Write($buffer, 0, $result.Count)
} until ($result.EndOfMessage)
$jsonBytes = $localStream.ToArray()
$jsonText = [Text.Encoding]::UTF8.GetString($jsonBytes)
$previewPayload = $jsonText
if (-not [string]::IsNullOrEmpty($previewPayload) -and $previewPayload.Length -gt 500) {
$previewPayload = $previewPayload.Substring(0, 500) + '...'
}
WriteLog "DevTools eval payload (cmd $CommandId, poll $poll): $previewPayload"
$message = $null
try {
$message = $jsonText | ConvertFrom-Json
}
catch {
WriteLog "Failed to parse DevTools eval payload for command id $CommandId (poll $poll): $($_.Exception.Message)"
continue
}
if ($message.PSObject.Properties['id'] -and $message.id -eq $CommandId) {
if ($message.PSObject.Properties['error']) {
$errorMessage = $message.error.message
throw "Edge DevTools reported an error for expression '$Expression': $errorMessage"
}
if ($message.PSObject.Properties['result'] -and $message.result.PSObject.Properties['result']) {
$innerResult = $message.result.result
return [PSCustomObject]@{
Value = $innerResult.value
Type = $innerResult.type
Subtype = $innerResult.subtype
}
}
$serializedMessage = $message | ConvertTo-Json -Compress -Depth 5
WriteLog "DevTools response for command id $CommandId lacked result data. Message: $serializedMessage"
return $null
}
if ($message.PSObject.Properties['method']) {
WriteLog "Received DevTools event '$($message.method)' while waiting for command id $CommandId."
}
else {
WriteLog "Received DevTools message without id or method while waiting for command id $CommandId."
}
}
finally {
if ($null -ne $localStream) {
$localStream.Dispose()
}
}
}
throw "No DevTools response received for command id $CommandId after $MaxPolls polls."
}
WriteLog "Waiting for PSREF page to initialize local storage context."
Start-Sleep -Seconds 2
$commandCounter = 1000
$rawToken = $null
$maxTokenAttempts = 12
for ($attempt = 1; $attempt -le $maxTokenAttempts -and [string]::IsNullOrWhiteSpace($rawToken); $attempt++) {
$commandCounter++
$tokenResponse = Invoke-DevToolsValue -CommandId $commandCounter -Expression "window.localStorage?.getItem('asut')" -MaxPolls 25
if ($null -ne $tokenResponse -and -not [string]::IsNullOrWhiteSpace($tokenResponse.Value)) {
$rawToken = $tokenResponse.Value
WriteLog "DevTools response for command id $commandCounter returned token length $($rawToken.Length)."
break
}
WriteLog "Lenovo PSREF token not yet available (attempt $attempt of $maxTokenAttempts)."
$commandCounter++
$keysResponse = Invoke-DevToolsValue -CommandId $commandCounter -Expression "JSON.stringify(Object.keys(window.localStorage || {}))" -MaxPolls 10
if ($null -ne $keysResponse -and -not [string]::IsNullOrWhiteSpace($keysResponse.Value)) {
WriteLog "Current localStorage keys: $($keysResponse.Value)"
}
$commandCounter++
$cookieResponse = Invoke-DevToolsValue -CommandId $commandCounter -Expression "document.cookie" -MaxPolls 10
if ($null -ne $cookieResponse -and -not [string]::IsNullOrWhiteSpace($cookieResponse.Value)) {
WriteLog "document.cookie contents: $($cookieResponse.Value)"
$cookieEntry = ($cookieResponse.Value -split ';') | ForEach-Object { $_.Trim() } | Where-Object { $_ -like 'asut=*' } | Select-Object -First 1
if ($cookieEntry) {
$rawToken = $cookieEntry.Substring($cookieEntry.IndexOf('=') + 1)
WriteLog "Extracted Lenovo PSREF token from cookies with length $($rawToken.Length)."
break
}
}
Start-Sleep -Milliseconds 750
}
if ([string]::IsNullOrWhiteSpace($rawToken)) {
throw "Received empty Lenovo PSREF token from Edge DevTools after $maxTokenAttempts attempts."
}
$token = "X-PSREF-USER-TOKEN=$rawToken"
WriteLog "Retrieved Lenovo PSREF token: $token"
} }
catch { catch {
WriteLog "Could not run netstat to find listening PID. Error: $($_.Exception.Message)" WriteLog "Failed to retrieve Lenovo PSREF token. Error: $($_.Exception.Message)"
throw
} }
finally {
# Determine the correct PID to kill. Prioritize the one found via netstat. if ($null -ne $socket) {
$pidToKill = $null try {
if ($listeningPid) { $socket.Dispose()
$pidToKill = $listeningPid WriteLog "Edge DevTools WebSocket disposed."
} }
elseif ($null -ne $edgeProcess -and -not $edgeProcess.HasExited) { catch {
$pidToKill = $edgeProcess.Id WriteLog "Error disposing Edge DevTools WebSocket: $($_.Exception.Message)"
WriteLog "Could not find listening process via netstat. Falling back to initial Edge process PID $($pidToKill) for termination." }
}
if ($pidToKill) {
WriteLog "Attempting to terminate Edge process tree with PID: $pidToKill"
try {
taskkill /PID $pidToKill /T /F | Out-Null
WriteLog "Successfully issued termination command for Edge process tree with PID: $pidToKill."
} }
catch {
WriteLog "Failed to terminate Edge process tree with PID: $pidToKill. It may have already closed. Error: $($_.Exception.Message)" $listeningPid = $null
if ($null -ne $port) {
try {
$netstatOutput = netstat -ano -p TCP | Where-Object { $_ -match "127\.0\.0\.1:$port.*LISTENING" }
if ($netstatOutput) {
$listeningPid = ($netstatOutput -split '\s+')[-1]
WriteLog "Found Edge process PID $listeningPid listening on port $port."
}
else {
WriteLog "No process reported as listening on port $port."
}
}
catch {
WriteLog "Could not run netstat to find listening PID for port $port. Error: $($_.Exception.Message)"
}
}
$pidToKill = $null
if ($null -ne $listeningPid) {
$pidToKill = $listeningPid
}
elseif ($null -ne $edgeProcess -and -not $edgeProcess.HasExited) {
$pidToKill = $edgeProcess.Id
WriteLog "Falling back to initial Edge process PID $pidToKill for termination."
}
if ($null -ne $pidToKill) {
try {
taskkill /PID $pidToKill /T /F | Out-Null
WriteLog "Issued termination command for Edge process tree with PID: $pidToKill."
}
catch {
WriteLog "Failed to terminate Edge process tree with PID: $pidToKill. Error: $($_.Exception.Message)"
}
}
else {
WriteLog "No active Edge process found to terminate."
}
if ($null -ne $edgeProcess) {
try {
$edgeProcess.WaitForExit(3000) | Out-Null
}
catch {
WriteLog "Error while waiting for Edge process PID $($edgeProcess.Id) to exit: $($_.Exception.Message)"
}
}
Start-Sleep -Milliseconds 250
if (-not [string]::IsNullOrWhiteSpace($tempProfile) -and (Test-Path -Path $tempProfile -PathType Container)) {
$maxRemoveAttempts = 5
$originalProgressPreference = $ProgressPreference
try {
$ProgressPreference = 'SilentlyContinue'
for ($removeAttempt = 1; $removeAttempt -le $maxRemoveAttempts; $removeAttempt++) {
try {
Remove-Item -Path $tempProfile -Recurse -Force -ErrorAction Stop
WriteLog "Removed temporary Edge profile at $tempProfile."
break
}
catch {
if ($removeAttempt -eq $maxRemoveAttempts) {
WriteLog "Failed to remove temporary Edge profile at $tempProfile after $maxRemoveAttempts attempts. Error: $($_.Exception.Message)"
}
else {
WriteLog "Temporary Edge profile still locked (attempt $removeAttempt of $maxRemoveAttempts). Retrying..."
Start-Sleep -Milliseconds 500
}
}
}
}
finally {
$ProgressPreference = $originalProgressPreference
}
} }
}
else {
WriteLog "No active Edge process found to terminate."
} }
return $token return $token
@@ -389,4 +815,8 @@ function Get-LenovoPSREFToken {
# SECTION: Module Export # SECTION: Module Export
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
Export-ModuleMember -Function Compress-DriverFolderToWim, Update-DriverMappingJson, Test-ExistingDriver, Get-LenovoPSREFToken Export-ModuleMember -Function `
Compress-DriverFolderToWim, `
Update-DriverMappingJson, `
Test-ExistingDriver, `
Get-LenovoPSREFToken
@@ -156,14 +156,16 @@ function Invoke-ParallelProcessing {
# Execute the appropriate background task based on $localTaskType # Execute the appropriate background task based on $localTaskType
switch ($localTaskType) { switch ($localTaskType) {
'WingetDownload' { 'WingetDownload' {
# Pass the progress queue to the task function # Pass the progress queue and SkipWin32Json to the task function
$wingetTaskArgs = @{ $wingetTaskArgs = @{
ApplicationItemData = $currentItem ApplicationItemData = $currentItem
AppListJsonPath = $localJobArgs['AppListJsonPath'] AppListJsonPath = $localJobArgs['AppListJsonPath']
AppsPath = $localJobArgs['AppsPath'] AppsPath = $localJobArgs['AppsPath']
UserAppListPath = $localJobArgs['UserAppListPath']
OrchestrationPath = $localJobArgs['OrchestrationPath'] OrchestrationPath = $localJobArgs['OrchestrationPath']
ProgressQueue = $localProgressQueue ProgressQueue = $localProgressQueue
WindowsArch = $localJobArgs['WindowsArch'] WindowsArch = $localJobArgs['WindowsArch']
SkipWin32Json = [bool]$localJobArgs['SkipWin32Json']
} }
$taskResult = Start-WingetAppDownloadTask @wingetTaskArgs $taskResult = Start-WingetAppDownloadTask @wingetTaskArgs
if ($null -ne $taskResult) { if ($null -ne $taskResult) {
@@ -209,7 +211,8 @@ function Invoke-ParallelProcessing {
-Headers $localJobArgs['Headers'] ` -Headers $localJobArgs['Headers'] `
-UserAgent $localJobArgs['UserAgent'] ` -UserAgent $localJobArgs['UserAgent'] `
-ProgressQueue $localProgressQueue ` -ProgressQueue $localProgressQueue `
-CompressToWim $localJobArgs['CompressToWim'] -CompressToWim $localJobArgs['CompressToWim'] `
-PreserveSourceOnCompress $localJobArgs['PreserveSourceOnCompress']
} }
'Dell' { 'Dell' {
$taskResult = Save-DellDriversTask -DriverItemData $currentItem ` $taskResult = Save-DellDriversTask -DriverItemData $currentItem `
@@ -217,7 +220,8 @@ function Invoke-ParallelProcessing {
-WindowsArch $localJobArgs['WindowsArch'] ` -WindowsArch $localJobArgs['WindowsArch'] `
-WindowsRelease $localJobArgs['WindowsRelease'] ` -WindowsRelease $localJobArgs['WindowsRelease'] `
-ProgressQueue $localProgressQueue ` -ProgressQueue $localProgressQueue `
-CompressToWim $localJobArgs['CompressToWim'] -CompressToWim $localJobArgs['CompressToWim'] `
-PreserveSourceOnCompress $localJobArgs['PreserveSourceOnCompress']
} }
'HP' { 'HP' {
$taskResult = Save-HPDriversTask -DriverItemData $currentItem ` $taskResult = Save-HPDriversTask -DriverItemData $currentItem `
@@ -226,7 +230,8 @@ function Invoke-ParallelProcessing {
-WindowsRelease $localJobArgs['WindowsRelease'] ` -WindowsRelease $localJobArgs['WindowsRelease'] `
-WindowsVersion $localJobArgs['WindowsVersion'] ` -WindowsVersion $localJobArgs['WindowsVersion'] `
-ProgressQueue $localProgressQueue ` -ProgressQueue $localProgressQueue `
-CompressToWim $localJobArgs['CompressToWim'] -CompressToWim $localJobArgs['CompressToWim'] `
-PreserveSourceOnCompress $localJobArgs['PreserveSourceOnCompress']
} }
'Lenovo' { 'Lenovo' {
$taskResult = Save-LenovoDriversTask -DriverItemData $currentItem ` $taskResult = Save-LenovoDriversTask -DriverItemData $currentItem `
@@ -235,7 +240,8 @@ function Invoke-ParallelProcessing {
-Headers $localJobArgs['Headers'] ` -Headers $localJobArgs['Headers'] `
-UserAgent $localJobArgs['UserAgent'] ` -UserAgent $localJobArgs['UserAgent'] `
-ProgressQueue $localProgressQueue ` -ProgressQueue $localProgressQueue `
-CompressToWim $localJobArgs['CompressToWim'] -CompressToWim $localJobArgs['CompressToWim'] `
-PreserveSourceOnCompress $localJobArgs['PreserveSourceOnCompress']
} }
default { default {
$unsupportedMakeMessage = "Error: Unsupported Make '$make' for driver download." $unsupportedMakeMessage = "Error: Unsupported Make '$make' for driver download."
@@ -265,7 +271,7 @@ function Invoke-ParallelProcessing {
else { else {
# Fallback for any task that *still* doesn't return 'Success'. This is now the exceptional case. # 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)'" 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 $resultCode = 0 # Treat as success
} }
else { else {
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -67,8 +67,11 @@ Description = 'Common functions shared between FFU Builder UI and the BuildFFUVM
# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess
NestedModules = @('FFU.Common.Drivers.psm1', NestedModules = @('FFU.Common.Drivers.psm1',
'FFU.Common.Drivers.Microsoft.psm1',
'FFU.Common.Drivers.Dell.psm1',
'FFU.Common.Winget.psm1', 'FFU.Common.Winget.psm1',
'FFU.Common.Parallel.psm1') 'FFU.Common.Parallel.psm1',
'FFU.Common.Cleanup.psm1')
# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
FunctionsToExport = '*' FunctionsToExport = '*'
@@ -27,6 +27,22 @@ function Update-BYOAppsActionButtonsState {
} }
} }
# Function to resolve the configured BYO app list path
function Get-BYOApplicationListPath {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[psobject]$State
)
# Fall back to the legacy default path when the textbox is empty.
if (-not [string]::IsNullOrWhiteSpace($State.Controls.txtUserAppListPath.Text)) {
return $State.Controls.txtUserAppListPath.Text
}
return (Join-Path -Path $State.Controls.txtApplicationPath.Text -ChildPath 'UserAppList.json')
}
# Function to remove all selected BYO applications # Function to remove all selected BYO applications
function Remove-SelectedBYOApplications { function Remove-SelectedBYOApplications {
[CmdletBinding()] [CmdletBinding()]
@@ -76,10 +92,10 @@ function Remove-SelectedBYOApplications {
} }
# Ask user if they want to save the changes # Ask user if they want to save the changes
$result = [System.Windows.MessageBox]::Show("The selected applications have been removed from the list. Do you want to save these changes to UserAppList.json now?", "Save Changes", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question) $result = [System.Windows.MessageBox]::Show("The selected applications have been removed from the list. Do you want to save these changes to the configured BYO app list now?", "Save Changes", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question)
if ($result -eq 'Yes') { if ($result -eq 'Yes') {
$userAppListPath = Join-Path -Path $State.Controls.txtApplicationPath.Text -ChildPath 'UserAppList.json' $userAppListPath = Get-BYOApplicationListPath -State $State
Save-BYOApplicationList -Path $userAppListPath -State $State Save-BYOApplicationList -Path $userAppListPath -State $State
} }
} }
@@ -166,6 +182,7 @@ function Add-BYOApplication {
# Refresh the ListView to show the changes # Refresh the ListView to show the changes
$listView.Items.Refresh() $listView.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $listView
# Reset state # Reset state
$State.Data.editingBYOApplication = $null $State.Data.editingBYOApplication = $null
@@ -196,6 +213,7 @@ function Add-BYOApplication {
CopyStatus = "" CopyStatus = ""
} }
$listView.Items.Add($application) $listView.Items.Add($application)
Request-ListViewColumnAutoResize -ListView $listView
} }
# Clear form and update button states for both add and update operations # Clear form and update button states for both add and update operations
@@ -269,6 +287,7 @@ function Add-AppsScriptVariable {
} }
$State.Data.appsScriptVariablesDataList.Add($newItem) $State.Data.appsScriptVariablesDataList.Add($newItem)
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray() $State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables
$State.Controls.txtAppsScriptKey.Clear() $State.Controls.txtAppsScriptKey.Clear()
$State.Controls.txtAppsScriptValue.Clear() $State.Controls.txtAppsScriptValue.Clear()
# Update the header checkbox state # Update the header checkbox state
@@ -295,6 +314,7 @@ function Remove-SelectedAppsScriptVariable {
$State.Data.appsScriptVariablesDataList.Remove($itemToRemove) $State.Data.appsScriptVariablesDataList.Remove($itemToRemove)
} }
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray() $State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables
# Update the header checkbox state # Update the header checkbox state
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) { if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
@@ -391,18 +411,24 @@ function Invoke-CopyBYOApps {
) )
$localAppsPath = $State.Controls.txtApplicationPath.Text $localAppsPath = $State.Controls.txtApplicationPath.Text
$userAppListPath = Join-Path -Path $localAppsPath -ChildPath 'UserAppList.json' $userAppListPath = Get-BYOApplicationListPath -State $State
$listView = $State.Controls.lstApplications $listView = $State.Controls.lstApplications
try { try {
# Ensure items are sorted by current priority before saving # Ensure the configured BYO app list folder exists before writing the manifest.
# Exclude CopyStatus when saving and ensure Priority is an integer $userAppListDirectory = Split-Path -Path $userAppListPath -Parent
$applications = $listView.Items | Sort-Object Priority | Select-Object @{N = 'Priority'; E = { [int]$_.Priority } }, Name, CommandLine, Arguments, Source if (-not [string]::IsNullOrWhiteSpace($userAppListDirectory) -and -not (Test-Path -Path $userAppListDirectory -PathType Container)) {
New-Item -Path $userAppListDirectory -ItemType Directory -Force | Out-Null
}
# Ensure items are sorted by current priority before saving.
# Exclude CopyStatus when saving and ensure Priority is an integer; include AdditionalExitCodes and IgnoreNonZeroExitCodes for parity with Save-BYOApplicationList.
$applications = $listView.Items | Sort-Object Priority | Select-Object @{N = 'Priority'; E = { [int]$_.Priority } }, Name, CommandLine, Arguments, Source, AdditionalExitCodes, IgnoreNonZeroExitCodes
$applications | ConvertTo-Json -Depth 5 | Set-Content -Path $userAppListPath -Force -Encoding UTF8 $applications | ConvertTo-Json -Depth 5 | Set-Content -Path $userAppListPath -Force -Encoding UTF8
WriteLog "Successfully updated UserAppList.json with all applications from the UI." WriteLog "Successfully updated BYO app list at $userAppListPath with all applications from the UI."
} }
catch { catch {
$errorMessage = "Failed to update UserAppList.json: $_" $errorMessage = "Failed to update BYO app list at $($userAppListPath): $_"
WriteLog $errorMessage WriteLog $errorMessage
[System.Windows.MessageBox]::Show($errorMessage, "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error) [System.Windows.MessageBox]::Show($errorMessage, "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error)
return return
+685 -25
View File
@@ -25,7 +25,6 @@ function Get-UIConfig {
else { $null } else { $null }
BuildUSBDrive = $State.Controls.chkBuildUSBDriveEnable.IsChecked BuildUSBDrive = $State.Controls.chkBuildUSBDriveEnable.IsChecked
CleanupAppsISO = $State.Controls.chkCleanupAppsISO.IsChecked CleanupAppsISO = $State.Controls.chkCleanupAppsISO.IsChecked
CleanupCaptureISO = $State.Controls.chkCleanupCaptureISO.IsChecked
CleanupDeployISO = $State.Controls.chkCleanupDeployISO.IsChecked CleanupDeployISO = $State.Controls.chkCleanupDeployISO.IsChecked
CleanupDrivers = $State.Controls.chkCleanupDrivers.IsChecked CleanupDrivers = $State.Controls.chkCleanupDrivers.IsChecked
CompactOS = $State.Controls.chkCompactOS.IsChecked CompactOS = $State.Controls.chkCompactOS.IsChecked
@@ -34,16 +33,26 @@ function Get-UIConfig {
CopyDrivers = $State.Controls.chkCopyDrivers.IsChecked CopyDrivers = $State.Controls.chkCopyDrivers.IsChecked
CopyOfficeConfigXML = $State.Controls.chkCopyOfficeConfigXML.IsChecked CopyOfficeConfigXML = $State.Controls.chkCopyOfficeConfigXML.IsChecked
CopyPEDrivers = $State.Controls.chkCopyPEDrivers.IsChecked CopyPEDrivers = $State.Controls.chkCopyPEDrivers.IsChecked
UseDriversAsPEDrivers = $State.Controls.chkUseDriversAsPEDrivers.IsChecked
CopyPPKG = $State.Controls.chkCopyPPKG.IsChecked CopyPPKG = $State.Controls.chkCopyPPKG.IsChecked
CopyUnattend = $State.Controls.chkCopyUnattend.IsChecked CopyUnattend = $State.Controls.chkCopyUnattend.IsChecked
CreateCaptureMedia = $State.Controls.chkCreateCaptureMedia.IsChecked DeviceNamingMode = Get-ConfiguredDeviceNamingMode -State $State
DeviceNameTemplate = $State.Controls.txtDeviceNameTemplate.Text
DeviceNamePrefixesPath = $State.Controls.txtDeviceNamePrefixesPath.Text
DeviceNamePrefixes = @(Get-DeviceNamePrefixes -State $State)
DeviceNameSerialComputerNamesPath = $State.Controls.txtDeviceNameSerialComputerNamesPath.Text
DeviceNameSerialComputerNames = @(Get-SerialComputerNamesLines -State $State)
CopyAdditionalFFUFiles = $State.Controls.chkCopyAdditionalFFUFiles.IsChecked
CreateDeploymentMedia = $State.Controls.chkCreateDeploymentMedia.IsChecked CreateDeploymentMedia = $State.Controls.chkCreateDeploymentMedia.IsChecked
InjectUnattend = $State.Controls.chkInjectUnattend.IsChecked InjectUnattend = $State.Controls.chkInjectUnattend.IsChecked
UnattendX64FilePath = $State.Controls.txtUnattendX64FilePath.Text
UnattendArm64FilePath = $State.Controls.txtUnattendArm64FilePath.Text
CustomFFUNameTemplate = $State.Controls.txtCustomFFUNameTemplate.Text CustomFFUNameTemplate = $State.Controls.txtCustomFFUNameTemplate.Text
Disksize = [int64]$State.Controls.txtDiskSize.Text * 1GB Disksize = [int64]$State.Controls.txtDiskSize.Text * 1GB
DownloadDrivers = $State.Controls.chkDownloadDrivers.IsChecked DownloadDrivers = $State.Controls.chkDownloadDrivers.IsChecked
DriversFolder = $State.Controls.txtDriversFolder.Text DriversFolder = $State.Controls.txtDriversFolder.Text
DriversJsonPath = $State.Controls.txtDriversJsonPath.Text DriversJsonPath = $State.Controls.txtDriversJsonPath.Text
EnableVMNetworking = $State.Controls.chkEnableVMNetworking.IsChecked
FFUCaptureLocation = $State.Controls.txtFFUCaptureLocation.Text FFUCaptureLocation = $State.Controls.txtFFUCaptureLocation.Text
FFUDevelopmentPath = $State.Controls.txtFFUDevPath.Text FFUDevelopmentPath = $State.Controls.txtFFUDevPath.Text
FFUPrefix = $State.Controls.txtVMNamePrefix.Text FFUPrefix = $State.Controls.txtVMNamePrefix.Text
@@ -52,6 +61,7 @@ function Get-UIConfig {
InstallOffice = $State.Controls.chkInstallOffice.IsChecked InstallOffice = $State.Controls.chkInstallOffice.IsChecked
InstallWingetApps = $State.Controls.chkInstallWingetApps.IsChecked InstallWingetApps = $State.Controls.chkInstallWingetApps.IsChecked
ISOPath = $State.Controls.txtISOPath.Text ISOPath = $State.Controls.txtISOPath.Text
WindowsMediaSource = if ($null -ne $State.Controls.rbProvideISO -and $State.Controls.rbProvideISO.IsChecked) { "Provide Windows ISO" } else { "Download Windows ESD" }
LogicalSectorSizeBytes = [int]$State.Controls.cmbLogicalSectorSize.SelectedItem.Content LogicalSectorSizeBytes = [int]$State.Controls.cmbLogicalSectorSize.SelectedItem.Content
# Make = $null # Make = $null
MediaType = $State.Controls.cmbMediaType.SelectedItem MediaType = $State.Controls.cmbMediaType.SelectedItem
@@ -80,7 +90,7 @@ function Get-UIConfig {
RemoveApps = $State.Controls.chkRemoveApps.IsChecked RemoveApps = $State.Controls.chkRemoveApps.IsChecked
RemoveFFU = $State.Controls.chkRemoveFFU.IsChecked RemoveFFU = $State.Controls.chkRemoveFFU.IsChecked
RemoveUpdates = $State.Controls.chkRemoveUpdates.IsChecked RemoveUpdates = $State.Controls.chkRemoveUpdates.IsChecked
ShareName = $State.Controls.txtShareName.Text RemoveDownloadedESD = $State.Controls.chkRemoveDownloadedESD.IsChecked
UpdateADK = $State.Controls.chkUpdateADK.IsChecked UpdateADK = $State.Controls.chkUpdateADK.IsChecked
UpdateEdge = $State.Controls.chkUpdateEdge.IsChecked UpdateEdge = $State.Controls.chkUpdateEdge.IsChecked
UpdateLatestCU = $State.Controls.chkUpdateLatestCU.IsChecked UpdateLatestCU = $State.Controls.chkUpdateLatestCU.IsChecked
@@ -90,13 +100,13 @@ function Get-UIConfig {
UpdateLatestNet = $State.Controls.chkUpdateLatestNet.IsChecked UpdateLatestNet = $State.Controls.chkUpdateLatestNet.IsChecked
UpdateOneDrive = $State.Controls.chkUpdateOneDrive.IsChecked UpdateOneDrive = $State.Controls.chkUpdateOneDrive.IsChecked
UpdatePreviewCU = $State.Controls.chkUpdatePreviewCU.IsChecked UpdatePreviewCU = $State.Controls.chkUpdatePreviewCU.IsChecked
UserAppListPath = "$($State.Controls.txtApplicationPath.Text)\UserAppList.json" UserAppListPath = $State.Controls.txtUserAppListPath.Text
USBDriveList = @{} USBDriveList = @{}
Username = $State.Controls.txtUsername.Text
Threads = [int]$State.Controls.txtThreads.Text Threads = [int]$State.Controls.txtThreads.Text
BitsPriority = $State.Controls.cmbBitsPriority.SelectedItem
MaxUSBDrives = [int]$State.Controls.txtMaxUSBDrives.Text MaxUSBDrives = [int]$State.Controls.txtMaxUSBDrives.Text
ThemeMode = if ($null -ne $State.Controls.cmbThemeMode -and $null -ne $State.Controls.cmbThemeMode.SelectedItem) { $State.Controls.cmbThemeMode.SelectedItem } else { "System" }
Verbose = $State.Controls.chkVerbose.IsChecked Verbose = $State.Controls.chkVerbose.IsChecked
VMHostIPAddress = $State.Controls.txtVMHostIPAddress.Text
VMLocation = $State.Controls.txtVMLocation.Text VMLocation = $State.Controls.txtVMLocation.Text
VMSwitchName = if ($State.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') { VMSwitchName = if ($State.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') {
$State.Controls.txtCustomVMSwitchName.Text $State.Controls.txtCustomVMSwitchName.Text
@@ -111,8 +121,38 @@ function Get-UIConfig {
WindowsVersion = $State.Controls.cmbWindowsVersion.SelectedItem WindowsVersion = $State.Controls.cmbWindowsVersion.SelectedItem
} }
# Save selected USB drives using UniqueId for reliable identification
# Multiple physical drives can share the same Model, so store an array of UniqueIds per Model.
$State.Controls.lstUSBDrives.Items | Where-Object { $_.IsSelected } | ForEach-Object { $State.Controls.lstUSBDrives.Items | Where-Object { $_.IsSelected } | ForEach-Object {
$config.USBDriveList[$_.Model] = $_.SerialNumber $modelName = $_.Model
$uniqueId = $_.UniqueId
if ([string]::IsNullOrWhiteSpace($modelName) -or [string]::IsNullOrWhiteSpace($uniqueId)) {
return
}
# Ensure the hashtable value is always an array so multiple same-model drives are preserved
$existingUniqueIds = $config.USBDriveList[$modelName]
if ($null -eq $existingUniqueIds) {
$config.USBDriveList[$modelName] = @($uniqueId)
return
}
$existingUniqueIds = @($existingUniqueIds)
if (-not ($existingUniqueIds -contains $uniqueId)) {
$existingUniqueIds += $uniqueId
}
$config.USBDriveList[$modelName] = $existingUniqueIds
}
# Additional FFU file selections
$config.AdditionalFFUFiles = @()
if ($State.Controls.chkCopyAdditionalFFUFiles.IsChecked) {
$config.AdditionalFFUFiles = @(
$State.Controls.lstAdditionalFFUs.Items |
Where-Object { $_.IsSelected } |
ForEach-Object { $_.FullName }
)
} }
return $config return $config
@@ -231,6 +271,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 { function Invoke-LoadConfiguration {
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
@@ -242,19 +331,39 @@ function Invoke-LoadConfiguration {
WriteLog "Load configuration cancelled by user." WriteLog "Load configuration cancelled by user."
return return
} }
WriteLog "Loading configuration from: $filePath" WriteLog "Loading configuration from: $filePath"
$configContent = Get-Content -Path $filePath -Raw | ConvertFrom-Json $raw = $null
try {
$raw = Get-Content -Path $filePath -Raw -ErrorAction Stop
}
catch {
WriteLog "LoadConfig Error: Failed reading file $filePath : $($_.Exception.Message)"
[System.Windows.MessageBox]::Show("Failed to read the configuration file.`n$($_.Exception.Message)", "Load Error", "OK", "Error")
return
}
if ([string]::IsNullOrWhiteSpace($raw)) {
WriteLog "LoadConfig Error: File $filePath is empty."
[System.Windows.MessageBox]::Show("The selected configuration file is empty.", "Load Error", "OK", "Error")
return
}
$configContent = $null
try {
$configContent = $raw | ConvertFrom-Json -ErrorAction Stop
}
catch {
WriteLog "LoadConfig Error: JSON parse failure for $filePath : $($_.Exception.Message)"
[System.Windows.MessageBox]::Show("Failed to parse the configuration file (invalid JSON).`n$($_.Exception.Message)", "Load Error", "OK", "Error")
return
}
if ($null -eq $configContent) { if ($null -eq $configContent) {
WriteLog "LoadConfig Error: configContent is null after parsing $filePath. File might be empty or malformed." WriteLog "LoadConfig Error: Parsed config object is null after $filePath."
[System.Windows.MessageBox]::Show("Failed to parse the configuration file. It might be empty or not valid JSON.", "Load Error", "OK", "Error") [System.Windows.MessageBox]::Show("Parsed configuration object was null.", "Load Error", "OK", "Error")
return return
} }
WriteLog "LoadConfig: Successfully parsed config file. Top-level keys: $($configContent.PSObject.Properties.Name -join ', ')" WriteLog "LoadConfig: Successfully parsed config file. Top-level keys: $($configContent.PSObject.Properties.Name -join ', ')"
# Apply the configuration to the UI
Update-UIFromConfig -ConfigContent $configContent -State $State Update-UIFromConfig -ConfigContent $configContent -State $State
$State.Data.lastConfigFilePath = $filePath
Import-ConfigSupplementalAssets -ConfigContent $configContent -State $State -ShowWarnings:$true
} }
catch { catch {
WriteLog "LoadConfig FATAL Error: $($_.Exception.ToString())" WriteLog "LoadConfig FATAL Error: $($_.Exception.ToString())"
@@ -309,7 +418,6 @@ function Select-VMSwitchFromConfig {
$State.Controls.txtCustomVMSwitchName.Visibility = 'Visible' $State.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
$State.Controls.txtCustomVMSwitchName.Text = $configSwitch $State.Controls.txtCustomVMSwitchName.Text = $configSwitch
$State.Data.customVMSwitchName = $configSwitch $State.Data.customVMSwitchName = $configSwitch
$State.Data.customVMHostIP = $ConfigContent.VMHostIPAddress
WriteLog "LoadConfig: VMSwitchName '$configSwitch' not found. Selected 'Other' and populated custom VM Switch Name textbox." WriteLog "LoadConfig: VMSwitchName '$configSwitch' not found. Selected 'Other' and populated custom VM Switch Name textbox."
} }
} }
@@ -324,13 +432,21 @@ function Update-UIFromConfig {
WriteLog "Applying loaded configuration to the UI." WriteLog "Applying loaded configuration to the UI."
# Apply theme mode from config (must be done before other controls load for proper styling)
if ($null -ne $ConfigContent.PSObject.Properties.Item('ThemeMode') -and $State.Flags.isFluentSupported) {
$configTheme = $ConfigContent.ThemeMode
if ($configTheme -in @("Light", "Dark", "System")) {
Initialize-FluentTheme -Window $State.Window -ThemeMode $configTheme -State $State
Set-UIValue -ControlName 'cmbThemeMode' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'ThemeMode' -State $State
}
}
# Update Build tab values # Update Build tab values
Set-UIValue -ControlName 'txtFFUDevPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUDevelopmentPath' -State $State Set-UIValue -ControlName 'txtFFUDevPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUDevelopmentPath' -State $State
Set-UIValue -ControlName 'txtCustomFFUNameTemplate' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'CustomFFUNameTemplate' -State $State Set-UIValue -ControlName 'txtCustomFFUNameTemplate' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'CustomFFUNameTemplate' -State $State
Set-UIValue -ControlName 'txtFFUCaptureLocation' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUCaptureLocation' -State $State Set-UIValue -ControlName 'txtFFUCaptureLocation' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUCaptureLocation' -State $State
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 '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 'txtMaxUSBDrives' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'MaxUSBDrives' -State $State
Set-UIValue -ControlName 'chkBuildUSBDriveEnable' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'BuildUSBDrive' -State $State Set-UIValue -ControlName 'chkBuildUSBDriveEnable' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'BuildUSBDrive' -State $State
Set-UIValue -ControlName 'chkCompactOS' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CompactOS' -State $State Set-UIValue -ControlName 'chkCompactOS' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CompactOS' -State $State
@@ -339,37 +455,91 @@ function Update-UIFromConfig {
Set-UIValue -ControlName 'chkAllowVHDXCaching' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'AllowVHDXCaching' -State $State Set-UIValue -ControlName 'chkAllowVHDXCaching' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'AllowVHDXCaching' -State $State
Set-UIValue -ControlName 'chkAllowExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'AllowExternalHardDiskMedia' -State $State Set-UIValue -ControlName 'chkAllowExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'AllowExternalHardDiskMedia' -State $State
Set-UIValue -ControlName 'chkPromptExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'PromptExternalHardDiskMedia' -State $State Set-UIValue -ControlName 'chkPromptExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'PromptExternalHardDiskMedia' -State $State
Set-UIValue -ControlName 'chkCreateCaptureMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CreateCaptureMedia' -State $State Set-UIValue -ControlName 'chkCopyAdditionalFFUFiles' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyAdditionalFFUFiles' -State $State
Set-UIValue -ControlName 'chkCreateDeploymentMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CreateDeploymentMedia' -State $State Set-UIValue -ControlName 'chkCreateDeploymentMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CreateDeploymentMedia' -State $State
Set-UIValue -ControlName 'chkInjectUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'InjectUnattend' -State $State Set-UIValue -ControlName 'chkInjectUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'InjectUnattend' -State $State
Set-UIValue -ControlName 'txtUnattendX64FilePath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'UnattendX64FilePath' -State $State
Set-UIValue -ControlName 'txtUnattendArm64FilePath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'UnattendArm64FilePath' -State $State
Set-UIValue -ControlName 'chkVerbose' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'Verbose' -State $State Set-UIValue -ControlName 'chkVerbose' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'Verbose' -State $State
if ([string]::IsNullOrWhiteSpace($State.Controls.txtUnattendX64FilePath.Text)) {
$State.Controls.txtUnattendX64FilePath.Text = Get-DefaultUnattendFilePath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text -WindowsArch 'x64'
}
if ([string]::IsNullOrWhiteSpace($State.Controls.txtUnattendArm64FilePath.Text)) {
$State.Controls.txtUnattendArm64FilePath.Text = Get-DefaultUnattendFilePath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text -WindowsArch 'arm64'
}
# USB Drive Modification group (Build Tab) # USB Drive Modification group (Build Tab)
Set-UIValue -ControlName 'chkCopyAutopilot' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyAutopilot' -State $State Set-UIValue -ControlName 'chkCopyAutopilot' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyAutopilot' -State $State
Set-UIValue -ControlName 'chkCopyUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyUnattend' -State $State Set-UIValue -ControlName 'chkCopyUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyUnattend' -State $State
Set-UIValue -ControlName 'chkCopyPPKG' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyPPKG' -State $State Set-UIValue -ControlName 'chkCopyPPKG' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyPPKG' -State $State
Set-UIValue -ControlName 'txtDeviceNamePrefixesPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNamePrefixesPath' -State $State
Set-UIValue -ControlName 'txtDeviceNameSerialComputerNamesPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNameSerialComputerNamesPath' -State $State
Set-UIValue -ControlName 'txtDeviceNameTemplate' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNameTemplate' -State $State
Set-UIValue -ControlName 'txtDeviceNamePrefixes' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNamePrefixes' -TransformValue { param($val) if ($val -is [System.Array]) { $val -join [System.Environment]::NewLine } else { [string]$val } } -State $State
Set-UIValue -ControlName 'txtDeviceNameSerialComputerNames' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNameSerialComputerNames' -TransformValue { param($val) if ($val -is [System.Array]) { $val -join [System.Environment]::NewLine } else { [string]$val } } -State $State
if ([string]::IsNullOrWhiteSpace($State.Controls.txtDeviceNamePrefixesPath.Text)) {
$State.Controls.txtDeviceNamePrefixesPath.Text = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text
}
if ([string]::IsNullOrWhiteSpace($State.Controls.txtDeviceNameSerialComputerNamesPath.Text)) {
$State.Controls.txtDeviceNameSerialComputerNamesPath.Text = Get-DefaultSerialComputerNamesPath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text
}
$loadedDeviceNamingMode = $null
if ($ConfigContent.PSObject.Properties.Name -contains 'DeviceNamingMode') {
$candidateDeviceNamingMode = [string]$ConfigContent.DeviceNamingMode
if ($candidateDeviceNamingMode -in @('Legacy', 'None', 'Prompt', 'Template', 'Prefixes', 'SerialComputerNames')) {
$loadedDeviceNamingMode = $candidateDeviceNamingMode
}
}
$displayDeviceNamingMode = if ($loadedDeviceNamingMode -in @('Prompt', 'Template', 'Prefixes', 'SerialComputerNames')) {
$loadedDeviceNamingMode
}
else {
'None'
}
Set-DeviceNamingModeState -State $State -DisplayMode $displayDeviceNamingMode -LoadedMode $loadedDeviceNamingMode
Import-DeviceNamePrefixesFromConfiguredPath -State $State
Import-SerialComputerNamesFromConfiguredPath -State $State
Update-DeviceNamingControls -State $State
# Post Build Cleanup group (Build Tab) # Post Build Cleanup group (Build Tab)
Set-UIValue -ControlName 'chkCleanupAppsISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupAppsISO' -State $State Set-UIValue -ControlName 'chkCleanupAppsISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupAppsISO' -State $State
Set-UIValue -ControlName 'chkCleanupCaptureISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupCaptureISO' -State $State
Set-UIValue -ControlName 'chkCleanupDeployISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupDeployISO' -State $State Set-UIValue -ControlName 'chkCleanupDeployISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupDeployISO' -State $State
Set-UIValue -ControlName 'chkCleanupDrivers' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupDrivers' -State $State Set-UIValue -ControlName 'chkCleanupDrivers' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupDrivers' -State $State
Set-UIValue -ControlName 'chkRemoveFFU' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveFFU' -State $State Set-UIValue -ControlName 'chkRemoveFFU' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveFFU' -State $State
Set-UIValue -ControlName 'chkRemoveApps' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveApps' -State $State Set-UIValue -ControlName 'chkRemoveApps' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveApps' -State $State
Set-UIValue -ControlName 'chkRemoveUpdates' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveUpdates' -State $State Set-UIValue -ControlName 'chkRemoveUpdates' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveUpdates' -State $State
Set-UIValue -ControlName 'chkRemoveDownloadedESD' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveDownloadedESD' -State $State
# Hyper-V Settings # Hyper-V Settings
Set-UIValue -ControlName 'chkEnableVMNetworking' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'EnableVMNetworking' -State $State
Select-VMSwitchFromConfig -State $State -ConfigContent $ConfigContent Select-VMSwitchFromConfig -State $State -ConfigContent $ConfigContent
Set-UIValue -ControlName 'txtVMHostIPAddress' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'VMHostIPAddress' -State $State
Set-UIValue -ControlName 'txtDiskSize' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Disksize' -TransformValue { param($val) $val / 1GB } -State $State Set-UIValue -ControlName 'txtDiskSize' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Disksize' -TransformValue { param($val) $val / 1GB } -State $State
Set-UIValue -ControlName 'txtMemory' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Memory' -TransformValue { param($val) $val / 1GB } -State $State Set-UIValue -ControlName 'txtMemory' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Memory' -TransformValue { param($val) $val / 1GB } -State $State
Set-UIValue -ControlName 'txtProcessors' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Processors' -State $State Set-UIValue -ControlName 'txtProcessors' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Processors' -State $State
Set-UIValue -ControlName 'txtVMLocation' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'VMLocation' -State $State Set-UIValue -ControlName 'txtVMLocation' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'VMLocation' -State $State
Set-UIValue -ControlName 'txtVMNamePrefix' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUPrefix' -State $State Set-UIValue -ControlName 'txtVMNamePrefix' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUPrefix' -State $State
Set-UIValue -ControlName 'cmbLogicalSectorSize' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'LogicalSectorSizeBytes' -TransformValue { param($val) $val.ToString() } -State $State Set-UIValue -ControlName 'cmbLogicalSectorSize' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'LogicalSectorSizeBytes' -TransformValue { param($val) $val.ToString() } -State $State
$State.Controls.spVMNetworkingSettings.IsEnabled = $true -eq $State.Controls.chkEnableVMNetworking.IsChecked
if (-not ($true -eq $State.Controls.chkEnableVMNetworking.IsChecked)) {
$State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
}
# Windows Settings # Windows Settings
Set-UIValue -ControlName 'txtISOPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ISOPath' -State $State Set-UIValue -ControlName 'txtISOPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ISOPath' -State $State
# Load Windows Media Source setting
if ($null -ne $ConfigContent.PSObject.Properties.Item('WindowsMediaSource')) {
if ($ConfigContent.WindowsMediaSource -eq 'Provide Windows ISO') {
$State.Controls.rbProvideISO.IsChecked = $true
}
else {
$State.Controls.rbDownloadESD.IsChecked = $true
}
}
# Special handling for Windows Release and SKU due to value collision (e.g., 2019 for Server and LTSC) # Special handling for Windows Release and SKU due to value collision (e.g., 2019 for Server and LTSC)
if (($null -ne $ConfigContent.PSObject.Properties.Item('WindowsRelease')) -and ($null -ne $ConfigContent.PSObject.Properties.Item('WindowsSKU'))) { if (($null -ne $ConfigContent.PSObject.Properties.Item('WindowsRelease')) -and ($null -ne $ConfigContent.PSObject.Properties.Item('WindowsSKU'))) {
@@ -460,6 +630,7 @@ function Update-UIFromConfig {
Set-UIValue -ControlName 'txtPEDriversFolder' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'PEDriversFolder' -State $State Set-UIValue -ControlName 'txtPEDriversFolder' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'PEDriversFolder' -State $State
Set-UIValue -ControlName 'txtDriversJsonPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DriversJsonPath' -State $State Set-UIValue -ControlName 'txtDriversJsonPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DriversJsonPath' -State $State
Set-UIValue -ControlName 'chkCopyPEDrivers' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyPEDrivers' -State $State Set-UIValue -ControlName 'chkCopyPEDrivers' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyPEDrivers' -State $State
Set-UIValue -ControlName 'chkUseDriversAsPEDrivers' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'UseDriversAsPEDrivers' -State $State
Set-UIValue -ControlName 'chkCompressDriversToWIM' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CompressDownloadedDriversToWim' -State $State Set-UIValue -ControlName 'chkCompressDriversToWIM' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CompressDownloadedDriversToWim' -State $State
# Updates tab # Updates tab
@@ -478,6 +649,7 @@ function Update-UIFromConfig {
Set-UIValue -ControlName 'chkBringYourOwnApps' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'BringYourOwnApps' -State $State Set-UIValue -ControlName 'chkBringYourOwnApps' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'BringYourOwnApps' -State $State
Set-UIValue -ControlName 'txtApplicationPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'AppsPath' -State $State Set-UIValue -ControlName 'txtApplicationPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'AppsPath' -State $State
Set-UIValue -ControlName 'txtAppListJsonPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'AppListPath' -State $State Set-UIValue -ControlName 'txtAppListJsonPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'AppListPath' -State $State
Set-UIValue -ControlName 'txtUserAppListPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'UserAppListPath' -State $State
# Handle AppsScriptVariables # Handle AppsScriptVariables
$appsScriptVarsKeyExists = $false $appsScriptVarsKeyExists = $false
@@ -541,6 +713,7 @@ function Update-UIFromConfig {
} }
# Update the ListView's ItemsSource after populating the data list # Update the ListView's ItemsSource after populating the data list
$lstAppsScriptVars.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray() $lstAppsScriptVars.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
Request-ListViewColumnAutoResize -ListView $lstAppsScriptVars
# Update the header checkbox state # Update the header checkbox state
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) { if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
Update-SelectAllHeaderCheckBoxState -ListView $lstAppsScriptVars -HeaderCheckBox $State.Controls.chkSelectAllAppsScriptVariables Update-SelectAllHeaderCheckBoxState -ListView $lstAppsScriptVars -HeaderCheckBox $State.Controls.chkSelectAllAppsScriptVariables
@@ -584,8 +757,21 @@ function Update-UIFromConfig {
} }
} }
if ($propertyExists -and ($propertyValue -eq $item.SerialNumber)) { # Match USB drives by UniqueId instead of SerialNumber
WriteLog "LoadConfig: Selecting USB Drive Model '$($item.Model)' with Serial '$($item.SerialNumber)'." # USBDriveList values can be a single UniqueId (string) or an array of UniqueIds (multiple same-model drives)
$isMatch = $false
if ($propertyExists) {
if ($propertyValue -is [string]) {
$isMatch = ($propertyValue -eq $item.UniqueId)
}
else {
$propertyValueArray = @($propertyValue)
$isMatch = ($propertyValueArray -contains $item.UniqueId)
}
}
if ($isMatch) {
WriteLog "LoadConfig: Selecting USB Drive Model '$($item.Model)' with UniqueId '$($item.UniqueId)'."
$item.IsSelected = $true $item.IsSelected = $true
} }
else { else {
@@ -596,6 +782,7 @@ function Update-UIFromConfig {
} }
} }
$State.Controls.lstUSBDrives.Items.Refresh() $State.Controls.lstUSBDrives.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstUSBDrives
# Update the Select All header checkbox state # Update the Select All header checkbox state
$headerChk = $State.Controls.chkSelectAllUSBDrivesHeader $headerChk = $State.Controls.chkSelectAllUSBDrivesHeader
@@ -632,8 +819,48 @@ function Update-UIFromConfig {
else { else {
WriteLog "LoadConfig: Condition to auto-check 'Select Specific USB Drives' was NOT met." WriteLog "LoadConfig: Condition to auto-check 'Select Specific USB Drives' was NOT met."
} }
WriteLog "LoadConfig: Configuration loading process finished." # Populate additional FFU list and apply selections
} try {
if ($State.Controls.chkCopyAdditionalFFUFiles.IsChecked) {
$State.Controls.additionalFFUPanel.Visibility = 'Visible'
if ($State.Controls.btnRefreshAdditionalFFUs) {
$State.Controls.btnRefreshAdditionalFFUs.RaiseEvent([System.Windows.RoutedEventArgs]::new([System.Windows.Controls.Button]::ClickEvent))
}
$selectedFiles = @()
$addFFUKeyExists = $false
if ($ConfigContent -is [System.Management.Automation.PSCustomObject] -and $null -ne $ConfigContent.PSObject.Properties) {
if (($ConfigContent.PSObject.Properties.Match('AdditionalFFUFiles')).Count -gt 0) {
$addFFUKeyExists = $true
}
}
if ($addFFUKeyExists -and $null -ne $ConfigContent.AdditionalFFUFiles) {
$selectedFiles = @($ConfigContent.AdditionalFFUFiles)
}
if ($selectedFiles.Count -gt 0) {
foreach ($item in $State.Controls.lstAdditionalFFUs.Items) {
if ($selectedFiles -contains $item.FullName) {
$item.IsSelected = $true
}
}
$State.Controls.lstAdditionalFFUs.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAdditionalFFUs
$headerChk = $State.Controls.chkSelectAllAdditionalFFUs
if ($null -ne $headerChk) {
Update-SelectAllHeaderCheckBoxState -ListView $State.Controls.lstAdditionalFFUs -HeaderCheckBox $headerChk
}
}
}
else {
$State.Controls.additionalFFUPanel.Visibility = 'Collapsed'
}
}
catch {
WriteLog "LoadConfig: Error applying Additional FFU selections: $($_.Exception.Message)"
}
Update-BitsPrioritySetting -State $State
WriteLog "LoadConfig: Configuration loading process finished."
}
function Invoke-SaveConfiguration { function Invoke-SaveConfiguration {
param( param(
@@ -655,7 +882,10 @@ function Invoke-SaveConfiguration {
-DefaultExt ".json" -DefaultExt ".json"
if ($savePath) { if ($savePath) {
$config | ConvertTo-Json -Depth 10 | Set-Content -Path $savePath -Encoding UTF8 # Sort top-level keys alphabetically for consistent output
$sortedConfig = [ordered]@{}
foreach ($k in ($config.Keys | Sort-Object)) { $sortedConfig[$k] = $config[$k] }
$sortedConfig | ConvertTo-Json -Depth 10 | Set-Content -Path $savePath -Encoding UTF8
[System.Windows.MessageBox]::Show("Configuration file saved to:`n$savePath", "Success", "OK", "Information") [System.Windows.MessageBox]::Show("Configuration file saved to:`n$savePath", "Success", "OK", "Information")
} }
} }
@@ -664,4 +894,434 @@ function Invoke-SaveConfiguration {
} }
} }
function Invoke-RestoreDefaults {
param(
[Parameter(Mandatory = $true)]
[psobject]$State
)
try {
$rootPath = $State.FFUDevelopmentPath
# Normalize potential array values to single strings
function Get-PathScalar {
param([object]$value)
if ($null -eq $value) { return $null }
if ($value -is [System.Array]) {
foreach ($v in $value) {
if (-not [string]::IsNullOrWhiteSpace([string]$v)) {
return [string]$v
}
}
return $null
}
return [string]$value
}
$appsPath = Join-Path $rootPath 'Apps'
$driversRaw = Get-PathScalar -value $State.Controls.txtDriversFolder.Text
if ([string]::IsNullOrWhiteSpace($driversRaw)) {
$driversPath = Join-Path $rootPath 'Drivers'
}
else {
$driversPath = $driversRaw
}
$ffuCaptureRaw = Get-PathScalar -value $State.Controls.txtFFUCaptureLocation.Text
$ffuCapturePath = if ([string]::IsNullOrWhiteSpace($ffuCaptureRaw)) { Join-Path $rootPath 'FFU' } else { $ffuCaptureRaw }
$deployISOPath = Join-Path $rootPath 'WinPEDeployFFUFiles\WinPE-Deploy.iso'
$appsISOPath = Join-Path $rootPath 'Apps.iso'
$msg = "Restore Defaults will:`n`n- Delete generated config and app/driver list JSON files`n- Remove ISO files (Deploy, Apps) if present`n- Remove Apps/Update/downloaded artifacts`n- Remove driver folder contents (not the folder)`n- Remove FFU files in the capture folder`n`nSample/template files and VM/VHDX cache are NOT removed.`n`nProceed?"
$result = [System.Windows.MessageBox]::Show($msg, "Confirm Restore Defaults", "YesNo", "Warning")
if ($result -ne [System.Windows.MessageBoxResult]::Yes) {
WriteLog "RestoreDefaults: User cancelled."
return
}
WriteLog "RestoreDefaults: Starting environment reset."
WriteLog "RestoreDefaults: Paths -> Apps=$appsPath Drivers=$driversPath FFUCapture=$ffuCapturePath"
# Remove JSON artifact files if present
$artifactFiles = @(
(Join-Path $rootPath 'config\FFUConfig.json'),
(Join-Path $appsPath 'AppList.json'),
(Join-Path $driversPath 'Drivers.json'),
(Join-Path $appsPath 'UserAppList.json')
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
foreach ($file in $artifactFiles) {
if ((-not [string]::IsNullOrWhiteSpace($file)) -and (Test-Path -LiteralPath $file)) {
try {
WriteLog "RestoreDefaults: Removing $file"
Remove-Item -LiteralPath $file -Force -ErrorAction Stop
}
catch {
WriteLog "RestoreDefaults: Failed removing $file : $($_.Exception.Message)"
}
}
}
# Force all cleanup flags true
Invoke-FFUPostBuildCleanup `
-RootPath $rootPath `
-AppsPath $appsPath `
-DriversPath $driversPath `
-FFUCapturePath $ffuCapturePath `
-DeployISOPath $deployISOPath `
-AppsISOPath $appsISOPath `
-KBPath (Join-Path $rootPath 'KB') `
-RemoveDeployISO:$true `
-RemoveAppsISO:$true `
-RemoveDrivers:$true `
-RemoveFFU:$true `
-RemoveApps:$true `
-RemoveUpdates:$true `
-RemoveDownloadedESD:$true
# Clear UI lists / state
if ($null -ne $State.Data.allDriverModels) { $State.Data.allDriverModels.Clear() }
if ($null -ne $State.Controls.lstDriverModels) { $State.Controls.lstDriverModels.Items.Refresh() }
if ($null -ne $State.Controls.lstApplications) {
try {
if ($State.Controls.lstApplications.ItemsSource) { $State.Controls.lstApplications.ItemsSource = $null }
$State.Controls.lstApplications.Items.Clear()
} catch {}
}
if ($null -ne $State.Controls.lstWingetResults) {
try {
if ($State.Controls.lstWingetResults.ItemsSource) { $State.Controls.lstWingetResults.ItemsSource = $null }
$State.Controls.lstWingetResults.Items.Clear()
} catch {}
}
if ($null -ne $State.Controls.lstAppsScriptVariables) {
try {
if ($State.Controls.lstAppsScriptVariables.ItemsSource) { $State.Controls.lstAppsScriptVariables.ItemsSource = $null }
$State.Controls.lstAppsScriptVariables.Items.Clear()
} catch {}
}
$State.Data.lastConfigFilePath = $null
Initialize-UIDefaults -State $State
WriteLog "RestoreDefaults: Completed."
[System.Windows.MessageBox]::Show("Environment restored to defaults.", "Restore Defaults", "OK", "Information")
}
catch {
WriteLog "RestoreDefaults: Failed with $($_.Exception.Message)"
[System.Windows.MessageBox]::Show("Restore Defaults failed:`n$($_.Exception.Message)", "Error", "OK", "Error")
}
}
function Invoke-AutoLoadPreviousEnvironment {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[psobject]$State
)
try {
$ffuDevRoot = $State.FFUDevelopmentPath
if ([string]::IsNullOrWhiteSpace($ffuDevRoot)) {
WriteLog "AutoLoad: FFUDevelopmentPath not set; skipping."
return
}
$configPath = Join-Path $ffuDevRoot "config\FFUConfig.json"
if (-not (Test-Path -LiteralPath $configPath)) {
WriteLog "AutoLoad: No existing FFUConfig.json found at $configPath."
return
}
WriteLog "AutoLoad: Found config file at $configPath. Parsing..."
$raw = Get-Content -Path $configPath -Raw -ErrorAction SilentlyContinue
if ([string]::IsNullOrWhiteSpace($raw)) {
WriteLog "AutoLoad: Config file empty; aborting."
return
}
$configContent = $null
try {
$configContent = $raw | ConvertFrom-Json -ErrorAction Stop
}
catch {
WriteLog "AutoLoad: JSON parse failed: $($_.Exception.Message)"
return
}
if ($null -eq $configContent) {
WriteLog "AutoLoad: Parsed object null; aborting."
return
}
WriteLog "AutoLoad: Applying core configuration."
Update-UIFromConfig -ConfigContent $configContent -State $State
$State.Data.lastConfigFilePath = $configPath
Import-ConfigSupplementalAssets -ConfigContent $configContent -State $State -ShowWarnings:$false
WriteLog "AutoLoad: Completed supplemental import with warnings disabled."
}
catch {
WriteLog "AutoLoad: Unexpected failure: $($_.Exception.ToString())"
}
}
function Import-ConfigSupplementalAssets {
param(
[Parameter(Mandatory = $true)]
[psobject]$ConfigContent,
[Parameter(Mandatory = $true)]
[psobject]$State,
[Parameter()]
[bool]$ShowWarnings = $false
)
WriteLog "SupplementalImport: Starting import of helper assets."
$loadedWinget = $false
$loadedBYO = $false
$loadedDrivers = $false
$missing = New-Object System.Collections.Generic.List[string]
# Winget AppList
$appListPath = $null
if ($ConfigContent.PSObject.Properties.Match('AppListPath').Count -gt 0) {
$appListPath = $ConfigContent.AppListPath
}
if (-not [string]::IsNullOrWhiteSpace($appListPath)) {
if (Test-Path -LiteralPath $appListPath) {
WriteLog "SupplementalImport: Loading Winget AppList from $appListPath"
try {
$importedAppsData = Get-Content -Path $appListPath -Raw | ConvertFrom-Json -ErrorAction Stop
if ($null -ne $importedAppsData -and $null -ne $importedAppsData.apps) {
$defaultArch = $State.Controls.cmbWindowsArch.SelectedItem
$appsBuffer = [System.Collections.Generic.List[object]]::new()
foreach ($appInfo in $importedAppsData.apps) {
$arch = if ($appInfo.source -eq 'msstore') { 'NA' } else {
if ($appInfo.PSObject.Properties['architecture']) { $appInfo.architecture } else { $defaultArch }
}
$appsBuffer.Add([PSCustomObject]@{
IsSelected = $true
Name = $appInfo.name
Id = $appInfo.id
Version = ""
Source = $appInfo.source
Architecture = $arch
AdditionalExitCodes = if ($appInfo.PSObject.Properties['AdditionalExitCodes']) { $appInfo.AdditionalExitCodes } else { "" }
IgnoreNonZeroExitCodes = if ($appInfo.PSObject.Properties['IgnoreNonZeroExitCodes']) { [bool]$appInfo.IgnoreNonZeroExitCodes } else { $false }
DownloadStatus = ""
})
}
$State.Controls.lstWingetResults.ItemsSource = $appsBuffer.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
$loadedWinget = $true
if ($null -ne $State.Controls.wingetSearchPanel) {
$State.Controls.wingetSearchPanel.Visibility = 'Visible'
}
if ($null -ne $State.Controls.chkSelectAllWingetResults -and (Get-Command -Name Update-SelectAllHeaderCheckBoxState -ErrorAction SilentlyContinue)) {
Update-SelectAllHeaderCheckBoxState -ListView $State.Controls.lstWingetResults -HeaderCheckBox $State.Controls.chkSelectAllWingetResults
}
WriteLog "SupplementalImport: Winget list loaded with $($appsBuffer.Count) entries."
}
else {
WriteLog "SupplementalImport: Winget AppList missing 'apps' array."
}
}
catch {
WriteLog "SupplementalImport: Failed loading Winget AppList ($appListPath): $($_.Exception.Message)"
}
}
else {
WriteLog "SupplementalImport: Winget AppList file missing: $appListPath"
$missing.Add("Winget AppList (AppListPath): $appListPath")
}
}
else {
WriteLog "SupplementalImport: AppListPath not defined in config."
}
# UserAppList (BYO)
$userAppListPath = $null
if ($ConfigContent.PSObject.Properties.Match('UserAppListPath').Count -gt 0) {
$userAppListPath = $ConfigContent.UserAppListPath
}
if (-not [string]::IsNullOrWhiteSpace($userAppListPath)) {
if (Test-Path -LiteralPath $userAppListPath) {
WriteLog "SupplementalImport: Loading UserAppList from $userAppListPath"
try {
$applications = Get-Content -Path $userAppListPath -Raw | ConvertFrom-Json -ErrorAction Stop
if ($applications) {
$listView = $State.Controls.lstApplications
$listView.Items.Clear()
$sortedApps = $applications | Sort-Object Priority
foreach ($app in $sortedApps) {
$ignoreNonZero = if ($app.PSObject.Properties['IgnoreNonZeroExitCodes']) { $app.IgnoreNonZeroExitCodes } else { $false }
$listView.Items.Add([PSCustomObject]@{
IsSelected = $false
Priority = $app.Priority
Name = $app.Name
CommandLine = $app.CommandLine
Arguments = if ($app.PSObject.Properties['Arguments']) { $app.Arguments } else { "" }
Source = $app.Source
AdditionalExitCodes = if ($app.PSObject.Properties['AdditionalExitCodes']) { $app.AdditionalExitCodes } else { "" }
IgnoreNonZeroExitCodes = $ignoreNonZero
IgnoreExitCodes = if ($ignoreNonZero) { "Yes" } else { "No" }
CopyStatus = ""
})
}
if (Get-Command -Name Update-ListViewPriorities -ErrorAction SilentlyContinue) {
Update-ListViewPriorities -ListView $listView
}
if (Get-Command -Name Update-CopyButtonState -ErrorAction SilentlyContinue) {
Update-CopyButtonState -State $State
}
if (Get-Command -Name Update-BYOAppsActionButtonsState -ErrorAction SilentlyContinue) {
Update-BYOAppsActionButtonsState -State $State
}
$loadedBYO = $true
WriteLog "SupplementalImport: UserAppList loaded with $($listView.Items.Count) entries."
}
else {
WriteLog "SupplementalImport: UserAppList JSON empty."
}
}
catch {
WriteLog "SupplementalImport: Failed loading UserAppList ($userAppListPath): $($_.Exception.Message)"
}
}
else {
WriteLog "SupplementalImport: UserAppList file missing: $userAppListPath"
$missing.Add("UserAppList (UserAppListPath): $userAppListPath")
}
}
else {
WriteLog "SupplementalImport: UserAppListPath not defined in config."
}
# Drivers JSON
$driversJsonPath = $null
if ($ConfigContent.PSObject.Properties.Match('DriversJsonPath').Count -gt 0) {
$driversJsonPath = $ConfigContent.DriversJsonPath
}
if (-not [string]::IsNullOrWhiteSpace($driversJsonPath)) {
if (Test-Path -LiteralPath $driversJsonPath) {
WriteLog "SupplementalImport: Loading Drivers JSON from $driversJsonPath"
try {
$rawDrivers = Get-Content -Path $driversJsonPath -Raw | ConvertFrom-Json -ErrorAction Stop
if ($rawDrivers -and $rawDrivers.PSObject.Properties.Count -gt 0) {
$State.Data.allDriverModels.Clear()
foreach ($makeProp in $rawDrivers.PSObject.Properties) {
$makeName = $makeProp.Name
$makeObject = $makeProp.Value
if ($null -eq $makeObject -or -not ($makeObject.PSObject.Properties['Models'])) { continue }
$models = $makeObject.Models
if ($models -and ($models -is [System.Collections.IEnumerable])) {
foreach ($modelEntry in $models) {
if ($null -eq $modelEntry -or -not ($modelEntry.PSObject.Properties['Name'])) { continue }
$modelName = $modelEntry.Name
if ([string]::IsNullOrWhiteSpace($modelName)) { continue }
$downloadStatus = if ($modelEntry.PSObject.Properties['DownloadStatus']) { $modelEntry.DownloadStatus } else { "" }
$linkValue = if ($modelEntry.PSObject.Properties['Link']) { $modelEntry.Link } else { $null }
$productName = if ($modelEntry.PSObject.Properties['ProductName']) { $modelEntry.ProductName } else { $null }
$machineType = if ($modelEntry.PSObject.Properties['MachineType']) { $modelEntry.MachineType } else { $null }
$systemId = if ($modelEntry.PSObject.Properties['SystemId']) { $modelEntry.SystemId } else { $null }
$idValue = if ($modelEntry.PSObject.Properties['Id']) { $modelEntry.Id } else { $null }
if ($null -eq $idValue -and -not [string]::IsNullOrWhiteSpace($systemId)) { $idValue = $systemId }
if ($null -eq $idValue -and -not [string]::IsNullOrWhiteSpace($machineType)) { $idValue = $machineType }
$displayModel = Get-ConfigDriverDisplayName -Make $makeName -StoredName $modelName -ProductName $productName -SystemId $systemId -MachineType $machineType
if ([string]::IsNullOrWhiteSpace($displayModel)) {
$displayModel = $modelName
}
$driverObj = [PSCustomObject]@{
IsSelected = $true
Make = $makeName
Model = $displayModel
DownloadStatus = $downloadStatus
Link = $linkValue
ProductName = $productName
MachineType = $machineType
SystemId = $systemId
Id = $idValue
}
$State.Data.allDriverModels.Add($driverObj)
}
}
}
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
if (Get-Command -Name Update-SelectAllHeaderCheckBoxState -ErrorAction SilentlyContinue) {
$headerChk = $State.Controls.chkSelectAllDriverModels
if ($null -ne $headerChk) {
Update-SelectAllHeaderCheckBoxState -ListView $State.Controls.lstDriverModels -HeaderCheckBox $headerChk
}
}
if ($State.Data.allDriverModels.Count -gt 0) {
if ($null -ne $State.Controls.spModelFilterSection) { $State.Controls.spModelFilterSection.Visibility = 'Visible' }
if ($null -ne $State.Controls.lstDriverModels) { $State.Controls.lstDriverModels.Visibility = 'Visible' }
if ($null -ne $State.Controls.spDriverActionButtons) { $State.Controls.spDriverActionButtons.Visibility = 'Visible' }
try {
if ($State.Controls.cmbMake.SelectedIndex -lt 0 -and $State.Data.allDriverModels.Count -gt 0) {
$firstMake = ($State.Data.allDriverModels | Select-Object -First 1).Make
if (-not [string]::IsNullOrWhiteSpace($firstMake)) {
$makeItem = $State.Controls.cmbMake.Items | Where-Object { $_ -eq $firstMake } | Select-Object -First 1
if ($makeItem) { $State.Controls.cmbMake.SelectedItem = $makeItem }
}
}
}
catch {
WriteLog "SupplementalImport: Non-fatal error selecting first Make: $($_.Exception.Message)"
}
}
$loadedDrivers = $true
WriteLog "SupplementalImport: Loaded $($State.Data.allDriverModels.Count) driver models."
}
else {
WriteLog "SupplementalImport: Drivers JSON empty or structure unexpected."
}
}
catch {
WriteLog "SupplementalImport: Failed loading Drivers JSON ($driversJsonPath): $($_.Exception.Message)"
}
}
else {
WriteLog "SupplementalImport: Drivers JSON file missing: $driversJsonPath"
$missing.Add("Drivers (DriversJsonPath): $driversJsonPath")
}
}
else {
WriteLog "SupplementalImport: DriversJsonPath not defined in config."
}
if ($loadedWinget -or $loadedBYO) {
$State.Controls.chkInstallApps.IsChecked = $true
}
if ($loadedWinget) {
$State.Controls.chkInstallWingetApps.IsChecked = $true
}
if ($loadedBYO) {
$State.Controls.chkBringYourOwnApps.IsChecked = $true
}
if ($loadedDrivers) {
$State.Controls.chkDownloadDrivers.IsChecked = $true
}
if (Get-Command -Name Update-ApplicationPanelVisibility -ErrorAction SilentlyContinue) {
Update-ApplicationPanelVisibility -State $State -TriggeringControlName 'SupplementalImport'
}
if (Get-Command -Name Update-DriverDownloadPanelVisibility -ErrorAction SilentlyContinue) {
Update-DriverDownloadPanelVisibility -State $State
}
if (Get-Command -Name Update-DriverCheckboxStates -ErrorAction SilentlyContinue) {
Update-DriverCheckboxStates -State $State
}
if (Get-Command -Name Update-OfficePanelVisibility -ErrorAction SilentlyContinue) {
Update-OfficePanelVisibility -State $State
}
if (Get-Command -Name Update-CopyButtonState -ErrorAction SilentlyContinue) {
Update-CopyButtonState -State $State
}
# Updated message to clarify successful load and that missing helper files are optional if not yet created.
if ($ShowWarnings -and $missing.Count -gt 0) {
$msg = "Configuration file loaded successfully.`n`n" +
"Optional helper file(s) referenced in the configuration were not found:`n" +
($missing | ForEach-Object { "- $_" } | Out-String) +
"`nThese files are optional. They won't exist until you create Winget (AppList.json), User (UserAppList.json), or Driver (Drivers.json) manifests. You can create them later or ignore this message."
[System.Windows.MessageBox]::Show($msg.TrimEnd(), "Configuration Loaded - Optional Files Missing", "OK", "Information") | Out-Null
}
WriteLog ("SupplementalImport: Complete. Winget={0} BYO={1} Drivers={2} Missing={3}" -f $loadedWinget, $loadedBYO, $loadedDrivers, $missing.Count)
}
Export-ModuleMember -Function * Export-ModuleMember -Function *
@@ -12,147 +12,98 @@ function Get-DellDriversModelList {
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[int]$WindowsRelease, [int]$WindowsRelease,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$DriversFolder, # Base drivers folder (e.g., C:\FFUDevelopment\Drivers) [string]$DriversFolder,
[Parameter(Mandatory = $true)] [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" $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" $dellCabFile = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).cab"
$dellCatalogXML = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).xml" $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) if (-not (Test-Path -Path $dellDriversFolder)) {
$reader = $null New-Item -Path $dellDriversFolder -ItemType Directory -Force | Out-Null
}
$download = $true
if (Test-Path -Path $dellCatalogXML) {
if (((Get-Date) - (Get-Item $dellCatalogXML).CreationTime).TotalDays -lt 7) {
$download = $false
}
}
if ($download) {
if (Test-Path $dellCabFile) { Remove-Item $dellCabFile -Force -ErrorAction SilentlyContinue }
if (Test-Path $dellCatalogXML) { Remove-Item $dellCatalogXML -Force -ErrorAction SilentlyContinue }
Start-BitsTransferWithRetry -Source $catalogUrl -Destination $dellCabFile
Invoke-Process -FilePath Expand.exe -ArgumentList """$dellCabFile"" ""$dellCatalogXML""" | Out-Null
Remove-Item $dellCabFile -Force -ErrorAction SilentlyContinue
}
if (-not (Test-Path $dellCatalogXML)) { throw "Dell server catalog XML missing: $dellCatalogXML" }
$settings = New-Object System.Xml.XmlReaderSettings
$settings.IgnoreWhitespace = $true
$settings.IgnoreComments = $true
$reader = [System.Xml.XmlReader]::Create($dellCatalogXML, $settings)
$inDriver = $false
$inModel = $false
$depthModel = -1
$modelsHash = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
try { try {
# Check if the Dell catalog XML exists and is recent
$downloadCatalog = $true
if (Test-Path -Path $dellCatalogXML -PathType Leaf) {
WriteLog "Dell Catalog XML found: $dellCatalogXML"
$dellCatalogCreationTime = (Get-Item $dellCatalogXML).CreationTime
WriteLog "Dell Catalog XML Creation time: $dellCatalogCreationTime"
# Check if the XML file is less than 7 days old
if (((Get-Date) - $dellCatalogCreationTime).TotalDays -lt 7) {
WriteLog "Using existing Dell Catalog XML (less than 7 days old): $dellCatalogXML"
$downloadCatalog = $false
}
else {
WriteLog "Existing Dell Catalog XML is older than 7 days: $dellCatalogXML"
}
}
else {
WriteLog "Dell Catalog XML not found: $dellCatalogXML"
}
if ($downloadCatalog) {
WriteLog "Attempting to download and extract Dell Catalog for Get-DellDriversModelList..."
# Ensure Dell drivers folder exists
if (-not (Test-Path -Path $dellDriversFolder -PathType Container)) {
WriteLog "Creating Dell drivers folder: $dellDriversFolder"
New-Item -Path $dellDriversFolder -ItemType Directory -Force | Out-Null
}
# Check URL accessibility
try {
$request = [System.Net.WebRequest]::Create($catalogUrl)
$request.Method = 'HEAD'; $response = $request.GetResponse(); $response.Close()
}
catch { throw "Dell Catalog URL '$catalogUrl' not accessible: $($_.Exception.Message)" }
# Remove existing files before download if they exist
if (Test-Path -Path $dellCabFile) { Remove-Item -Path $dellCabFile -Force -ErrorAction SilentlyContinue }
if (Test-Path -Path $dellCatalogXML) { Remove-Item -Path $dellCatalogXML -Force -ErrorAction SilentlyContinue }
WriteLog "Downloading Dell Catalog cab file: $catalogUrl to $dellCabFile"
Start-BitsTransferWithRetry -Source $catalogUrl -Destination $dellCabFile
WriteLog "Dell Catalog cab file downloaded to $dellCabFile"
WriteLog "Extracting Dell Catalog cab file '$dellCabFile' to '$dellCatalogXML'"
Invoke-Process -FilePath "Expand.exe" -ArgumentList """$dellCabFile"" ""$dellCatalogXML""" | Out-Null
WriteLog "Dell Catalog cab file extracted to $dellCatalogXML"
# Delete the CAB file after extraction
WriteLog "Deleting Dell Catalog CAB file: $dellCabFile"
Remove-Item -Path $dellCabFile -Force -ErrorAction SilentlyContinue
}
# Ensure the XML file exists before trying to read it
if (-not (Test-Path -Path $dellCatalogXML -PathType Leaf)) {
throw "Dell Catalog XML file '$dellCatalogXML' not found after download/check attempt."
}
# Use XmlReader for streaming from the XML file
$settings = New-Object System.Xml.XmlReaderSettings
$settings.IgnoreWhitespace = $true
$settings.IgnoreComments = $true
# $settings.DtdProcessing = [System.Xml.DtdProcessing]::Ignore # Optional
$reader = [System.Xml.XmlReader]::Create($dellCatalogXML, $settings)
WriteLog "Starting XML stream parsing for Dell models from '$dellCatalogXML'..."
$isDriverComponent = $false
$isModelElement = $false
$modelDepth = -1 # Track depth to handle nested elements if needed
# Read through the XML stream node by node
while ($reader.Read()) { while ($reader.Read()) {
switch ($reader.NodeType) { switch ($reader.NodeType) {
([System.Xml.XmlNodeType]::Element) { ([System.Xml.XmlNodeType]::Element) {
switch ($reader.Name) { switch ($reader.Name) {
'SoftwareComponent' { $isDriverComponent = $false } # Reset flag 'SoftwareComponent' { $inDriver = $false }
'ComponentType' { if ($reader.GetAttribute('value') -eq 'DRVR') { $isDriverComponent = $true } } 'ComponentType' { if ($reader.GetAttribute('value') -eq 'DRVR') { $inDriver = $true } }
'Model' { if ($isDriverComponent) { $isModelElement = $true; $modelDepth = $reader.Depth } } 'Model' { if ($inDriver) { $inModel = $true; $depthModel = $reader.Depth } }
} }
} }
([System.Xml.XmlNodeType]::CDATA) { ([System.Xml.XmlNodeType]::CDATA) {
if ($isModelElement -and $isDriverComponent) { if ($inDriver -and $inModel) {
$modelName = $reader.Value.Trim() $val = $reader.Value.Trim()
if (-not [string]::IsNullOrWhiteSpace($modelName)) { $uniqueModelNames.Add($modelName) | Out-Null } if ($val) { $modelsHash.Add($val) | Out-Null }
$isModelElement = $false # Reset after reading CDATA $inModel = $false
} }
} }
([System.Xml.XmlNodeType]::EndElement) { ([System.Xml.XmlNodeType]::EndElement) {
switch ($reader.Name) { if ($reader.Name -eq 'SoftwareComponent') { $inDriver = $false; $inModel = $false }
'SoftwareComponent' { $isDriverComponent = $false; $isModelElement = $false; $modelDepth = -1 } elseif ($reader.Name -eq 'Model' -and $reader.Depth -eq $depthModel) { $inModel = $false; $depthModel = -1 }
'Model' { if ($reader.Depth -eq $modelDepth) { $isModelElement = $false; $modelDepth = -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 { finally {
# Ensure the reader is closed and disposed $reader.Dispose()
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 $out = [System.Collections.Generic.List[pscustomobject]]::new()
$models = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($nm in ($modelsHash | Sort-Object)) {
foreach ($modelName in ($uniqueModelNames | Sort-Object)) { $out.Add([pscustomobject]@{ Make = $Make; Model = $nm })
$models.Add([PSCustomObject]@{
Make = $Make
Model = $modelName
# Link is not applicable here like for Microsoft
})
} }
return $out
return $models
} }
# Function to download and extract drivers for a specific Dell model (Modified for ForEach-Object -Parallel) # Function to download and extract drivers for a specific Dell model (Modified for ForEach-Object -Parallel)
@@ -160,548 +111,269 @@ function Save-DellDriversTask {
[CmdletBinding()] [CmdletBinding()]
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[PSCustomObject]$DriverItemData, # Contains Model property [pscustomobject]$DriverItemData,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$DriversFolder, # Base drivers folder (e.g., C:\FFUDevelopment\Drivers) [string]$DriversFolder,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$WindowsArch, [string]$WindowsArch,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[int]$WindowsRelease, [int]$WindowsRelease,
[Parameter()] # Made optional
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null
[Parameter()] [Parameter()]
[bool]$CompressToWim = $false # New parameter for compression [System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null,
[Parameter()]
[bool]$CompressToWim = $false,
[Parameter()]
[bool]$PreserveSourceOnCompress = $false
) )
$modelName = $DriverItemData.Model $modelDisplay = $DriverItemData.Model
$make = "Dell" # Hardcoded for this task $make = 'Dell'
$status = "Starting..." # Initial local status if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Checking...' }
$success = $false
# Initial status update $sanitizedModelName = ConvertTo-SafeName -Name $modelDisplay
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Checking..." } $makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $make
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $sanitizedModelName
$driverRelativePath = Join-Path -Path $make -ChildPath $sanitizedModelName
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $Make # Helper: safe folder removal
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $modelName function Remove-SafeFolder {
$driverRelativePath = Join-Path -Path $make -ChildPath $modelName # Relative path for the driver folder param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return }
# Never allow deleting the entire Dell root folder accidentally
$dellRoot = (Resolve-Path $makeDriversPath).ProviderPath
$target = (Resolve-Path $Path -ErrorAction SilentlyContinue)?.ProviderPath
if ($null -eq $target) { return }
if ($target -eq $dellRoot) { return }
if (-not ($target.StartsWith($dellRoot, [System.StringComparison]::OrdinalIgnoreCase))) { return }
Remove-Item -Path $target -Recurse -Force -ErrorAction SilentlyContinue
}
try { try {
# Check for existing drivers # Existing drivers shortcircuit
$existingDriver = Test-ExistingDriver -Make $make -Model $modelName -DriversFolder $DriversFolder -Identifier $modelName -ProgressQueue $ProgressQueue $existing = Test-ExistingDriver -Make $make -Model $sanitizedModelName -DriversFolder $DriversFolder -Identifier $modelDisplay -ProgressQueue $ProgressQueue
if ($null -ne $existingDriver) { if ($existing) {
# Add the 'Model' property to the return object for consistency if it's not there if (-not $existing.PSObject.Properties['Model']) {
if (-not $existingDriver.PSObject.Properties['Model']) { $existing | Add-Member -MemberType NoteProperty -Name 'Model' -Value $modelDisplay
$existingDriver | Add-Member -MemberType NoteProperty -Name 'Model' -Value $modelName
} }
if ($CompressToWim -and $existing.Status -eq 'Already downloaded') {
# Special handling for existing folders that need compression $wimPath = Join-Path $makeDriversPath "$sanitizedModelName.wim"
if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') { $wimRelativePath = Join-Path $make "$sanitizedModelName.wim"
$wimFilePath = Join-Path -Path $makeDriversPath -ChildPath "$($modelName).wim" $srcPath = Join-Path $makeDriversPath $sanitizedModelName
$sourceFolderPath = Join-Path -Path $makeDriversPath -ChildPath $modelName if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Compressing existing...' }
WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Compressing existing..." }
try { try {
Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $modelName -WimDescription "Drivers for $modelName" -ErrorAction Stop $null = Compress-DriverFolderToWim -SourceFolderPath $srcPath -DestinationWimPath $wimPath -WimName $modelDisplay -WimDescription "Drivers for $modelDisplay" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
$existingDriver.Status = "Already downloaded & Compressed" $existing.Status = 'Compression successful'
$existingDriver.DriverPath = Join-Path -Path $make -ChildPath "$($modelName).wim" $existing.DriverPath = $wimRelativePath
$existingDriver.Success = $true $existing.Success = $true
WriteLog "Successfully compressed existing drivers for $modelName to $wimFilePath."
} }
catch { catch {
WriteLog "Error compressing existing drivers for $($modelName): $($_.Exception.Message)" WriteLog "Compression failed for $($modelDisplay): $($_.Exception.Message)"
$existingDriver.Status = "Already downloaded (Compression failed)" $existing.Status = 'Already downloaded (Compression failed)'
$existingDriver.Success = $false $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
} }
# Define paths for Dell catalog. The catalog is assumed to be prepared by the calling function. if (-not (Test-Path $makeDriversPath)) { New-Item -Path $makeDriversPath -ItemType Directory -Force | Out-Null }
$dellDriversFolder = Join-Path -Path $DriversFolder -ChildPath "Dell" if (-not (Test-Path $modelPath)) { New-Item -Path $modelPath -ItemType Directory -Force | Out-Null }
$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* $packages = @()
$status = "Finding drivers..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Check if the provided XML path exists if ($WindowsRelease -le 11) {
if (-not (Test-Path -Path $dellCatalogXML -PathType Leaf)) { $cabUrl = $DriverItemData.CabUrl
throw "Dell Catalog XML file not found at specified path: $dellCatalogXML" if ([string]::IsNullOrWhiteSpace($cabUrl)) {
} WriteLog "CabUrl missing for '$modelDisplay' resolving via CatalogIndexPC."
$resolved = Resolve-DellCabUrlFromModel -DriversFolder $DriversFolder -ModelDisplay $modelDisplay
WriteLog "Parsing existing Dell Catalog XML for model '$modelName' from: $dellCatalogXML" if ($null -eq $resolved -or [string]::IsNullOrWhiteSpace($resolved.CabUrl)) {
throw "Unable to resolve CabUrl for $modelDisplay from CatalogIndexPC."
# Initialize variables }
$baseLocation = $null $cabUrl = $resolved.CabUrl
$latestDrivers = @{} # Hashtable to store latest drivers for this model # Optionally persist back into the incoming object if property exists
$modelSpecificDriversFound = $false if ($DriverItemData.PSObject.Properties['CabUrl']) {
$DriverItemData.CabUrl = $cabUrl
# 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) { # Model-based workflow (always used for client pathway now)
throw "Invalid Dell Catalog XML format: Missing 'baseLocation' attribute in Manifest element." $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')
# Reset reader for second pass if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Downloading catalog...' }
$reader.Dispose() if (Test-Path $modelCabPath) { Remove-SafeFolder $modelCabPath }
$reader = [System.Xml.XmlReader]::Create($dellCatalogXML, $settings) if (Test-Path $modelXmlPath) { Remove-SafeFolder $modelXmlPath }
# Process SoftwareComponents WriteLog "Downloading Dell model catalog from $cabUrl to $modelCabPath"
while ($reader.Read()) { Start-BitsTransferWithRetry -Source $cabUrl -Destination $modelCabPath
if ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq "SoftwareComponent") { Invoke-Process -FilePath Expand.exe -ArgumentList """$modelCabPath"" ""$modelXmlPath""" | Out-Null
# Read the entire SoftwareComponent subtree Remove-Item $modelCabPath -Force -ErrorAction SilentlyContinue
$componentXml = $reader.ReadSubtree() if (-not (Test-Path $modelXmlPath)) { throw "Model XML not found after extraction: $modelXmlPath" }
$component = New-Object System.Xml.XmlDocument
$component.Load($componentXml)
$componentXml.Dispose()
# Check if it's a driver component # Track extracted model XML so cancel cleanup can remove it even if file timestamps are preserved from source metadata.
$componentTypeNode = $component.SelectSingleNode("//ComponentType[@value='DRVR']")
if ($null -eq $componentTypeNode) {
continue
}
# Check if component supports the model
$modelNodes = $component.SelectNodes("//SupportedSystems/Brand/Model")
$modelMatch = $false
foreach ($modelNode in $modelNodes) {
$displayNode = $modelNode.SelectSingleNode("Display")
if ($null -ne $displayNode -and $displayNode.InnerText.Trim() -eq $modelName) {
$modelMatch = $true
break
}
}
if ($modelMatch) {
# Check OS compatibility
$validOS = $null
$osNodes = $component.SelectNodes("//SupportedOperatingSystems/OperatingSystem")
if ($null -ne $osNodes) {
foreach ($osNode in $osNodes) {
$osArch = $osNode.GetAttribute("osArch")
if ($WindowsRelease -le 11) {
# Client OS check
if ($osArch -eq $WindowsArch) {
$validOS = $osNode
break
}
}
else {
# Server OS check
$osCode = $osNode.GetAttribute("osCode")
$osCodePattern = switch ($WindowsRelease) {
2016 { "W14" }
2019 { "W19" }
2022 { "W22" }
2025 { "W25" }
default { "W22" }
}
if ($osArch -eq $WindowsArch -and $osCode -match $osCodePattern) {
$validOS = $osNode
break
}
}
}
}
if ($validOS) {
$modelSpecificDriversFound = $true
# Extract driver information
$driverPath = $component.SoftwareComponent.GetAttribute("path")
$downloadUrl = $baseLocation + $driverPath
$driverFileName = [System.IO.Path]::GetFileName($driverPath)
# Get name
$nameNode = $component.SelectSingleNode("//Name/Display")
$name = if ($null -ne $nameNode) { $nameNode.InnerText } else { "UnknownDriver" }
$name = $name -replace '[\\\/\:\*\?\"\<\>\| ]', '_' -replace '[\,]', '-'
# Get category
$categoryNode = $component.SelectSingleNode("//Category/Display")
$category = if ($null -ne $categoryNode) { $categoryNode.InnerText } else { "Uncategorized" }
$category = $category -replace '[\\\/\:\*\?\"\<\>\| ]', '_'
# Get version
$version = [version]"0.0"
$vendorVersion = $component.SoftwareComponent.GetAttribute("vendorVersion")
if ($null -ne $vendorVersion) {
try { $version = [version]$vendorVersion } catch { WriteLog "Warning: Could not parse version '$vendorVersion' for driver '$name'. Using 0.0." }
}
$namePrefix = ($name -split '-')[0]
# Store the latest version for each category/prefix combination
if (-not $latestDrivers.ContainsKey($category)) { $latestDrivers[$category] = @{} }
if (-not $latestDrivers[$category].ContainsKey($namePrefix) -or $latestDrivers[$category][$namePrefix].Version -lt $version) {
$latestDrivers[$category][$namePrefix] = [PSCustomObject]@{
Name = $name
DownloadUrl = $downloadUrl
DriverFileName = $driverFileName
Version = $version
Category = $category
}
}
}
}
}
}
}
finally {
if ($null -ne $reader) {
$reader.Dispose()
}
}
WriteLog "Searching $($softwareComponents.Count) DRVR components in '$dellCatalogXML' for model '$modelName'..."
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
$driverPath = $component.path
$downloadUrl = $baseLocation + $driverPath
$driverFileName = [System.IO.Path]::GetFileName($driverPath)
# Check if Name, Display, and CDATA exist
$name = "UnknownDriver" # Default name
if ($null -ne $component.Name -and $null -ne $component.Name.Display -and $null -ne $component.Name.Display.'#cdata-section') {
$name = $component.Name.Display.'#cdata-section'
$name = $name -replace '[\\\/\:\*\?\"\<\>\| ]', '_' -replace '[\,]', '-'
}
# Check if Category, Display, and CDATA exist
$category = "Uncategorized" # Default category
if ($null -ne $component.Category -and $null -ne $component.Category.Display -and $null -ne $component.Category.Display.'#cdata-section') {
$category = $component.Category.Display.'#cdata-section'
$category = $category -replace '[\\\/\:\*\?\"\<\>\| ]', '_'
}
$version = [version]"0.0" # Default version
if ($null -ne $component.vendorVersion) {
try { $version = [version]$component.vendorVersion } catch { WriteLog "Warning: Could not parse version '$($component.vendorVersion)' for driver '$name'. Using 0.0." }
}
$namePrefix = ($name -split '-')[0] # Group by prefix within category
# Store the latest version for each category/prefix combination
if (-not $latestDrivers.ContainsKey($category)) { $latestDrivers[$category] = @{} }
if (-not $latestDrivers[$category].ContainsKey($namePrefix) -or $latestDrivers[$category][$namePrefix].Version -lt $version) {
$latestDrivers[$category][$namePrefix] = [PSCustomObject]@{
Name = $name
DownloadUrl = $downloadUrl
DriverFileName = $driverFileName
Version = $version
Category = $category
}
}
}
} # End if ($modelMatch)
} # End foreach ($component in $softwareComponents)
if (-not $modelSpecificDriversFound) {
$status = "No drivers found for OS"
WriteLog "No drivers found for model '$modelName' matching Windows Release '$WindowsRelease' and Arch '$WindowsArch' in '$dellCatalogXML'."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Consider this success as the process completed, just no drivers to download
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $true }
}
# 4. Download and Extract Found Drivers (Logic remains largely the same)
$totalDriversToProcess = ($latestDrivers.Values | ForEach-Object { $_.Values.Count } | Measure-Object -Sum).Sum
$driversProcessed = 0
WriteLog "Found $totalDriversToProcess latest driver packages to download for $modelName."
# Ensure base directories exist before loop
if (-not (Test-Path -Path $makeDriversPath)) { New-Item -Path $makeDriversPath -ItemType Directory -Force | Out-Null }
if (-not (Test-Path -Path $modelPath)) { New-Item -Path $modelPath -ItemType Directory -Force | Out-Null }
foreach ($category in $latestDrivers.Keys) {
foreach ($driver in $latestDrivers[$category].Values) {
$driversProcessed++
$status = "Downloading $($driversProcessed)/$($totalDriversToProcess): $($driver.Name)"
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
$downloadFolder = Join-Path -Path $modelPath -ChildPath $driver.Category
$driverFilePath = Join-Path -Path $downloadFolder -ChildPath $driver.DriverFileName
$extractFolder = Join-Path -Path $downloadFolder -ChildPath $driver.DriverFileName.TrimEnd($driver.DriverFileName[-4..-1])
# Check if already extracted (more robust check)
if (Test-Path -Path $extractFolder -PathType Container) {
$extractSize = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($extractSize -gt 1KB) {
WriteLog "Driver already extracted: $($driver.Name) in $extractFolder. Skipping."
continue # Skip to next driver
}
}
# Check if download file exists but extraction folder doesn't or is empty
if (Test-Path -Path $driverFilePath -PathType Leaf) {
WriteLog "Download file $($driver.DriverFileName) exists, but extraction folder '$extractFolder' is missing or empty. Will attempt extraction."
# Proceed to extraction logic below
}
else {
# Download the driver
WriteLog "Downloading driver: $($driver.Name) ($($driver.DriverFileName))"
if (-not (Test-Path -Path $downloadFolder)) {
WriteLog "Creating download folder: $downloadFolder"
New-Item -Path $downloadFolder -ItemType Directory -Force | Out-Null
}
WriteLog "Downloading from: $($driver.DownloadUrl) to $driverFilePath"
try {
Start-BitsTransferWithRetry -Source $driver.DownloadUrl -Destination $driverFilePath
WriteLog "Driver downloaded: $($driver.DriverFileName)"
}
catch {
WriteLog "Failed to download driver: $($driver.DownloadUrl). Error: $($_.Exception.Message). Skipping."
# Update status for this specific driver failure? Maybe too granular.
continue # Skip to next driver
}
}
# Extract the driver
$status = "Extracting $($driversProcessed)/$($totalDriversToProcess): $($driver.Name)"
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Ensure extraction folder exists before attempting extraction
if (-not (Test-Path -Path $extractFolder)) {
WriteLog "Creating extraction folder: $extractFolder"
New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null
}
# Dell uses /e to extact the entire DUP while /drivers to extract only the drivers
# In many cases /drivers will extract drivers for mutliple OS versions
# Which can cause many duplicate files and bloat your driver folder
# /e seems to be better and only extracts what is necessary and has less issues
# We will default to using /e, but will fall back to /drivers if content cannot be found
$arguments = "/s /e=`"$extractFolder`" /l=`"$extractFolder\log.log`""
$altArguments = "/s /drivers=`"$extractFolder`" /l=`"$extractFolder\log.log`""
$extractionSuccess = $false
try {
# Handle special cases (Chipset/Network) - Check if OS is Server
$osInfo = Get-CimInstance -ClassName Win32_OperatingSystem # Get OS info within the task scope
$isServer = $osInfo.Caption -match 'server'
# Chipset drivers may require killing child processes in some cases
if ($driver.Category -eq "Chipset") {
WriteLog "Extracting Chipset driver: $driverFilePath $arguments"
$process = Invoke-Process -FilePath $driverFilePath -ArgumentList $arguments -Wait $false
Start-Sleep -Seconds 5 # Allow time for extraction
WriteLog "Extraction exited with exit code: $($process.ExitCode)"
# Attempt to gracefully close child process if needed (logic from original script)
$childProcesses = Get-CimInstance Win32_Process -Filter "ParentProcessId = $($process.Id)"
if ($childProcesses) {
$latestProcess = $childProcesses | Sort-Object CreationDate -Descending | Select-Object -First 1
WriteLog "Stopping child process for Chipset driver: $($latestProcess.Name) (PID: $($latestProcess.ProcessId))"
Stop-Process -Id $latestProcess.ProcessId -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
}
}
# Network drivers on client OS may require killing child processes
elseif ($driver.Category -eq "Network" -and -not $isServer) {
WriteLog "Extracting Network driver: $driverFilePath $arguments"
$process = Invoke-Process -FilePath $driverFilePath -ArgumentList $arguments -Wait $false
Start-Sleep -Seconds 5
WriteLog "Extraction exited with exit code: $($process.ExitCode)"
if (-not $process.HasExited) {
$childProcesses = Get-CimInstance Win32_Process -Filter "ParentProcessId = $($process.Id)"
if ($childProcesses) {
$latestProcess = $childProcesses | Sort-Object CreationDate -Descending | Select-Object -First 1
WriteLog "Stopping child process for Network driver: $($latestProcess.Name) (PID: $($latestProcess.ProcessId))"
Stop-Process -Id $latestProcess.ProcessId -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
}
}
}
else {
WriteLog "Extracting driver: $driverFilePath $arguments"
$process = Invoke-Process -FilePath $driverFilePath -ArgumentList $arguments
WriteLog "Extraction exited with exit code: $($process.ExitCode)"
}
# Verify extraction (check if folder has content)
if (Test-Path -Path $extractFolder -PathType Container) {
$extractSize = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($extractSize -gt 1KB) {
$extractionSuccess = $true
WriteLog "Extraction successful (Method 1) for $driverFilePath $arguments"
}
}
# If primary extraction failed or folder is empty, try alternative
if (-not $extractionSuccess) {
# $arguments = "/s /e=`"$extractFolder`""
# $altArguments = "/s /drivers=`"$extractFolder`""
WriteLog "Extraction with $arguments failed or resulted in empty folder for $driverFilePath. Retrying with $altArguments"
# Clean up potentially empty folder before retrying
Remove-Item -Path $extractFolder -Recurse -Force -ErrorAction SilentlyContinue
New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null # Recreate empty folder
$process = Invoke-Process -FilePath $driverFilePath -ArgumentList $altArguments
WriteLog "Extraction exited with exit code: $($process.ExitCode)"
# Verify extraction again
if (Test-Path -Path $extractFolder -PathType Container) {
$extractSize = (Get-ChildItem -Path $extractFolder -Recurse -Exclude *.log | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($extractSize -gt 1KB) {
$extractionSuccess = $true
WriteLog "Extraction successful (Method 2) for $driverFilePath $altArguments"
}
}
}
}
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
}
}
# Cleanup downloaded file only if extraction was successful
if ($extractionSuccess) {
WriteLog "Deleting driver file: $driverFilePath"
Remove-Item -Path $driverFilePath -Force -ErrorAction SilentlyContinue
WriteLog "Driver file deleted: $driverFilePath"
}
else {
WriteLog "Extraction failed for $($driver.DriverFileName). Downloaded file kept at $driverFilePath for inspection."
# Update status to indicate partial failure?
}
} # 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'..."
try { try {
$compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $modelName -WimDescription $modelName -ErrorAction Stop Register-CurrentRunDownloadTarget -Destination $modelXmlPath
if ($compressResult) { }
WriteLog "Compression successful for '$modelName'." catch {
$status = "Completed & Compressed" WriteLog "Failed to register Dell model XML for current-run cleanup ($modelXmlPath): $($_.Exception.Message)"
}
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Selecting latest drivers...' }
$packages = Get-DellLatestDriverPackages -ModelXmlPath $modelXmlPath -WindowsArch $WindowsArch -WindowsRelease $WindowsRelease
}
else {
# Server legacy logic unchanged (kept as before)
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Preparing server catalog...' }
$catalogCab = Join-Path $makeDriversPath 'Catalog.cab'
$catalogXml = Join-Path $makeDriversPath 'Catalog.xml'
$catalogUrl = 'https://downloads.dell.com/catalog/Catalog.cab'
$need = $true
if (Test-Path $catalogXml) {
if (((Get-Date) - (Get-Item $catalogXml).CreationTime).TotalDays -lt 7) { $need = $false }
}
if ($need) {
if (Test-Path $catalogCab) { Remove-SafeFolder $catalogCab }
if (Test-Path $catalogXml) { Remove-SafeFolder $catalogXml }
WriteLog "Downloading Dell server catalog from $catalogUrl to $catalogCab"
Start-BitsTransferWithRetry -Source $catalogUrl -Destination $catalogCab
Invoke-Process -FilePath Expand.exe -ArgumentList """$catalogCab"" ""$catalogXml""" | Out-Null
Remove-Item $catalogCab -Force -ErrorAction SilentlyContinue
}
if (-not (Test-Path $catalogXml)) { throw "Server catalog XML missing: $catalogXml" }
[xml]$xmlContent = Get-Content -Path $catalogXml -Raw
$baseLocation = "https://$($xmlContent.manifest.baseLocation)/"
$softwareComponents = $xmlContent.Manifest.SoftwareComponent | Where-Object { $_.ComponentType.value -eq 'DRVR' }
$latestDrivers = @{}
foreach ($component in $softwareComponents) {
$models = $component.SupportedSystems.Brand.Model
foreach ($m in $models) {
if ($m.Display.'#cdata-section' -eq $modelDisplay) {
$validOS = $component.SupportedOperatingSystems.OperatingSystem | Where-Object { $_.osArch -eq $WindowsArch }
if (-not $validOS) { continue }
$driverPath = $component.path
$downloadUrl = $baseLocation + $driverPath
$fileName = [IO.Path]::GetFileName($driverPath)
$name = $component.Name.Display.'#cdata-section' -replace '[\\\/\:\*\?\"\<\>\| ]', '_' -replace '[\,]', '-'
$category = $component.Category.Display.'#cdata-section' -replace '[\\\/\:\*\?\"\<\>\| ]', '_'
$version = [version]$component.vendorVersion
$namePrefix = ($name -split '-')[0]
if (-not $latestDrivers[$category]) { $latestDrivers[$category] = @{} }
if (-not $latestDrivers[$category][$namePrefix] -or $latestDrivers[$category][$namePrefix].Version -lt $version) {
$latestDrivers[$category][$namePrefix] = [pscustomobject]@{
Name = $name
DownloadUrl = $downloadUrl
DriverFileName = $fileName
Version = $version
Category = $category
}
}
}
} }
else { }
WriteLog "Compression failed for '$modelName'. Check verbose/error output from Compress-DriverFolderToWim." foreach ($cat in $latestDrivers.Keys) { foreach ($drv in $latestDrivers[$cat].Values) { $packages += $drv } }
$status = "Completed (Compression Failed)" }
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 }
}
$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 {
$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 }
if (-not (Test-Path $extractFolder)) { New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null }
$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
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 { catch {
WriteLog "Error during compression for '$modelName': $($_.Exception.Message)" WriteLog "Extraction error: $($_.Exception.Message)"
$status = "Completed (Compression Error)" }
if ($ok) {
Remove-Item $driverFilePath -Force -ErrorAction SilentlyContinue
}
else {
$failureMessage = "Failed to extract driver '$driverName'."
WriteLog $failureMessage
throw (New-Object System.Exception($failureMessage))
}
}
if ($CompressToWim) {
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Compressing...' }
$wimPath = Join-Path $makeDriversPath "$sanitizedModelName.wim"
try {
$null = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $wimPath -WimName $modelDisplay -WimDescription $modelDisplay -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
$driverRelativePath = Join-Path $make "$sanitizedModelName.wim"
$statusFinal = 'Completed & Compressed'
}
catch {
WriteLog "Compression failed for $($modelDisplay): $($_.Exception.Message)"
$statusFinal = 'Completed (Compression Failed)'
} }
} }
else { 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 { catch {
$status = "Error: $($_.Exception.Message.Split('.')[0])" # Shorten error message $errorStatus = "Error: $($_.Exception.Message)"
WriteLog "Error saving Dell drivers for $($modelName): $($_.Exception.ToString())" # Log full exception string WriteLog "Save-DellDriversTask error for $($modelDisplay): $($_.Exception.ToString())"
$success = $false Remove-DriverModelFolder -DriversFolder $DriversFolder -TargetFolder $modelPath -Description $modelDisplay
# Enqueue the error status before returning if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status $errorStatus }
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status } return [pscustomobject]@{ Model = $modelDisplay; Status = $errorStatus; Success = $false; DriverPath = $null }
# Ensure return object is created even on error
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $success; 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 * Export-ModuleMember -Function *
@@ -66,30 +66,45 @@ function Get-HPDriversModelList {
$settings.Async = $false # Ensure synchronous reading $settings.Async = $false # Ensure synchronous reading
$reader = [System.Xml.XmlReader]::Create($platformListXml, $settings) $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()) { while ($reader.Read()) {
if ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq 'Platform') { if ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq 'Platform') {
# Read the inner content of the Platform node
$platformReader = $reader.ReadSubtree() $platformReader = $reader.ReadSubtree()
$platformNames = [System.Collections.Generic.List[string]]::new()
$platformSystemId = $null
while ($platformReader.Read()) { while ($platformReader.Read()) {
if ($platformReader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $platformReader.Name -eq 'ProductName') { if ($platformReader.NodeType -eq [System.Xml.XmlNodeType]::Element) {
$modelName = $platformReader.ReadElementContentAsString() if ($platformReader.Name -eq 'ProductName') {
if (-not [string]::IsNullOrWhiteSpace($modelName) -and $uniqueModels.Add($modelName)) { $modelName = $platformReader.ReadElementContentAsString()
# Add to list only if it's a new unique model if (-not [string]::IsNullOrWhiteSpace($modelName)) {
$modelList.Add([PSCustomObject]@{ $platformNames.Add($modelName.Trim())
Make = $Make }
Model = $modelName }
}) elseif ($platformReader.Name -eq 'SystemID') {
$platformSystemId = $platformReader.ReadElementContentAsString().Trim()
} }
} }
} }
$platformReader.Close() $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() $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 { catch {
@@ -97,7 +112,7 @@ function Get-HPDriversModelList {
} }
# Sort the list alphabetically by Model name before returning # 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 to download and extract drivers for a specific HP model (Designed for ForEach-Object -Parallel)
function Save-HPDriversTask { function Save-HPDriversTask {
@@ -118,13 +133,22 @@ function Save-HPDriversTask {
[Parameter()] # Made optional [Parameter()] # Made optional
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null [System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null
[Parameter()] [Parameter()]
[bool]$CompressToWim = $false # New parameter for compression [bool]$CompressToWim = $false, # New parameter for compression
[Parameter()]
[bool]$PreserveSourceOnCompress = $false
) )
$modelName = $DriverItemData.Model $displayModelName = if (-not [string]::IsNullOrWhiteSpace($DriverItemData.Model)) { $DriverItemData.Model } else { $DriverItemData.Id }
$make = $DriverItemData.Make # Should be 'HP' $make = $DriverItemData.Make # Should be 'HP'
$identifier = $modelName # Unique identifier for progress updates $productName = if ($DriverItemData.PSObject.Properties['ProductName'] -and -not [string]::IsNullOrWhiteSpace($DriverItemData.ProductName)) { $DriverItemData.ProductName } else { ConvertTo-DriverBaseName -ModelString $displayModelName }
$sanitizedModelName = $modelName -replace '[\\/:"*?<>|]', '_' 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 $hpDriversBaseFolder = Join-Path -Path $DriversFolder -ChildPath $make # Changed variable name for clarity
$platformListXml = Join-Path -Path $hpDriversBaseFolder -ChildPath "PlatformList.xml" $platformListXml = Join-Path -Path $hpDriversBaseFolder -ChildPath "PlatformList.xml"
$modelSpecificFolder = Join-Path -Path $hpDriversBaseFolder -ChildPath $sanitizedModelName # Sanitize model name for folder path $modelSpecificFolder = Join-Path -Path $hpDriversBaseFolder -ChildPath $sanitizedModelName # Sanitize model name for folder path
@@ -132,7 +156,16 @@ function Save-HPDriversTask {
$finalStatus = "" # Initialize final status $finalStatus = "" # Initialize final status
$successState = $true # Assume success unless an operation fails $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 { try {
# Check for existing drivers # Check for existing drivers
@@ -146,13 +179,14 @@ function Save-HPDriversTask {
# Special handling for existing folders that need compression # Special handling for existing folders that need compression
if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') { if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') {
$wimFilePath = Join-Path -Path $hpDriversBaseFolder -ChildPath "$($sanitizedModelName).wim" $wimFilePath = Join-Path -Path $hpDriversBaseFolder -ChildPath "$($sanitizedModelName).wim"
$wimRelativePath = Join-Path -Path $make -ChildPath "$($sanitizedModelName).wim"
$sourceFolderPath = Join-Path -Path $hpDriversBaseFolder -ChildPath $sanitizedModelName $sourceFolderPath = Join-Path -Path $hpDriversBaseFolder -ChildPath $sanitizedModelName
WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'." WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Compressing existing..." } if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Compressing existing..." }
try { try {
Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -ErrorAction Stop $null = Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
$existingDriver.Status = "Already downloaded & Compressed" $existingDriver.Status = "Compression successful"
$existingDriver.DriverPath = Join-Path -Path $make -ChildPath "$($sanitizedModelName).wim" $existingDriver.DriverPath = $wimRelativePath
$existingDriver.Success = $true $existingDriver.Success = $true
WriteLog "Successfully compressed existing drivers for $identifier to $wimFilePath." WriteLog "Successfully compressed existing drivers for $identifier to $wimFilePath."
} }
@@ -187,13 +221,23 @@ function Save-HPDriversTask {
} }
} }
# Parse PlatformList.xml to find SystemID and OSReleaseID for the specific model # Parse the PlatformList.xml to find the SystemID based on the ProductName
WriteLog "Parsing $platformListXml for model '$modelName' details..." WriteLog "Parsing $platformListXml for model '$displayModelName' (SystemID: $systemIdentifier) details..."
[xml]$platformListContent = Get-Content -Path $platformListXml -Raw -Encoding UTF8 -ErrorAction Stop [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) { 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 $systemID = $platformNode.SystemID
@@ -286,11 +330,11 @@ function Save-HPDriversTask {
} }
$availableVersionsString = ($allAvailableVersions | Select-Object -Unique) -join ', ' $availableVersionsString = ($allAvailableVersions | Select-Object -Unique) -join ', '
if ([string]::IsNullOrWhiteSpace($availableVersionsString)) { $availableVersionsString = "None" } 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' $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", "" $archSuffix = $WindowsArch -replace "^x", ""
$modelRelease = "$($systemID)_$($archSuffix)_$($selectedOSRelease).0.$($selectedOSVersion.ToLower())" $modelRelease = "$($systemID)_$($archSuffix)_$($selectedOSRelease).0.$($selectedOSVersion.ToLower())"
$driverCabUrl = "https://hpia.hpcloud.hp.com/ref/$systemID/$modelRelease.cab" $driverCabUrl = "https://hpia.hpcloud.hp.com/ref/$systemID/$modelRelease.cab"
@@ -309,7 +353,7 @@ function Save-HPDriversTask {
$updates = $driverXmlContent.ImagePal.Solutions.UpdateInfo | Where-Object { $_.Category -match '^Driver' } $updates = $driverXmlContent.ImagePal.Solutions.UpdateInfo | Where-Object { $_.Category -match '^Driver' }
$totalDrivers = ($updates | Measure-Object).Count $totalDrivers = ($updates | Measure-Object).Count
$downloadedCount = 0 $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 ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Found $totalDrivers drivers. Downloading..." }
if (-not (Test-Path -Path $modelSpecificFolder)) { if (-not (Test-Path -Path $modelSpecificFolder)) {
@@ -327,7 +371,7 @@ function Save-HPDriversTask {
$extractFolder = Join-Path -Path $downloadFolder -ChildPath ($driverName + "_" + $version + "_" + ($driverFileName -replace '\.exe$', '')) $extractFolder = Join-Path -Path $downloadFolder -ChildPath ($driverName + "_" + $version + "_" + ($driverFileName -replace '\.exe$', ''))
$downloadedCount++ $downloadedCount++
$progressMsg = "($downloadedCount/$totalDrivers) Downloading $driverName..." $progressMsg = "$downloadedCount/$totalDrivers Downloading $driverName"
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $progressMsg } if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $progressMsg }
WriteLog "$progressMsg URL: $driverUrl" WriteLog "$progressMsg URL: $driverUrl"
@@ -341,6 +385,8 @@ function Save-HPDriversTask {
WriteLog "Downloading driver to: $driverFilePath" WriteLog "Downloading driver to: $driverFilePath"
Start-BitsTransferWithRetry -Source $driverUrl -Destination $driverFilePath -ErrorAction Stop Start-BitsTransferWithRetry -Source $driverUrl -Destination $driverFilePath -ErrorAction Stop
WriteLog "Driver downloaded: $driverFilePath" 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" WriteLog "Creating extraction folder: $extractFolder"
New-Item -Path $extractFolder -ItemType Directory -Force -ErrorAction Stop | Out-Null New-Item -Path $extractFolder -ItemType Directory -Force -ErrorAction Stop | Out-Null
$arguments = "/s /e /f `"$extractFolder`"" $arguments = "/s /e /f `"$extractFolder`""
@@ -354,7 +400,7 @@ function Save-HPDriversTask {
} }
Remove-Item -Path $driverCabFile, $driverXmlFile -Force -ErrorAction SilentlyContinue 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" $finalStatus = "Completed"
if ($CompressToWim) { if ($CompressToWim) {
@@ -362,7 +408,7 @@ function Save-HPDriversTask {
$wimFilePath = Join-Path -Path $hpDriversBaseFolder -ChildPath "$($identifier).wim" $wimFilePath = Join-Path -Path $hpDriversBaseFolder -ChildPath "$($identifier).wim"
WriteLog "Compressing '$modelSpecificFolder' to '$wimFilePath'..." WriteLog "Compressing '$modelSpecificFolder' to '$wimFilePath'..."
try { try {
Compress-DriverFolderToWim -SourceFolderPath $modelSpecificFolder -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -ErrorAction Stop $null = Compress-DriverFolderToWim -SourceFolderPath $modelSpecificFolder -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
WriteLog "Compression successful for '$identifier'." WriteLog "Compression successful for '$identifier'."
$finalStatus = "Completed & Compressed" $finalStatus = "Completed & Compressed"
$driverRelativePath = Join-Path -Path $make -ChildPath "$($identifier).wim" # Update relative path to the WIM $driverRelativePath = Join-Path -Path $make -ChildPath "$($identifier).wim" # Update relative path to the WIM
@@ -375,15 +421,12 @@ function Save-HPDriversTask {
$successState = $true $successState = $true
} }
catch { catch {
$errorMessage = "Error saving HP drivers for $($modelName): $($_.Exception.Message)" $errorMessage = "Error saving HP drivers for $($displayModelName): $($_.Exception.Message)"
WriteLog $errorMessage WriteLog $errorMessage
$finalStatus = "Error: $($_.Exception.Message.Split([Environment]::NewLine)[0])" $finalStatus = "Error: $($_.Exception.Message.Split([Environment]::NewLine)[0])"
$successState = $false $successState = $false
$driverRelativePath = $null # Ensure path is null on error $driverRelativePath = $null # Ensure path is null on error
if (Test-Path -Path $modelSpecificFolder -PathType Container) { Remove-DriverModelFolder -DriversFolder $DriversFolder -TargetFolder $modelSpecificFolder -Description $identifier
WriteLog "Attempting to remove partially created folder $modelSpecificFolder due to error."
Remove-Item -Path $modelSpecificFolder -Recurse -Force -ErrorAction SilentlyContinue
}
} }
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $finalStatus } if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $finalStatus }
@@ -23,10 +23,11 @@ function Get-LenovoDriversModelList {
# If anyone knows how to reliably get the the model and machine type information from Lenovo, let me know. # If anyone knows how to reliably get the the model and machine type information from Lenovo, let me know.
# https://download.lenovo.com/cdrt/td/catalogv2.xml only provides a subset of the information available from PSREF (e.g. it's missing 300w, 500w, and other consumer models). # https://download.lenovo.com/cdrt/td/catalogv2.xml only provides a subset of the information available from PSREF (e.g. it's missing 300w, 500w, and other consumer models).
# $lenovoCookie = "X-PSREF-USER-TOKEN=eyJ0eXAiOiJKV1QifQ.bjVTdWk0YklZeUc2WnFzL0lXU0pTeU1JcFo0aExzRXl1UGxHN3lnS1BtckI0ZVU5WEJyVGkvaFE0NmVNU2U1ZjNrK3ZqTEVIZ29nTk1TNS9DQmIwQ0pTN1Q1VytlY1RpNzZTUldXbm4wZ1g2RGJuQWg4MXRkTmxKT2YrOW9LRjBzQUZzV05HM3NpcU92WFVTM0o0blM1SDQyUlVXNThIV1VBS2R0c1B2NjJyQjIrUGxNZ2x6RTRhUjY5UDZWclBX.ZDBmM2EyMWRjZTg2N2JmYWMxZDIxY2NiYjQzMWFhNjg1YjEzZTAxNmU2M2RmN2M5ZjIyZWJhMzZkOWI1OWJhZg" $lenovoCookie = "X-PSREF-USER-TOKEN=eyJ0eXAiOiJKV1QifQ.bjVTdWk0YklZeUc2WnFzL0lXU0pTeU1JcFo0aExzRXl1UGxHN3lnS1BtckI0ZVU5WEJyVGkvaFE0NmVNU2U1ZjNrK3ZqTEVIZ29nTk1TNS9DQmIwQ0pTN1Q1VytlY1RpNzZTUldXbm4wZ1g2RGJuQWg4MXRkTmxKT2YrOW9LRjBzQUZzV05HM3NpcU92WFVTM0o0blM1SDQyUlVXNThIV1VBS2R0c1B2NjJyQjIrUGxNZ2x6RTRhUjY5UDZWclBX.ZDBmM2EyMWRjZTg2N2JmYWMxZDIxY2NiYjQzMWFhNjg1YjEzZTAxNmU2M2RmN2M5ZjIyZWJhMzZkOWI1OWJhZg"
# Wrote a separate function to grab the token. Check the function notes for more details. Keep the above comment for now to see if the cookie ever changes. # Wrote a separate function to grab the token. Check the function notes for more details. Keep the above comment for now to see if the cookie ever changes.
$lenovoCookie = Get-LenovoPSREFToken # 3/25/2026 - The cookie is still the same after 8 months, but we'll keep the retrieval function in case it changes in the future or if we need to get a new one.
# $lenovoCookie = Get-LenovoPSREFToken
# Add the cookie to the headers # Add the cookie to the headers
$Headers["Cookie"] = $lenovoCookie $Headers["Cookie"] = $lenovoCookie
@@ -94,17 +95,20 @@ function Save-LenovoDriversTask {
[hashtable]$Headers, [hashtable]$Headers,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$UserAgent, [string]$UserAgent,
[Parameter()] # Made optional [Parameter()]
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, [System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null,
[Parameter()] [Parameter()]
[bool]$CompressToWim = $false [bool]$CompressToWim = $false,
[Parameter()]
[bool]$PreserveSourceOnCompress = $false
) )
# The Model property from the UI already contains the combined "ProductName (MachineType)" string # The Model property from the UI already contains the combined "ProductName (MachineType)" string
$identifier = $DriverItemData.Model $identifier = $DriverItemData.Model
$machineType = $DriverItemData.MachineType $machineType = $DriverItemData.MachineType
$make = "Lenovo" $make = "Lenovo"
$sanitizedIdentifier = $identifier -replace '[\\/:"*?<>|]', '_' $sanitizedIdentifier = ConvertTo-SafeName -Name $identifier
if ($sanitizedIdentifier -ne $identifier) { WriteLog "Sanitized model identifier: '$identifier' -> '$sanitizedIdentifier'" }
$status = "Starting..." $status = "Starting..."
$success = $false $success = $false
@@ -129,13 +133,14 @@ function Save-LenovoDriversTask {
# Special handling for existing folders that need compression # Special handling for existing folders that need compression
if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') { if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') {
$wimFilePath = Join-Path -Path $makeDriversPath -ChildPath "$($sanitizedIdentifier).wim" $wimFilePath = Join-Path -Path $makeDriversPath -ChildPath "$($sanitizedIdentifier).wim"
$wimRelativePath = Join-Path -Path $make -ChildPath "$($sanitizedIdentifier).wim"
$sourceFolderPath = Join-Path -Path $makeDriversPath -ChildPath $sanitizedIdentifier $sourceFolderPath = Join-Path -Path $makeDriversPath -ChildPath $sanitizedIdentifier
WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'." WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Compressing existing..." } if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Compressing existing..." }
try { try {
Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -ErrorAction Stop $null = Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
$existingDriver.Status = "Already downloaded & Compressed" $existingDriver.Status = "Compression successful"
$existingDriver.DriverPath = Join-Path -Path $make -ChildPath "$($sanitizedIdentifier).wim" $existingDriver.DriverPath = $wimRelativePath
$existingDriver.Success = $true $existingDriver.Success = $true
WriteLog "Successfully compressed existing drivers for $identifier to $wimFilePath." WriteLog "Successfully compressed existing drivers for $identifier to $wimFilePath."
} }
@@ -204,17 +209,16 @@ function Save-LenovoDriversTask {
$packageXMLPath = Join-Path -Path $tempDownloadPath -ChildPath $packageName $packageXMLPath = Join-Path -Path $tempDownloadPath -ChildPath $packageName
$baseURL = $packageUrl -replace [regex]::Escape($packageName), "" # Base URL for the driver file $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 # Download the package XML
WriteLog "($processedPackages/$totalPackages) Downloading package XML: $packageUrl" WriteLog "($processedPackages/$totalPackages) Downloading package XML: $packageUrl"
try { try {
Start-BitsTransferWithRetry -Source $packageUrl -Destination $packageXMLPath Start-BitsTransferWithRetry -Source $packageUrl -Destination $packageXMLPath
} }
catch { catch {
WriteLog "($processedPackages/$totalPackages) Failed to download package XML '$packageUrl'. Skipping. Error: $($_.Exception.Message)" $failureMessage = "Failed to download Lenovo package XML '$packageUrl': $($_.Exception.Message)"
continue # Skip this package WriteLog "($processedPackages/$totalPackages) $failureMessage"
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
throw (New-Object System.Exception($failureMessage, $_.Exception))
} }
# Load and parse the package XML # Load and parse the package XML
@@ -275,7 +279,7 @@ function Save-LenovoDriversTask {
} }
# Download the driver .exe # 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 } if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
WriteLog "($processedPackages/$totalPackages) Downloading driver: $driverUrl to $driverFilePath" WriteLog "($processedPackages/$totalPackages) Downloading driver: $driverUrl to $driverFilePath"
try { try {
@@ -283,13 +287,14 @@ function Save-LenovoDriversTask {
WriteLog "($processedPackages/$totalPackages) Driver downloaded: $driverFileName" WriteLog "($processedPackages/$totalPackages) Driver downloaded: $driverFileName"
} }
catch { catch {
WriteLog "($processedPackages/$totalPackages) Failed to download driver '$driverUrl'. Skipping. Error: $($_.Exception.Message)" $failureMessage = "Failed to download driver '$packageTitle' from $($driverUrl): $($_.Exception.Message)"
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue # Clean up package XML WriteLog "($processedPackages/$totalPackages) $failureMessage"
continue # Skip this driver Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
throw (New-Object System.Exception($failureMessage, $_.Exception))
} }
# --- Extraction Logic --- # --- Extraction Logic ---
$status = "($processedPackages/$totalPackages) Extracting $packageTitle..." $status = "$processedPackages/$totalPackages Extracting $packageTitle"
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status } if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
# Always use a temporary extraction path to avoid long path issues # Always use a temporary extraction path to avoid long path issues
@@ -314,7 +319,7 @@ function Save-LenovoDriversTask {
# Modify the extract command to point to the temporary folder # Modify the extract command to point to the temporary folder
$modifiedExtractCommand = $extractCommand -replace '%PACKAGEPATH%', "`"$extractFolder`"" $modifiedExtractCommand = $extractCommand -replace '%PACKAGEPATH%', "`"$extractFolder`""
WriteLog "($processedPackages/$totalPackages) Extracting driver: $driverFilePath using command: $modifiedExtractCommand" WriteLog "$processedPackages/$totalPackages Extracting driver: $driverFilePath using command: $modifiedExtractCommand"
try { try {
Invoke-Process -FilePath $driverFilePath -ArgumentList $modifiedExtractCommand -Wait $true | Out-Null Invoke-Process -FilePath $driverFilePath -ArgumentList $modifiedExtractCommand -Wait $true | Out-Null
@@ -322,14 +327,13 @@ function Save-LenovoDriversTask {
$extractionSucceeded = $true $extractionSucceeded = $true
} }
catch { catch {
WriteLog "($processedPackages/$totalPackages) Failed to extract driver '$driverFilePath' to temporary path. Skipping. Error: $($_.Exception.Message)" $failureMessage = "Failed to extract driver package '$packageTitle': $($_.Exception.Message)"
# Don't delete the downloaded exe yet if extraction fails WriteLog "($processedPackages/$totalPackages) $failureMessage"
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue # Clean up package XML Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
# Clean up temp folder if extraction failed
if ($tempExtractBase -and (Test-Path -Path $tempExtractBase)) { if ($tempExtractBase -and (Test-Path -Path $tempExtractBase)) {
Remove-Item -Path $tempExtractBase -Recurse -Force -ErrorAction SilentlyContinue 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) --- # --- Post-Extraction Handling (Move from Temp to Final Destination) ---
@@ -372,10 +376,9 @@ function Save-LenovoDriversTask {
Move-Item -Path $item.FullName -Destination $finalDestinationPath -Force -ErrorAction Stop Move-Item -Path $item.FullName -Destination $finalDestinationPath -Force -ErrorAction Stop
} }
catch { catch {
WriteLog "($processedPackages/$totalPackages) Failed to move item '$($item.FullName)' to '$finalDestinationPath'. Error: $($_.Exception.Message)" $failureMessage = "Failed to move extracted item '$($item.FullName)' to '$finalDestinationPath': $($_.Exception.Message)"
# Decide if this should stop the whole process or just skip this item WriteLog "($processedPackages/$totalPackages) $failureMessage"
# For now, we'll log and continue, but mark overall success as false throw (New-Object System.Exception($failureMessage, $_.Exception))
$extractionSucceeded = $false
} }
} # End foreach ($item in $extractedItems) } # End foreach ($item in $extractedItems)
@@ -412,6 +415,9 @@ function Save-LenovoDriversTask {
# Always delete the package XML # Always delete the package XML
WriteLog "($processedPackages/$totalPackages) Deleting package XML file: $packageXMLPath" WriteLog "($processedPackages/$totalPackages) Deleting package XML file: $packageXMLPath"
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue 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 } # End foreach package
@@ -424,7 +430,7 @@ function Save-LenovoDriversTask {
$driverRelativePath = Join-Path -Path $make -ChildPath $wimFileName # Update relative path to the WIM file $driverRelativePath = Join-Path -Path $make -ChildPath $wimFileName # Update relative path to the WIM file
WriteLog "Compressing '$modelPath' to '$destinationWimPath'..." WriteLog "Compressing '$modelPath' to '$destinationWimPath'..."
try { try {
$compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $identifier -WimDescription $identifier -ErrorAction Stop $compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $identifier -WimDescription $identifier -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
if ($compressResult) { if ($compressResult) {
WriteLog "Compression successful for '$identifier'." WriteLog "Compression successful for '$identifier'."
$status = "Completed & Compressed" $status = "Completed & Compressed"
@@ -448,12 +454,11 @@ function Save-LenovoDriversTask {
} }
catch { catch {
$status = "Error: $($_.Exception.Message.Split('.')[0])" # Shorten error message $status = "Error: $($_.Exception.Message)"
WriteLog "Error saving Lenovo drivers for '$identifier': $($_.Exception.ToString())" # Log full exception string WriteLog "Error saving Lenovo drivers for '$identifier': $($_.Exception.ToString())"
$success = $false $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 } 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 } return [PSCustomObject]@{ Identifier = $identifier; Status = $status; Success = $success; DriverPath = $null }
} }
finally { finally {
@@ -10,72 +10,13 @@ function Get-MicrosoftDriversModelList {
[CmdletBinding()] [CmdletBinding()]
param( param(
[hashtable]$Headers, # Pass necessary headers [hashtable]$Headers, # Pass necessary headers
[string]$UserAgent # Pass UserAgent [string]$UserAgent, # Pass UserAgent
[Parameter(Mandatory = $true)]
[string]$DriversFolder
) )
$url = "https://support.microsoft.com/en-us/surface/download-drivers-and-firmware-for-surface-09bb2e09-2a4b-cb69-0951-078a7739e120" # Keep the UI signature unchanged while using the shared Learn-based source.
$models = @() return @(Get-SurfaceDriverModelIndex -DriversFolder $DriversFolder)
try {
WriteLog "Getting Surface driver information from $url"
$OriginalVerbosePreference = $VerbosePreference
$VerbosePreference = 'SilentlyContinue'
# Use passed-in UserAgent and Headers
$webContent = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $Headers -UserAgent $UserAgent
$VerbosePreference = $OriginalVerbosePreference
WriteLog "Complete"
WriteLog "Parsing web content for models and download links"
$html = $webContent.Content
$divPattern = '<div[^>]*class="selectable-content-options__option-content(?: ocHidden)?"[^>]*>(.*?)</div>'
$divMatches = [regex]::Matches($html, $divPattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)
foreach ($divMatch in $divMatches) {
$divContent = $divMatch.Groups[1].Value
$tablePattern = '<table[^>]*>(.*?)</table>'
$tableMatches = [regex]::Matches($divContent, $tablePattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)
foreach ($tableMatch in $tableMatches) {
$tableContent = $tableMatch.Groups[1].Value
$rowPattern = '<tr[^>]*>(.*?)</tr>'
$rowMatches = [regex]::Matches($tableContent, $rowPattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)
foreach ($rowMatch in $rowMatches) {
$rowContent = $rowMatch.Groups[1].Value
$cellPattern = '<td[^>]*>\s*(?:<p[^>]*>)?(.*?)(?:</p>)?\s*</td>'
$cellMatches = [regex]::Matches($rowContent, $cellPattern, [System.Text.RegularExpressions.RegexOptions]::Singleline)
if ($cellMatches.Count -ge 2) {
$modelName = ([System.Net.WebUtility]::HtmlDecode(($cellMatches[0].Groups[1].Value).Trim()))
$secondTdContent = $cellMatches[1].Groups[1].Value.Trim()
# $linkPattern = '<a[^>]+href="([^"]+)"[^>]*>'
# Change linkPattern to match https://www.microsoft.com/download/details.aspx?id=
$linkPattern = '<a[^>]+href="(https://www\.microsoft\.com/download/details\.aspx\?id=\d+)"[^>]*>'
$linkMatch = [regex]::Match($secondTdContent, $linkPattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
if ($linkMatch.Success) {
$modelLink = $linkMatch.Groups[1].Value
}
else {
continue
}
$models += [PSCustomObject]@{
Make = 'Microsoft'
Model = $modelName
Link = $modelLink
}
}
}
}
}
WriteLog "Parsing complete. Found $($models.Count) models."
return $models
}
catch {
WriteLog "Error getting Microsoft models: $($_.Exception.Message)"
throw "Failed to retrieve Microsoft Surface models."
}
} }
# Function to download and extract drivers for a specific Microsoft model (Modified for ForEach-Object -Parallel) # Function to download and extract drivers for a specific Microsoft model (Modified for ForEach-Object -Parallel)
function Save-MicrosoftDriversTask { function Save-MicrosoftDriversTask {
@@ -94,7 +35,9 @@ function Save-MicrosoftDriversTask {
[Parameter()] # Made optional [Parameter()] # Made optional
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null [System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null
[Parameter()] [Parameter()]
[bool]$CompressToWim = $false # New parameter for compression [bool]$CompressToWim = $false, # New parameter for compression
[Parameter()]
[bool]$PreserveSourceOnCompress = $false
# REMOVED: UI-related parameters # REMOVED: UI-related parameters
) )
@@ -104,6 +47,10 @@ function Save-MicrosoftDriversTask {
$driverRelativePath = Join-Path -Path $make -ChildPath $modelName # Relative path for the driver folder $driverRelativePath = Join-Path -Path $make -ChildPath $modelName # Relative path for the driver folder
$status = "Getting download link..." # Initial local status $status = "Getting download link..." # Initial local status
$success = $false $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 # Initial status update
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Checking..." } if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Checking..." }
@@ -121,13 +68,14 @@ function Save-MicrosoftDriversTask {
if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') { if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') {
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $make $makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $make
$wimFilePath = Join-Path -Path $makeDriversPath -ChildPath "$($modelName).wim" $wimFilePath = Join-Path -Path $makeDriversPath -ChildPath "$($modelName).wim"
$wimRelativePath = Join-Path -Path $make -ChildPath "$($modelName).wim"
$sourceFolderPath = Join-Path -Path $makeDriversPath -ChildPath $modelName $sourceFolderPath = Join-Path -Path $makeDriversPath -ChildPath $modelName
WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'." WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Compressing existing..." } if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Compressing existing..." }
try { try {
Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $modelName -WimDescription "Drivers for $modelName" -ErrorAction Stop $null = Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $modelName -WimDescription "Drivers for $modelName" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
$existingDriver.Status = "Already downloaded & Compressed" $existingDriver.Status = "Compression successful"
$existingDriver.DriverPath = Join-Path -Path $make -ChildPath "$($modelName).wim" $existingDriver.DriverPath = $wimRelativePath
$existingDriver.Success = $true $existingDriver.Success = $true
WriteLog "Successfully compressed existing drivers for $modelName to $wimFilePath." WriteLog "Successfully compressed existing drivers for $modelName to $wimFilePath."
} }
@@ -145,49 +93,123 @@ function Save-MicrosoftDriversTask {
### GET THE DOWNLOAD LINK ### GET THE DOWNLOAD LINK
$status = "Getting download link..." $status = "Getting download link..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status } if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
WriteLog "Getting download page content for $modelName from $modelLink"
$OriginalVerbosePreference = $VerbosePreference
$VerbosePreference = 'SilentlyContinue'
# Use passed-in UserAgent and Headers
$downloadPageContent = Invoke-WebRequest -Uri $modelLink -UseBasicParsing -Headers $Headers -UserAgent $UserAgent
$VerbosePreference = $OriginalVerbosePreference
WriteLog "Complete"
$status = "Parsing download page..." # Initialize Win10/Win11 link variables
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status } $win10Link = $null
WriteLog "Parsing download page for file" $win10FileName = $null
$scriptPattern = '<script>window.__DLCDetails__={(.*?)}<\/script>' $win11Link = $null
$scriptMatch = [regex]::Match($downloadPageContent.Content, $scriptPattern) $win11FileName = $null
if ($scriptMatch.Success) { # Prefer cached Download Center details (Source C) to avoid unnecessary internet calls and cache rewrites
$scriptContent = $scriptMatch.Groups[1].Value $useCachedDownloadCenterDetails = $false
# $downloadFilePattern = '"name":"(.*?)",.*?"url":"(.*?)"' try {
$downloadFilePattern = '"name":"([^"]+\.(?:msi|zip))",[^}]*?"url":"(.*?)"' $cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
$downloadFileMatches = [regex]::Matches($scriptContent, $downloadFilePattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) $cachedDetails = @($cache.DownloadCenterDetails | Where-Object { $_.Link -eq $modelLink } | Select-Object -First 1)
if ($cachedDetails.Count -gt 0 -and $cachedDetails[0].Files -and $cachedDetails[0].Files.Count -gt 0) {
$useCachedDownloadCenterDetails = $true
WriteLog "Surface cache: Using cached Download Center details for $modelName from $modelLink"
foreach ($downloadFile in @($cachedDetails[0].Files)) {
if ($null -eq $downloadFile) { continue }
$currentFileName = $downloadFile.Name
$fileUrl = $downloadFile.Url
if ([string]::IsNullOrWhiteSpace($currentFileName) -or [string]::IsNullOrWhiteSpace($fileUrl)) { continue }
$win10Link = $null if ($currentFileName -match "Win10") {
$win10FileName = $null $win10Link = $fileUrl
$win11Link = $null $win10FileName = $currentFileName
$win11FileName = $null WriteLog "Found Win10 link (cached): $win10FileName"
}
# Iterate through all matches to find potential Win10 and Win11 links elseif ($currentFileName -match "Win11") {
foreach ($downloadFile in $downloadFileMatches) { $win11Link = $fileUrl
$currentFileName = $downloadFile.Groups[1].Value $win11FileName = $currentFileName
$fileUrl = $downloadFile.Groups[2].Value WriteLog "Found Win11 link (cached): $win11FileName"
}
if ($currentFileName -match "Win10") {
$win10Link = $fileUrl
$win10FileName = $currentFileName
WriteLog "Found Win10 link: $win10FileName"
}
elseif ($currentFileName -match "Win11") {
$win11Link = $fileUrl
$win11FileName = $currentFileName
WriteLog "Found Win11 link: $win11FileName"
} }
} }
}
catch {
WriteLog "Surface cache: Failed loading cached Download Center details for '$modelName'. Error: $($_.Exception.Message)"
}
# Cache miss: download and parse the model's Download Center page (Source C), then backfill the cache
if (-not $useCachedDownloadCenterDetails) {
WriteLog "Getting download page content for $modelName from $modelLink"
$OriginalVerbosePreference = $VerbosePreference
$VerbosePreference = 'SilentlyContinue'
# Use passed-in UserAgent and Headers
$downloadPageContent = Invoke-WebRequest -Uri $modelLink -UseBasicParsing -Headers $Headers -UserAgent $UserAgent
$VerbosePreference = $OriginalVerbosePreference
WriteLog "Complete"
$status = "Parsing download page..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
WriteLog "Parsing download page for file"
$scriptPattern = '<script>window.__DLCDetails__={(.*?)}<\/script>'
$scriptMatch = [regex]::Match($downloadPageContent.Content, $scriptPattern)
if ($scriptMatch.Success) {
$scriptContent = $scriptMatch.Groups[1].Value
# $downloadFilePattern = '"name":"(.*?)",.*?"url":"(.*?)"'
$downloadFilePattern = '"name":"([^"]+\.(?:msi|zip))",[^}]*?"url":"(.*?)"'
$downloadFileMatches = [regex]::Matches($scriptContent, $downloadFilePattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
# Iterate through all matches to find potential Win10 and Win11 links
foreach ($downloadFile in $downloadFileMatches) {
$currentFileName = $downloadFile.Groups[1].Value
$fileUrl = $downloadFile.Groups[2].Value
if ($currentFileName -match "Win10") {
$win10Link = $fileUrl
$win10FileName = $currentFileName
WriteLog "Found Win10 link: $win10FileName"
}
elseif ($currentFileName -match "Win11") {
$win11Link = $fileUrl
$win11FileName = $currentFileName
WriteLog "Found Win11 link: $win11FileName"
}
}
# Update local cache with Download Center file details (Source C) for this model.
# This runs during download (not during Get Models) so it won't slow the listview population.
try {
$filesForCache = [System.Collections.Generic.List[pscustomobject]]::new()
if ($win10Link -and $win10FileName) {
$filesForCache.Add([pscustomobject]@{ Name = $win10FileName; Url = $win10Link })
}
if ($win11Link -and $win11FileName) {
$filesForCache.Add([pscustomobject]@{ Name = $win11FileName; Url = $win11Link })
}
if ($filesForCache.Count -gt 0) {
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
$detailsEntry = [pscustomobject][ordered]@{
Model = $modelName
Link = $modelLink
Files = @($filesForCache)
}
$newDetails = [System.Collections.Generic.List[pscustomobject]]::new()
foreach ($item in @($cache.DownloadCenterDetails)) {
if ($null -ne $item -and $item.PSObject.Properties['Link'] -and $item.Link -ne $modelLink) {
$newDetails.Add($item)
}
}
$newDetails.Add($detailsEntry)
$cache.DownloadCenterDetails = @($newDetails)
Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder
}
}
catch {
WriteLog "Surface cache: Failed updating Download Center details cache for '$modelName'. Error: $($_.Exception.Message)"
}
$useCachedDownloadCenterDetails = $true
}
}
if ($useCachedDownloadCenterDetails) {
# Decision logic to select the appropriate download link # Decision logic to select the appropriate download link
$downloadLink = $null $downloadLink = $null
$fileName = $null $fileName = $null
@@ -224,7 +246,7 @@ function Save-MicrosoftDriversTask {
### DOWNLOAD AND EXTRACT ### DOWNLOAD AND EXTRACT
if ($downloadLink) { if ($downloadLink) {
WriteLog "Selected Download Link for $modelName (Actual: Windows $downloadedVersion): $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 } if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Create directories # Create directories
@@ -232,8 +254,6 @@ function Save-MicrosoftDriversTask {
WriteLog "Creating Drivers folder: $DriversFolder" WriteLog "Creating Drivers folder: $DriversFolder"
New-Item -Path $DriversFolder -ItemType Directory -Force | Out-Null New-Item -Path $DriversFolder -ItemType Directory -Force | Out-Null
} }
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $Make
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $modelName
if (-Not (Test-Path -Path $modelPath)) { if (-Not (Test-Path -Path $modelPath)) {
WriteLog "Creating model folder: $modelPath" WriteLog "Creating model folder: $modelPath"
New-Item -Path $modelPath -ItemType Directory -Force | Out-Null New-Item -Path $modelPath -ItemType Directory -Force | Out-Null
@@ -253,7 +273,7 @@ function Save-MicrosoftDriversTask {
### EXTRACT ### EXTRACT
if ($fileExtension -eq ".msi") { 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 } 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 # Use a named mutex to ensure only one MSI extraction happens at a time across all parallel tasks
@@ -282,14 +302,14 @@ function Save-MicrosoftDriversTask {
catch [System.Threading.WaitHandleCannotBeOpenedException] { catch [System.Threading.WaitHandleCannotBeOpenedException] {
# Mutex is clear, proceed to extraction attempt # Mutex is clear, proceed to extraction attempt
WriteLog "System MSI mutex clear. Proceeding with MSI extraction attempt for $modelName." 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 } if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
$mutexClear = $true $mutexClear = $true
} }
catch { catch {
# Handle other potential errors when checking the mutex # Handle other potential errors when checking the mutex
WriteLog "Warning: Error checking system MSI mutex for $($modelName): $_. Proceeding with caution." 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 } if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
$mutexClear = $true # Proceed despite mutex error $mutexClear = $true # Proceed despite mutex error
} }
@@ -347,7 +367,7 @@ function Save-MicrosoftDriversTask {
} }
} }
elseif ($fileExtension -eq ".zip") { 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 } if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
WriteLog "Extracting ZIP file to $modelPath" WriteLog "Extracting ZIP file to $modelPath"
$ProgressPreference = 'SilentlyContinue' $ProgressPreference = 'SilentlyContinue'
@@ -377,7 +397,7 @@ function Save-MicrosoftDriversTask {
WriteLog "Compressing '$modelPath' to '$destinationWimPath'..." WriteLog "Compressing '$modelPath' to '$destinationWimPath'..."
try { try {
# Use the function from the imported common module # Use the function from the imported common module
$compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $modelName -WimDescription $modelName -ErrorAction Stop $compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $modelName -WimDescription $modelName -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
if ($compressResult) { if ($compressResult) {
WriteLog "Compression successful for '$modelName'." WriteLog "Compression successful for '$modelName'."
$status = "Completed & Compressed" $status = "Completed & Compressed"
@@ -417,6 +437,7 @@ function Save-MicrosoftDriversTask {
$status = "Error: $($_.Exception.Message.Split('.')[0])" # Shorten error message $status = "Error: $($_.Exception.Message.Split('.')[0])" # Shorten error message
WriteLog "Error saving Microsoft drivers for $($modelName): $($_.Exception.Message)" WriteLog "Error saving Microsoft drivers for $($modelName): $($_.Exception.Message)"
$success = $false $success = $false
Remove-DriverModelFolder -DriversFolder $DriversFolder -TargetFolder $modelPath -Description $modelName
# Enqueue the error status before returning # Enqueue the error status before returning
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status } if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Ensure return object is created even on error # Ensure return object is created even on error
+455 -101
View File
@@ -5,8 +5,173 @@
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. 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 ConvertTo-DriverBaseName {
function Get-ModelsForMake { 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 Get-EffectiveDriverWindowsRelease {
param(
[Parameter(Mandatory = $true)]
[int]$WindowsRelease,
[string]$WindowsReleaseDisplay,
[string]$WindowsSku
)
# Normalize LTSC/LTSB UI release selections to client driver releases for OEM catalogs.
if (-not [string]::IsNullOrWhiteSpace($WindowsReleaseDisplay)) {
if (($WindowsReleaseDisplay -like 'Windows 10*') -and (($WindowsReleaseDisplay -like '*LTSB*') -or ($WindowsReleaseDisplay -like '*LTSC*'))) {
return 10
}
if (($WindowsReleaseDisplay -like 'Windows 11*') -and ($WindowsReleaseDisplay -like '*LTSC*')) {
return 11
}
}
# Use SKU-based fallback when display text is unavailable.
if (-not [string]::IsNullOrWhiteSpace($WindowsSku) -and $WindowsSku -like '*LTS*') {
if ($WindowsRelease -in 2016, 2019, 2021) {
return 10
}
if ($WindowsRelease -eq 2024) {
return 11
}
}
return $WindowsRelease
}
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( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$SelectedMake, [string]$SelectedMake,
@@ -20,8 +185,20 @@ function Get-ModelsForMake {
# Get necessary values from UI or script scope # Get necessary values from UI or script scope
$localDriversFolder = $State.Controls.txtDriversFolder.Text $localDriversFolder = $State.Controls.txtDriversFolder.Text
$localWindowsRelease = $null $localWindowsRelease = $null
$localWindowsReleaseDisplay = $null
if ($null -ne $State.Controls.cmbWindowsRelease.SelectedItem) { if ($null -ne $State.Controls.cmbWindowsRelease.SelectedItem) {
$localWindowsRelease = $State.Controls.cmbWindowsRelease.SelectedItem.Value $localWindowsRelease = $State.Controls.cmbWindowsRelease.SelectedItem.Value
$localWindowsReleaseDisplay = $State.Controls.cmbWindowsRelease.SelectedItem.Display
}
# Resolve effective release used specifically for OEM driver operations.
$localWindowsSku = if ($null -ne $State.Controls.cmbWindowsSKU.SelectedItem) { [string]$State.Controls.cmbWindowsSKU.SelectedItem } else { $null }
$localDriverWindowsRelease = $localWindowsRelease
if ($null -ne $localWindowsRelease) {
$localDriverWindowsRelease = Get-EffectiveDriverWindowsRelease -WindowsRelease $localWindowsRelease -WindowsReleaseDisplay $localWindowsReleaseDisplay -WindowsSku $localWindowsSku
if ($localDriverWindowsRelease -ne $localWindowsRelease) {
WriteLog "Normalized WindowsRelease for model retrieval from $localWindowsRelease to $localDriverWindowsRelease (Display='$localWindowsReleaseDisplay', SKU='$localWindowsSku')."
}
} }
# Get headers and user agent from Get-CoreStaticVariables # Get headers and user agent from Get-CoreStaticVariables
@@ -36,10 +213,10 @@ function Get-ModelsForMake {
switch ($SelectedMake) { switch ($SelectedMake) {
'Microsoft' { 'Microsoft' {
$rawModels = Get-MicrosoftDriversModelList -Headers $Headers -UserAgent $UserAgent $rawModels = Get-MicrosoftDriversModelList -Headers $Headers -UserAgent $UserAgent -DriversFolder $localDriversFolder
} }
'Dell' { 'Dell' {
$rawModels = Get-DellDriversModelList -WindowsRelease $localWindowsRelease -DriversFolder $localDriversFolder -Make $SelectedMake $rawModels = Get-DellDriversModelList -WindowsRelease $localDriverWindowsRelease -DriversFolder $localDriversFolder -Make $SelectedMake
} }
'HP' { 'HP' {
$rawModels = Get-HPDriversModelList -DriversFolder $localDriversFolder -Make $SelectedMake $rawModels = Get-HPDriversModelList -DriversFolder $localDriversFolder -Make $SelectedMake
@@ -84,11 +261,12 @@ function ConvertTo-StandardizedDriverModel {
[psobject]$State [psobject]$State
) )
$modelDisplay = $RawDriverObject.Model # Default $modelDisplay = $RawDriverObject.Model
$id = $RawDriverObject.Model # Default $id = $RawDriverObject.Model
$link = $null $link = $null
$productName = $null $productName = $null
$machineType = $null $machineType = $null
$systemId = $null
if ($RawDriverObject.PSObject.Properties['Link']) { if ($RawDriverObject.PSObject.Properties['Link']) {
$link = $RawDriverObject.Link $link = $RawDriverObject.Link
@@ -102,7 +280,30 @@ function ConvertTo-StandardizedDriverModel {
$id = $RawDriverObject.MachineType $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 IsSelected = $false
Make = $Make Make = $Make
Model = $modelDisplay Model = $modelDisplay
@@ -110,12 +311,25 @@ function ConvertTo-StandardizedDriverModel {
Id = $id Id = $id
ProductName = $productName ProductName = $productName
MachineType = $machineType MachineType = $machineType
Version = "" # Placeholder Version = ""
Type = "" # Placeholder Type = ""
Size = "" # Placeholder Size = ""
Arch = "" # Placeholder Arch = ""
DownloadStatus = "" # Initial download status 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 # Function to filter the driver model list based on text input
@@ -159,6 +373,7 @@ function Search-DriverModels {
} }
# The view will automatically refresh. No need to call .Refresh() explicitly for filtering. # The view will automatically refresh. No need to call .Refresh() explicitly for filtering.
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
$filteredCount = 0 $filteredCount = 0
if ($null -ne $collectionView) { if ($null -ne $collectionView) {
foreach ($item in $collectionView) { $filteredCount++ } foreach ($item in $collectionView) { $filteredCount++ }
@@ -173,7 +388,10 @@ function Save-DriversJson {
[psobject]$State [psobject]$State
) )
WriteLog "Save-DriversJson function called." WriteLog "Save-DriversJson function called."
$selectedDrivers = @($State.Controls.lstDriverModels.Items | Where-Object { $_.IsSelected })
# Save from the master model list so filtered-out selected rows are preserved.
$driverSelectionSource = if ($null -ne $State.Data.allDriverModels) { $State.Data.allDriverModels } else { $State.Controls.lstDriverModels.Items }
$selectedDrivers = @($driverSelectionSource | Where-Object { $_.IsSelected })
if (-not $selectedDrivers) { if (-not $selectedDrivers) {
[System.Windows.MessageBox]::Show("No drivers selected to save.", "Save Drivers", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information) [System.Windows.MessageBox]::Show("No drivers selected to save.", "Save Drivers", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
@@ -188,35 +406,7 @@ function Save-DriversJson {
$modelsForThisMake = @() # Initialize an array to hold model objects $modelsForThisMake = @() # Initialize an array to hold model objects
foreach ($driverItem in $_.Group) { foreach ($driverItem in $_.Group) {
$modelObject = $null $modelObject = Convert-DriverItemToJsonModel -DriverItem $driverItem
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."
}
}
if ($null -ne $modelObject) { if ($null -ne $modelObject) {
$modelsForThisMake += $modelObject $modelsForThisMake += $modelObject
} }
@@ -307,11 +497,97 @@ function Import-DriversJson {
continue 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 $existingModel = $State.Data.allDriverModels | Where-Object { $_.Make -eq $makeName -and $_.Model -eq $importedModelNameFromObject } | Select-Object -First 1
if ($null -ne $existingModel) { if ($null -ne $existingModel) {
$existingModel.IsSelected = $true $existingModel.IsSelected = $true
$existingModel.DownloadStatus = "Imported" $existingModel.DownloadStatus = "Imported"
$existingModel.Model = $importedModelNameFromObject
if ($makeName -eq 'Microsoft' -and $importedModelObject.PSObject.Properties['Link']) { if ($makeName -eq 'Microsoft' -and $importedModelObject.PSObject.Properties['Link']) {
if ($existingModel.Link -ne $importedModelObject.Link) { if ($existingModel.Link -ne $importedModelObject.Link) {
@@ -327,13 +603,36 @@ function Import-DriversJson {
} }
if ($importedModelObject.PSObject.Properties['MachineType'] -and $existingModel.PSObject.Properties['MachineType'] -and $existingModel.MachineType -ne $importedModelObject.MachineType) { if ($importedModelObject.PSObject.Properties['MachineType'] -and $existingModel.PSObject.Properties['MachineType'] -and $existingModel.MachineType -ne $importedModelObject.MachineType) {
$existingModel.MachineType = $importedModelObject.MachineType $existingModel.MachineType = $importedModelObject.MachineType
$existingModel.Id = $importedModelObject.MachineType # Update Id as well $existingModel.Id = $importedModelObject.MachineType
$updateExistingLenovo = $true $updateExistingLenovo = $true
} }
if ($updateExistingLenovo) { if ($updateExistingLenovo) {
WriteLog "Import-DriversJson: Updated ProductName/MachineType/Id for existing Lenovo model '$($existingModel.Model)'." 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++ $existingModelsUpdated++
WriteLog "Import-DriversJson: Marked existing model '$($existingModel.Make) - $($existingModel.Model)' as imported." WriteLog "Import-DriversJson: Marked existing model '$($existingModel.Make) - $($existingModel.Model)' as imported."
} }
@@ -370,7 +669,7 @@ function Import-DriversJson {
$newDriverModel = [PSCustomObject]@{ $newDriverModel = [PSCustomObject]@{
IsSelected = $true IsSelected = $true
Make = $makeName Make = $makeName
Model = $importedModelNameFromObject # Full display name Model = $importedModelNameFromObject
Link = $importedLink Link = $importedLink
Id = $importedId Id = $importedId
ProductName = $importedProductName ProductName = $importedProductName
@@ -381,6 +680,20 @@ function Import-DriversJson {
Arch = "" Arch = ""
DownloadStatus = "Imported" 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) $State.Data.allDriverModels.Add($newDriverModel)
$newModelsAdded++ $newModelsAdded++
WriteLog "Import-DriversJson: Added new model '$($newDriverModel.Make) - $($newDriverModel.Model)' from import. ID: $($newDriverModel.Id), Link: $($newDriverModel.Link)" WriteLog "Import-DriversJson: Added new model '$($newDriverModel.Make) - $($newDriverModel.Model)' from import. ID: $($newDriverModel.Id), Link: $($newDriverModel.Link)"
@@ -403,6 +716,7 @@ function Import-DriversJson {
# Update the UI and apply any existing filter # Update the UI and apply any existing filter
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels $State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
Search-DriverModels -filterText $State.Controls.txtModelFilter.Text -State $State Search-DriverModels -filterText $State.Controls.txtModelFilter.Text -State $State
$message = "Driver import complete.`nNew models added: $newModelsAdded`nExisting models updated: $existingModelsUpdated" $message = "Driver import complete.`nNew models added: $newModelsAdded`nExisting models updated: $existingModelsUpdated"
@@ -474,6 +788,7 @@ function Invoke-GetModels {
# Update the UI ItemsSource to point to the new list and clear the filter # Update the UI ItemsSource to point to the new list and clear the filter
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels $State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
$State.Controls.txtModelFilter.Text = "" $State.Controls.txtModelFilter.Text = ""
if ($State.Data.allDriverModels.Count -gt 0) { if ($State.Data.allDriverModels.Count -gt 0) {
@@ -565,12 +880,20 @@ function Invoke-DownloadSelectedDrivers {
$localDriversFolder = $State.Controls.txtDriversFolder.Text $localDriversFolder = $State.Controls.txtDriversFolder.Text
$localWindowsRelease = $State.Controls.cmbWindowsRelease.SelectedItem.Value $localWindowsRelease = $State.Controls.cmbWindowsRelease.SelectedItem.Value
$localWindowsReleaseDisplay = $State.Controls.cmbWindowsRelease.SelectedItem.Display
$localWindowsSku = if ($null -ne $State.Controls.cmbWindowsSKU.SelectedItem) { [string]$State.Controls.cmbWindowsSKU.SelectedItem } else { $null }
$localDriverWindowsRelease = Get-EffectiveDriverWindowsRelease -WindowsRelease $localWindowsRelease -WindowsReleaseDisplay $localWindowsReleaseDisplay -WindowsSku $localWindowsSku
if ($localDriverWindowsRelease -ne $localWindowsRelease) {
WriteLog "Normalized WindowsRelease for driver download from $localWindowsRelease to $localDriverWindowsRelease (Display='$localWindowsReleaseDisplay', SKU='$localWindowsSku')."
}
$localWindowsArch = $State.Controls.cmbWindowsArch.SelectedItem $localWindowsArch = $State.Controls.cmbWindowsArch.SelectedItem
$localWindowsVersion = if ($null -ne $State.Controls.cmbWindowsVersion -and $null -ne $State.Controls.cmbWindowsVersion.SelectedItem) { $State.Controls.cmbWindowsVersion.SelectedItem } else { $null } $localWindowsVersion = if ($null -ne $State.Controls.cmbWindowsVersion -and $null -ne $State.Controls.cmbWindowsVersion.SelectedItem) { $State.Controls.cmbWindowsVersion.SelectedItem } else { $null }
$coreStaticVars = Get-CoreStaticVariables $coreStaticVars = Get-CoreStaticVariables
$localHeaders = $coreStaticVars.Headers $localHeaders = $coreStaticVars.Headers
$localUserAgent = $coreStaticVars.UserAgent $localUserAgent = $coreStaticVars.UserAgent
$compressDrivers = $State.Controls.chkCompressDriversToWIM.IsChecked $compressDrivers = $State.Controls.chkCompressDriversToWIM.IsChecked
# Determine if we must preserve source folders (used later for PE driver harvesting)
$preserveSource = ($State.Controls.chkUseDriversAsPEDrivers.IsChecked -and $State.Controls.chkCompressDriversToWIM.IsChecked)
$State.Controls.txtStatus.Text = "Processing all selected drivers..." $State.Controls.txtStatus.Text = "Processing all selected drivers..."
WriteLog "Processing all selected drivers: $($selectedDrivers.Model -join ', ')" WriteLog "Processing all selected drivers: $($selectedDrivers.Model -join ', ')"
@@ -580,10 +903,10 @@ function Invoke-DownloadSelectedDrivers {
WriteLog "Dell drivers selected. Ensuring Dell Catalog is up-to-date..." WriteLog "Dell drivers selected. Ensuring Dell Catalog is up-to-date..."
try { try {
$dellDriversFolder = Join-Path -Path $localDriversFolder -ChildPath "Dell" $dellDriversFolder = Join-Path -Path $localDriversFolder -ChildPath "Dell"
$catalogBaseName = if ($localWindowsRelease -le 11) { "CatalogPC" } else { "Catalog" } $catalogBaseName = if ($localDriverWindowsRelease -le 11) { "CatalogIndexPC" } else { "Catalog" }
$dellCabFile = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).cab" $dellCabFile = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).cab"
$dellCatalogXML = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).xml" $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 ($localDriverWindowsRelease -le 11) { "https://downloads.dell.com/catalog/CatalogIndexPC.cab" } else { "https://downloads.dell.com/catalog/Catalog.cab" }
$downloadCatalog = $true $downloadCatalog = $true
if (Test-Path -Path $dellCatalogXML -PathType Leaf) { if (Test-Path -Path $dellCatalogXML -PathType Leaf) {
@@ -620,15 +943,16 @@ function Invoke-DownloadSelectedDrivers {
return return
} }
} }
$preserveSource = ($State.Controls.chkUseDriversAsPEDrivers.IsChecked -and $State.Controls.chkCompressDriversToWIM.IsChecked)
$taskArguments = @{ $taskArguments = @{
DriversFolder = $localDriversFolder DriversFolder = $localDriversFolder
WindowsRelease = $localWindowsRelease WindowsRelease = $localDriverWindowsRelease
WindowsArch = $localWindowsArch WindowsArch = $localWindowsArch
WindowsVersion = $localWindowsVersion WindowsVersion = $localWindowsVersion
Headers = $localHeaders Headers = $localHeaders
UserAgent = $localUserAgent UserAgent = $localUserAgent
CompressToWim = $compressDrivers CompressToWim = $compressDrivers
PreserveSourceOnCompress = $preserveSource
} }
$parallelResults = Invoke-ParallelProcessing -ItemsToProcess $selectedDrivers ` $parallelResults = Invoke-ParallelProcessing -ItemsToProcess $selectedDrivers `
@@ -645,13 +969,17 @@ function Invoke-DownloadSelectedDrivers {
$overallSuccess = $true $overallSuccess = $true
$successfullyDownloaded = [System.Collections.Generic.List[PSCustomObject]]::new() $successfullyDownloaded = [System.Collections.Generic.List[PSCustomObject]]::new()
$failedDownloads = [System.Collections.Generic.List[PSCustomObject]]::new()
# Check the results from the parallel processing tasks # Check the results from the parallel processing tasks
if ($null -ne $parallelResults) { if ($null -ne $parallelResults) {
# Create a lookup from the original selected drivers to get the 'Make' property, # Create a lookup from the original selected drivers to retain full metadata for mapping.
# as the result object might only have 'Identifier' or 'Model'. $driverLookup = @{}
$makeLookup = @{} foreach ($driver in $selectedDrivers) {
$selectedDrivers | ForEach-Object { $makeLookup[$_.Model] = $_.Make } if (-not [string]::IsNullOrWhiteSpace($driver.Model)) {
$driverLookup[$driver.Model] = $driver
}
}
# Filter for objects that could be results, avoiding stray log strings # Filter for objects that could be results, avoiding stray log strings
foreach ($result in ($parallelResults | Where-Object { $_ -is [hashtable] })) { foreach ($result in ($parallelResults | Where-Object { $_ -is [hashtable] })) {
@@ -666,27 +994,66 @@ function Invoke-DownloadSelectedDrivers {
if ([string]::IsNullOrWhiteSpace($modelName)) { if ([string]::IsNullOrWhiteSpace($modelName)) {
WriteLog "Could not determine model name from result object: $($result | ConvertTo-Json -Compress -Depth 3)" WriteLog "Could not determine model name from result object: $($result | ConvertTo-Json -Compress -Depth 3)"
$overallSuccess = $false $overallSuccess = $false
$failedDownloads.Add([PSCustomObject]@{
Model = 'Unknown model'
Status = 'Driver task returned without a model identifier.'
})
continue continue
} }
if ($resultCode -ne 0) { if ($resultCode -ne 0) {
$overallSuccess = $false $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." WriteLog "Error detected for model $modelName."
} }
elseif (-not [string]::IsNullOrWhiteSpace($driverPath)) { elseif (-not [string]::IsNullOrWhiteSpace($driverPath)) {
# The task was successful and returned a driver path. # The task was successful and returned a driver path.
$make = $makeLookup[$modelName] $driverMetadata = $null
if ($make) { if (-not [string]::IsNullOrWhiteSpace($modelName) -and $driverLookup.ContainsKey($modelName)) {
$successfullyDownloaded.Add([PSCustomObject]@{ $driverMetadata = $driverLookup[$modelName]
Make = $make }
Model = $modelName
DriverPath = $driverPath if ($driverMetadata) {
}) $driverRecord = [PSCustomObject]@{
Make = $driverMetadata.Make
Model = $modelName
DriverPath = $driverPath
}
if ($driverMetadata.PSObject.Properties['Link'] -and -not [string]::IsNullOrWhiteSpace($driverMetadata.Link)) {
$driverRecord | Add-Member -NotePropertyName Link -NotePropertyValue $driverMetadata.Link
}
if ($driverMetadata.PSObject.Properties['SystemId'] -and -not [string]::IsNullOrWhiteSpace($driverMetadata.SystemId)) {
$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 { else {
WriteLog "Warning: Could not find 'Make' for successful download of model '$modelName'. Skipping from DriverMapping.json." WriteLog "Warning: Could not find driver metadata for successful download of model '$modelName'. Skipping from DriverMapping.json."
} }
} }
else {
$overallSuccess = $false
$fallbackStatus = $result['Status']
if ([string]::IsNullOrWhiteSpace($fallbackStatus)) { $fallbackStatus = 'Driver download did not return a driver path.' }
$failedDownloads.Add([PSCustomObject]@{
Model = $modelName
Status = $fallbackStatus
})
WriteLog "Driver download did not provide a path for model $modelName."
}
} }
} }
@@ -714,43 +1081,17 @@ function Invoke-DownloadSelectedDrivers {
$makeName = $_.Name $makeName = $_.Name
$modelsForThisMake = @() # Initialize an array to hold model objects $modelsForThisMake = @() # Initialize an array to hold model objects
foreach ($driverItem in $_.Group) { foreach ($driverItem in $_.Group) {
$modelObject = $null $modelObject = Convert-DriverItemToJsonModel -DriverItem $driverItem
switch ($makeName) { if ($null -ne $modelObject) {
'Microsoft' { $modelsForThisMake += $modelObject
$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."
} }
} }
if ($null -ne $modelObject) {
$modelsForThisMake += $modelObject if ($modelsForThisMake.Count -gt 0) {
$outputJson[$makeName] = @{ Models = $modelsForThisMake }
} }
} }
# Add the models array to the make-specific object
$outputJson[$makeName] = @{ Models = $modelsForThisMake }
}
# Ensure directory exists # Ensure directory exists
$parentDir = Split-Path -Path $driversJsonPath -Parent $parentDir = Split-Path -Path $driversJsonPath -Parent
@@ -775,8 +1116,21 @@ function Invoke-DownloadSelectedDrivers {
[System.Windows.MessageBox]::Show("All selected driver downloads processed. Check status column for details.", "Download Process Finished", "OK", "Information") [System.Windows.MessageBox]::Show("All selected driver downloads processed. Check status column for details.", "Download Process Finished", "OK", "Information")
} }
else { else {
$State.Controls.txtStatus.Text = "Driver downloads processed with some errors. Check status column and log." $State.Controls.txtStatus.Text = "Driver download failed. Resolve the errors and try again."
[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") $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")
} }
} }
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,97 @@
This module is critical for setting up the initial state of the application window when it first loads. This module is critical for setting up the initial state of the application window when it first loads.
#> #>
function Initialize-FluentTheme {
param(
[Parameter(Mandatory = $true)]
[System.Windows.Window]$Window,
[Parameter(Mandatory = $false)]
[string]$ThemeMode = "System",
[Parameter(Mandatory = $false)]
[PSCustomObject]$State
)
# Check if the current .NET runtime supports Window.ThemeMode (requires .NET 9+ / PowerShell 7.5+)
$themeModeProperty = [System.Windows.Window].GetProperty("ThemeMode")
if ($null -eq $themeModeProperty) {
WriteLog "Fluent theme not available. Window.ThemeMode requires PowerShell 7.5+ (.NET 9+). Using default Aero2 theme."
if ($null -ne $State) {
$State.Flags.isFluentSupported = $false
}
# Still create tooltip styles for non-Fluent mode so Tag-to-ToolTip binding works
$controlTypes = @(
[System.Windows.Controls.TextBox],
[System.Windows.Controls.TextBlock],
[System.Windows.Controls.CheckBox]
)
foreach ($controlType in $controlTypes) {
$newStyle = New-Object System.Windows.Style($controlType)
$toolTipBinding = New-Object System.Windows.Data.Binding("Tag")
$toolTipBinding.RelativeSource = [System.Windows.Data.RelativeSource]::new([System.Windows.Data.RelativeSourceMode]::Self)
$toolTipSetter = New-Object System.Windows.Setter([System.Windows.FrameworkElement]::ToolTipProperty, $toolTipBinding)
$newStyle.Setters.Add($toolTipSetter)
if ($Window.Resources.Contains($controlType)) {
$Window.Resources.Remove($controlType)
}
$Window.Resources.Add($controlType, $newStyle)
}
WriteLog "Tooltip styles created for non-Fluent mode."
return
}
# Mark Fluent as supported in state
if ($null -ne $State) {
$State.Flags.isFluentSupported = $true
}
# Resolve the ThemeMode enum value using reflection to avoid compile-time experimental attribute issues
$themeModeType = [System.Windows.Window].GetProperty("ThemeMode").PropertyType
$themeModeValue = $null
switch ($ThemeMode) {
"Light" { $themeModeValue = $themeModeType::Light }
"Dark" { $themeModeValue = $themeModeType::Dark }
"System" { $themeModeValue = $themeModeType::System }
default { $themeModeValue = $themeModeType::System }
}
# Apply the Fluent theme mode to the window
$themeModeProperty.SetValue($Window, $themeModeValue)
WriteLog "Applied Fluent theme: $ThemeMode"
# Re-create implicit tooltip styles with BasedOn pointing to the Fluent base style
# This preserves the Tag-to-ToolTip binding while inheriting Fluent visual styling
$controlTypes = @(
[System.Windows.Controls.TextBox],
[System.Windows.Controls.TextBlock],
[System.Windows.Controls.CheckBox]
)
foreach ($controlType in $controlTypes) {
# Get the Fluent base style that was loaded by ThemeMode
$fluentBaseStyle = $Window.TryFindResource($controlType)
# Create a new implicit style with ToolTip binding
$newStyle = New-Object System.Windows.Style($controlType)
if ($null -ne $fluentBaseStyle) {
$newStyle.BasedOn = $fluentBaseStyle
}
# Add the ToolTip setter that binds to the Tag property
$toolTipBinding = New-Object System.Windows.Data.Binding("Tag")
$toolTipBinding.RelativeSource = [System.Windows.Data.RelativeSource]::new([System.Windows.Data.RelativeSourceMode]::Self)
$toolTipSetter = New-Object System.Windows.Setter([System.Windows.FrameworkElement]::ToolTipProperty, $toolTipBinding)
$newStyle.Setters.Add($toolTipSetter)
# Remove any existing implicit style for this type before adding the new one
if ($Window.Resources.Contains($controlType)) {
$Window.Resources.Remove($controlType)
}
$Window.Resources.Add($controlType, $newStyle)
}
WriteLog "Tooltip styles updated with Fluent base styles."
}
function Initialize-UIControls { function Initialize-UIControls {
param([PSCustomObject]$State) param([PSCustomObject]$State)
WriteLog "Initializing UI control references..." WriteLog "Initializing UI control references..."
@@ -21,6 +112,9 @@ function Initialize-UIControls {
$State.Controls.cmbWindowsRelease = $window.FindName('cmbWindowsRelease') $State.Controls.cmbWindowsRelease = $window.FindName('cmbWindowsRelease')
$State.Controls.cmbWindowsVersion = $window.FindName('cmbWindowsVersion') $State.Controls.cmbWindowsVersion = $window.FindName('cmbWindowsVersion')
$State.Controls.txtISOPath = $window.FindName('txtISOPath') $State.Controls.txtISOPath = $window.FindName('txtISOPath')
$State.Controls.rbDownloadESD = $window.FindName('rbDownloadESD')
$State.Controls.rbProvideISO = $window.FindName('rbProvideISO')
$State.Controls.isoPathPanel = $window.FindName('isoPathPanel')
$State.Controls.btnBrowseISO = $window.FindName('btnBrowseISO') $State.Controls.btnBrowseISO = $window.FindName('btnBrowseISO')
$State.Controls.cmbWindowsArch = $window.FindName('cmbWindowsArch') $State.Controls.cmbWindowsArch = $window.FindName('cmbWindowsArch')
$State.Controls.cmbWindowsLang = $window.FindName('cmbWindowsLang') $State.Controls.cmbWindowsLang = $window.FindName('cmbWindowsLang')
@@ -59,6 +153,10 @@ function Initialize-UIControls {
$State.Controls.usbSelectionPanel = $window.FindName('usbDriveSelectionPanel') $State.Controls.usbSelectionPanel = $window.FindName('usbDriveSelectionPanel')
$State.Controls.chkAllowExternalHardDiskMedia = $window.FindName('chkAllowExternalHardDiskMedia') $State.Controls.chkAllowExternalHardDiskMedia = $window.FindName('chkAllowExternalHardDiskMedia')
$State.Controls.chkPromptExternalHardDiskMedia = $window.FindName('chkPromptExternalHardDiskMedia') $State.Controls.chkPromptExternalHardDiskMedia = $window.FindName('chkPromptExternalHardDiskMedia')
$State.Controls.chkCopyAdditionalFFUFiles = $window.FindName('chkCopyAdditionalFFUFiles')
$State.Controls.additionalFFUPanel = $window.FindName('additionalFFUPanel')
$State.Controls.lstAdditionalFFUs = $window.FindName('lstAdditionalFFUs')
$State.Controls.btnRefreshAdditionalFFUs = $window.FindName('btnRefreshAdditionalFFUs')
$State.Controls.chkInstallWingetApps = $window.FindName('chkInstallWingetApps') $State.Controls.chkInstallWingetApps = $window.FindName('chkInstallWingetApps')
$State.Controls.wingetPanel = $window.FindName('wingetPanel') $State.Controls.wingetPanel = $window.FindName('wingetPanel')
$State.Controls.btnCheckWingetModule = $window.FindName('btnCheckWingetModule') $State.Controls.btnCheckWingetModule = $window.FindName('btnCheckWingetModule')
@@ -66,8 +164,10 @@ function Initialize-UIControls {
$State.Controls.txtWingetModuleVersion = $window.FindName('txtWingetModuleVersion') $State.Controls.txtWingetModuleVersion = $window.FindName('txtWingetModuleVersion')
$State.Controls.applicationPathPanel = $window.FindName('applicationPathPanel') $State.Controls.applicationPathPanel = $window.FindName('applicationPathPanel')
$State.Controls.appListJsonPathPanel = $window.FindName('appListJsonPathPanel') $State.Controls.appListJsonPathPanel = $window.FindName('appListJsonPathPanel')
$State.Controls.userAppListPathPanel = $window.FindName('userAppListPathPanel')
$State.Controls.btnBrowseApplicationPath = $window.FindName('btnBrowseApplicationPath') $State.Controls.btnBrowseApplicationPath = $window.FindName('btnBrowseApplicationPath')
$State.Controls.btnBrowseAppListJsonPath = $window.FindName('btnBrowseAppListJsonPath') $State.Controls.btnBrowseAppListJsonPath = $window.FindName('btnBrowseAppListJsonPath')
$State.Controls.btnBrowseUserAppListPath = $window.FindName('btnBrowseUserAppListPath')
$State.Controls.chkBringYourOwnApps = $window.FindName('chkBringYourOwnApps') $State.Controls.chkBringYourOwnApps = $window.FindName('chkBringYourOwnApps')
$State.Controls.byoApplicationPanel = $window.FindName('byoApplicationPanel') $State.Controls.byoApplicationPanel = $window.FindName('byoApplicationPanel')
$State.Controls.wingetSearchPanel = $window.FindName('wingetSearchPanel') $State.Controls.wingetSearchPanel = $window.FindName('wingetSearchPanel')
@@ -105,31 +205,51 @@ function Initialize-UIControls {
$State.Controls.txtStatus = $window.FindName('txtStatus') $State.Controls.txtStatus = $window.FindName('txtStatus')
$State.Controls.pbOverallProgress = $window.FindName('progressBar') $State.Controls.pbOverallProgress = $window.FindName('progressBar')
$State.Controls.txtOverallStatus = $window.FindName('txtStatus') $State.Controls.txtOverallStatus = $window.FindName('txtStatus')
$State.Controls.chkEnableVMNetworking = $window.FindName('chkEnableVMNetworking')
$State.Controls.spVMNetworkingSettings = $window.FindName('spVMNetworkingSettings')
$State.Controls.cmbVMSwitchName = $window.FindName('cmbVMSwitchName') $State.Controls.cmbVMSwitchName = $window.FindName('cmbVMSwitchName')
$State.Controls.txtVMHostIPAddress = $window.FindName('txtVMHostIPAddress')
$State.Controls.txtCustomVMSwitchName = $window.FindName('txtCustomVMSwitchName') $State.Controls.txtCustomVMSwitchName = $window.FindName('txtCustomVMSwitchName')
$State.Controls.txtFFUDevPath = $window.FindName('txtFFUDevPath') $State.Controls.txtFFUDevPath = $window.FindName('txtFFUDevPath')
$State.Controls.txtCustomFFUNameTemplate = $window.FindName('txtCustomFFUNameTemplate') $State.Controls.txtCustomFFUNameTemplate = $window.FindName('txtCustomFFUNameTemplate')
$State.Controls.txtFFUCaptureLocation = $window.FindName('txtFFUCaptureLocation') $State.Controls.txtFFUCaptureLocation = $window.FindName('txtFFUCaptureLocation')
$State.Controls.txtShareName = $window.FindName('txtShareName')
$State.Controls.txtUsername = $window.FindName('txtUsername')
$State.Controls.txtThreads = $window.FindName('txtThreads') $State.Controls.txtThreads = $window.FindName('txtThreads')
$State.Controls.cmbBitsPriority = $window.FindName('cmbBitsPriority')
$State.Controls.txtMaxUSBDrives = $window.FindName('txtMaxUSBDrives') $State.Controls.txtMaxUSBDrives = $window.FindName('txtMaxUSBDrives')
$State.Controls.chkCompactOS = $window.FindName('chkCompactOS') $State.Controls.chkCompactOS = $window.FindName('chkCompactOS')
$State.Controls.chkOptimize = $window.FindName('chkOptimize') $State.Controls.chkOptimize = $window.FindName('chkOptimize')
$State.Controls.chkAllowVHDXCaching = $window.FindName('chkAllowVHDXCaching') $State.Controls.chkAllowVHDXCaching = $window.FindName('chkAllowVHDXCaching')
$State.Controls.chkCreateCaptureMedia = $window.FindName('chkCreateCaptureMedia')
$State.Controls.chkCreateDeploymentMedia = $window.FindName('chkCreateDeploymentMedia') $State.Controls.chkCreateDeploymentMedia = $window.FindName('chkCreateDeploymentMedia')
$State.Controls.chkInjectUnattend = $window.FindName('chkInjectUnattend') $State.Controls.chkInjectUnattend = $window.FindName('chkInjectUnattend')
$State.Controls.txtUnattendX64FilePath = $window.FindName('txtUnattendX64FilePath')
$State.Controls.btnBrowseUnattendX64FilePath = $window.FindName('btnBrowseUnattendX64FilePath')
$State.Controls.txtUnattendArm64FilePath = $window.FindName('txtUnattendArm64FilePath')
$State.Controls.btnBrowseUnattendArm64FilePath = $window.FindName('btnBrowseUnattendArm64FilePath')
$State.Controls.rbDeviceNamingNone = $window.FindName('rbDeviceNamingNone')
$State.Controls.rbDeviceNamingPrompt = $window.FindName('rbDeviceNamingPrompt')
$State.Controls.rbDeviceNamingTemplate = $window.FindName('rbDeviceNamingTemplate')
$State.Controls.rbDeviceNamingPrefixes = $window.FindName('rbDeviceNamingPrefixes')
$State.Controls.rbDeviceNamingSerialComputerNames = $window.FindName('rbDeviceNamingSerialComputerNames')
$State.Controls.deviceNameTemplatePanel = $window.FindName('deviceNameTemplatePanel')
$State.Controls.deviceNamePrefixesPanel = $window.FindName('deviceNamePrefixesPanel')
$State.Controls.deviceNameSerialComputerNamesPanel = $window.FindName('deviceNameSerialComputerNamesPanel')
$State.Controls.txtDeviceNameTemplate = $window.FindName('txtDeviceNameTemplate')
$State.Controls.txtDeviceNamePrefixesPath = $window.FindName('txtDeviceNamePrefixesPath')
$State.Controls.btnBrowseDeviceNamePrefixesPath = $window.FindName('btnBrowseDeviceNamePrefixesPath')
$State.Controls.txtDeviceNamePrefixes = $window.FindName('txtDeviceNamePrefixes')
$State.Controls.btnSaveDeviceNamePrefixes = $window.FindName('btnSaveDeviceNamePrefixes')
$State.Controls.txtDeviceNameSerialComputerNamesPath = $window.FindName('txtDeviceNameSerialComputerNamesPath')
$State.Controls.btnBrowseDeviceNameSerialComputerNamesPath = $window.FindName('btnBrowseDeviceNameSerialComputerNamesPath')
$State.Controls.txtDeviceNameSerialComputerNames = $window.FindName('txtDeviceNameSerialComputerNames')
$State.Controls.btnSaveDeviceNameSerialComputerNames = $window.FindName('btnSaveDeviceNameSerialComputerNames')
$State.Controls.chkVerbose = $window.FindName('chkVerbose') $State.Controls.chkVerbose = $window.FindName('chkVerbose')
$State.Controls.chkCopyAutopilot = $window.FindName('chkCopyAutopilot') $State.Controls.chkCopyAutopilot = $window.FindName('chkCopyAutopilot')
$State.Controls.chkCopyUnattend = $window.FindName('chkCopyUnattend') $State.Controls.chkCopyUnattend = $window.FindName('chkCopyUnattend')
$State.Controls.chkCopyPPKG = $window.FindName('chkCopyPPKG') $State.Controls.chkCopyPPKG = $window.FindName('chkCopyPPKG')
$State.Controls.chkCleanupAppsISO = $window.FindName('chkCleanupAppsISO') $State.Controls.chkCleanupAppsISO = $window.FindName('chkCleanupAppsISO')
$State.Controls.chkCleanupCaptureISO = $window.FindName('chkCleanupCaptureISO')
$State.Controls.chkCleanupDeployISO = $window.FindName('chkCleanupDeployISO') $State.Controls.chkCleanupDeployISO = $window.FindName('chkCleanupDeployISO')
$State.Controls.chkCleanupDrivers = $window.FindName('chkCleanupDrivers') $State.Controls.chkCleanupDrivers = $window.FindName('chkCleanupDrivers')
$State.Controls.chkRemoveFFU = $window.FindName('chkRemoveFFU') $State.Controls.chkRemoveFFU = $window.FindName('chkRemoveFFU')
$State.Controls.chkRemoveDownloadedESD = $window.FindName('chkRemoveDownloadedESD')
$State.Controls.txtDiskSize = $window.FindName('txtDiskSize') $State.Controls.txtDiskSize = $window.FindName('txtDiskSize')
$State.Controls.txtMemory = $window.FindName('txtMemory') $State.Controls.txtMemory = $window.FindName('txtMemory')
$State.Controls.txtProcessors = $window.FindName('txtProcessors') $State.Controls.txtProcessors = $window.FindName('txtProcessors')
@@ -143,6 +263,7 @@ function Initialize-UIControls {
$State.Controls.txtDriversFolder = $window.FindName('txtDriversFolder') $State.Controls.txtDriversFolder = $window.FindName('txtDriversFolder')
$State.Controls.txtPEDriversFolder = $window.FindName('txtPEDriversFolder') $State.Controls.txtPEDriversFolder = $window.FindName('txtPEDriversFolder')
$State.Controls.chkCopyPEDrivers = $window.FindName('chkCopyPEDrivers') $State.Controls.chkCopyPEDrivers = $window.FindName('chkCopyPEDrivers')
$State.Controls.chkUseDriversAsPEDrivers = $window.FindName('chkUseDriversAsPEDrivers')
$State.Controls.chkUpdateLatestCU = $window.FindName('chkUpdateLatestCU') $State.Controls.chkUpdateLatestCU = $window.FindName('chkUpdateLatestCU')
$State.Controls.chkUpdateLatestNet = $window.FindName('chkUpdateLatestNet') $State.Controls.chkUpdateLatestNet = $window.FindName('chkUpdateLatestNet')
$State.Controls.chkUpdateLatestDefender = $window.FindName('chkUpdateLatestDefender') $State.Controls.chkUpdateLatestDefender = $window.FindName('chkUpdateLatestDefender')
@@ -152,6 +273,7 @@ function Initialize-UIControls {
$State.Controls.chkUpdatePreviewCU = $window.FindName('chkUpdatePreviewCU') $State.Controls.chkUpdatePreviewCU = $window.FindName('chkUpdatePreviewCU')
$State.Controls.txtApplicationPath = $window.FindName('txtApplicationPath') $State.Controls.txtApplicationPath = $window.FindName('txtApplicationPath')
$State.Controls.txtAppListJsonPath = $window.FindName('txtAppListJsonPath') $State.Controls.txtAppListJsonPath = $window.FindName('txtAppListJsonPath')
$State.Controls.txtUserAppListPath = $window.FindName('txtUserAppListPath')
$State.Controls.chkInstallDrivers = $window.FindName('chkInstallDrivers') $State.Controls.chkInstallDrivers = $window.FindName('chkInstallDrivers')
$State.Controls.chkCopyDrivers = $window.FindName('chkCopyDrivers') $State.Controls.chkCopyDrivers = $window.FindName('chkCopyDrivers')
$State.Controls.chkCompressDriversToWIM = $window.FindName('chkCompressDriversToWIM') $State.Controls.chkCompressDriversToWIM = $window.FindName('chkCompressDriversToWIM')
@@ -170,13 +292,62 @@ function Initialize-UIControls {
$State.Controls.btnBrowseDriversJsonPath = $window.FindName('btnBrowseDriversJsonPath') $State.Controls.btnBrowseDriversJsonPath = $window.FindName('btnBrowseDriversJsonPath')
$State.Controls.chkUpdateADK = $window.FindName('chkUpdateADK') $State.Controls.chkUpdateADK = $window.FindName('chkUpdateADK')
$State.Controls.btnLoadConfig = $window.FindName('btnLoadConfig') $State.Controls.btnLoadConfig = $window.FindName('btnLoadConfig')
$State.Controls.btnRestoreDefaults = $window.FindName('btnRestoreDefaults')
$State.Controls.btnBuildConfig = $window.FindName('btnBuildConfig') $State.Controls.btnBuildConfig = $window.FindName('btnBuildConfig')
# Monitor Tab # Home page
$State.Controls.MainTabControl = $window.FindName('MainTabControl') $State.Controls.txtHomeCurrentBuildValue = $window.FindName('txtHomeCurrentBuildValue')
$State.Controls.MonitorTab = $window.FindName('MonitorTab') $State.Controls.txtHomeLatestReleaseValue = $window.FindName('txtHomeLatestReleaseValue')
$State.Controls.txtHomeReleaseStatusValue = $window.FindName('txtHomeReleaseStatusValue')
$State.Controls.spHomeReleaseNotesSections = $window.FindName('spHomeReleaseNotesSections')
$State.Controls.ellipseHomeDiskSpaceStatus = $window.FindName('ellipseHomeDiskSpaceStatus')
$State.Controls.txtHomeDiskSpaceStatusValue = $window.FindName('txtHomeDiskSpaceStatusValue')
$State.Controls.ellipseHomeHyperVStatus = $window.FindName('ellipseHomeHyperVStatus')
$State.Controls.txtHomeHyperVStatusValue = $window.FindName('txtHomeHyperVStatusValue')
$State.Controls.txtHomeDiscussionsStatusValue = $window.FindName('txtHomeDiscussionsStatusValue')
$State.Controls.tbDiscussion1 = $window.FindName('tbDiscussion1')
$State.Controls.linkDiscussion1 = $window.FindName('linkDiscussion1')
$State.Controls.runDiscussion1 = $window.FindName('runDiscussion1')
$State.Controls.tbDiscussion2 = $window.FindName('tbDiscussion2')
$State.Controls.linkDiscussion2 = $window.FindName('linkDiscussion2')
$State.Controls.runDiscussion2 = $window.FindName('runDiscussion2')
$State.Controls.tbDiscussion3 = $window.FindName('tbDiscussion3')
$State.Controls.linkDiscussion3 = $window.FindName('linkDiscussion3')
$State.Controls.runDiscussion3 = $window.FindName('runDiscussion3')
$State.Controls.tbDiscussion4 = $window.FindName('tbDiscussion4')
$State.Controls.linkDiscussion4 = $window.FindName('linkDiscussion4')
$State.Controls.runDiscussion4 = $window.FindName('runDiscussion4')
$State.Controls.tbDiscussion5 = $window.FindName('tbDiscussion5')
$State.Controls.linkDiscussion5 = $window.FindName('linkDiscussion5')
$State.Controls.runDiscussion5 = $window.FindName('runDiscussion5')
$State.Controls.tbDiscussionsLink = $window.FindName('tbDiscussionsLink')
$State.Controls.linkDiscussions = $window.FindName('linkDiscussions')
# Settings page
$State.Controls.cmbThemeMode = $window.FindName('cmbThemeMode')
# Shared page shell
$State.Controls.txtPageTitle = $window.FindName('txtPageTitle')
# Navigation controls
$State.Controls.lstNavigation = $window.FindName('lstNavigation')
$State.Controls.lstNavSettings = $window.FindName('lstNavSettings')
$State.Controls.lstLogOutput = $window.FindName('lstLogOutput') $State.Controls.lstLogOutput = $window.FindName('lstLogOutput')
# Content pages (for navigation visibility toggling)
$State.Controls.navigationPages = @(
$window.FindName('pageHome'),
$window.FindName('pageHyperV'),
$window.FindName('pageWindows'),
$window.FindName('pageUpdates'),
$window.FindName('pageApplications'),
$window.FindName('pageOffice'),
$window.FindName('pageDrivers'),
$window.FindName('pageBuild'),
$window.FindName('pageMonitor')
)
$State.Controls.pageSettings = $window.FindName('pageSettings')
# Initialize and bind the log data collection # Initialize and bind the log data collection
$State.Data.logData = New-Object System.Collections.ObjectModel.ObservableCollection[string] $State.Data.logData = New-Object System.Collections.ObjectModel.ObservableCollection[string]
$State.Controls.lstLogOutput.ItemsSource = $State.Data.logData $State.Controls.lstLogOutput.ItemsSource = $State.Data.logData
@@ -197,19 +368,11 @@ function Initialize-VMSwitchData {
$State.Controls.cmbVMSwitchName.Items.Add('Other') | Out-Null $State.Controls.cmbVMSwitchName.Items.Add('Other') | Out-Null
if ($State.Controls.cmbVMSwitchName.Items.Count -gt 1) { if ($State.Controls.cmbVMSwitchName.Items.Count -gt 1) {
$State.Controls.cmbVMSwitchName.SelectedIndex = 0 $State.Controls.cmbVMSwitchName.SelectedIndex = 0
$firstSwitch = $State.Controls.cmbVMSwitchName.SelectedItem
if ($State.Data.vmSwitchMap.ContainsKey($firstSwitch)) {
$State.Controls.txtVMHostIPAddress.Text = $State.Data.vmSwitchMap[$firstSwitch]
}
else {
$State.Controls.txtVMHostIPAddress.Text = $State.Defaults.generalDefaults.VMHostIPAddress # Use default if IP not found
}
$State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed' $State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
} }
else { else {
$State.Controls.cmbVMSwitchName.SelectedItem = 'Other' $State.Controls.cmbVMSwitchName.SelectedItem = 'Other'
$State.Controls.txtCustomVMSwitchName.Visibility = 'Visible' $State.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
$State.Controls.txtVMHostIPAddress.Text = $State.Defaults.generalDefaults.VMHostIPAddress # Use default
} }
} }
@@ -225,9 +388,8 @@ function Initialize-UIDefaults {
$State.Controls.txtFFUDevPath.Text = $State.FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text = $State.FFUDevelopmentPath
$State.Controls.txtCustomFFUNameTemplate.Text = $State.Defaults.generalDefaults.CustomFFUNameTemplate $State.Controls.txtCustomFFUNameTemplate.Text = $State.Defaults.generalDefaults.CustomFFUNameTemplate
$State.Controls.txtFFUCaptureLocation.Text = $State.Defaults.generalDefaults.FFUCaptureLocation $State.Controls.txtFFUCaptureLocation.Text = $State.Defaults.generalDefaults.FFUCaptureLocation
$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.txtThreads.Text = $State.Defaults.generalDefaults.Threads
$State.Controls.cmbBitsPriority.SelectedItem = $State.Defaults.generalDefaults.BitsPriority
$State.Controls.txtMaxUSBDrives.Text = $State.Defaults.generalDefaults.MaxUSBDrives $State.Controls.txtMaxUSBDrives.Text = $State.Defaults.generalDefaults.MaxUSBDrives
$State.Controls.chkBuildUSBDriveEnable.IsChecked = $State.Defaults.generalDefaults.BuildUSBDriveEnable $State.Controls.chkBuildUSBDriveEnable.IsChecked = $State.Defaults.generalDefaults.BuildUSBDriveEnable
$State.Controls.chkCompactOS.IsChecked = $State.Defaults.generalDefaults.CompactOS $State.Controls.chkCompactOS.IsChecked = $State.Defaults.generalDefaults.CompactOS
@@ -235,7 +397,8 @@ function Initialize-UIDefaults {
$State.Controls.chkOptimize.IsChecked = $State.Defaults.generalDefaults.Optimize $State.Controls.chkOptimize.IsChecked = $State.Defaults.generalDefaults.Optimize
$State.Controls.chkAllowVHDXCaching.IsChecked = $State.Defaults.generalDefaults.AllowVHDXCaching $State.Controls.chkAllowVHDXCaching.IsChecked = $State.Defaults.generalDefaults.AllowVHDXCaching
$State.Controls.chkInjectUnattend.IsChecked = $State.Defaults.generalDefaults.InjectUnattend $State.Controls.chkInjectUnattend.IsChecked = $State.Defaults.generalDefaults.InjectUnattend
$State.Controls.chkCreateCaptureMedia.IsChecked = $State.Defaults.generalDefaults.CreateCaptureMedia $State.Controls.txtUnattendX64FilePath.Text = $State.Defaults.generalDefaults.UnattendX64FilePath
$State.Controls.txtUnattendArm64FilePath.Text = $State.Defaults.generalDefaults.UnattendArm64FilePath
$State.Controls.chkCreateDeploymentMedia.IsChecked = $State.Defaults.generalDefaults.CreateDeploymentMedia $State.Controls.chkCreateDeploymentMedia.IsChecked = $State.Defaults.generalDefaults.CreateDeploymentMedia
$State.Controls.chkAllowExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.AllowExternalHardDiskMedia $State.Controls.chkAllowExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.AllowExternalHardDiskMedia
$State.Controls.chkPromptExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.PromptExternalHardDiskMedia $State.Controls.chkPromptExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.PromptExternalHardDiskMedia
@@ -243,21 +406,40 @@ function Initialize-UIDefaults {
$State.Controls.chkCopyAutopilot.IsChecked = $State.Defaults.generalDefaults.CopyAutopilot $State.Controls.chkCopyAutopilot.IsChecked = $State.Defaults.generalDefaults.CopyAutopilot
$State.Controls.chkCopyUnattend.IsChecked = $State.Defaults.generalDefaults.CopyUnattend $State.Controls.chkCopyUnattend.IsChecked = $State.Defaults.generalDefaults.CopyUnattend
$State.Controls.chkCopyPPKG.IsChecked = $State.Defaults.generalDefaults.CopyPPKG $State.Controls.chkCopyPPKG.IsChecked = $State.Defaults.generalDefaults.CopyPPKG
$defaultDeviceNamingMode = if ($State.Defaults.generalDefaults.DeviceNamingMode -in @('None', 'Prompt', 'Template', 'Prefixes', 'SerialComputerNames')) {
$State.Defaults.generalDefaults.DeviceNamingMode
}
else {
'None'
}
Set-DeviceNamingModeState -State $State -DisplayMode $defaultDeviceNamingMode -LoadedMode $null
$State.Controls.txtDeviceNameTemplate.Text = $State.Defaults.generalDefaults.DeviceNameTemplate
$State.Controls.txtDeviceNamePrefixesPath.Text = $State.Defaults.generalDefaults.DeviceNamePrefixesPath
$State.Controls.txtDeviceNamePrefixes.Text = ($State.Defaults.generalDefaults.DeviceNamePrefixes -join [System.Environment]::NewLine)
$State.Controls.txtDeviceNameSerialComputerNamesPath.Text = $State.Defaults.generalDefaults.DeviceNameSerialComputerNamesPath
$State.Controls.txtDeviceNameSerialComputerNames.Text = ($State.Defaults.generalDefaults.DeviceNameSerialComputerNames -join [System.Environment]::NewLine)
Import-DeviceNamePrefixesFromConfiguredPath -State $State
Import-SerialComputerNamesFromConfiguredPath -State $State
Update-DeviceNamingControls -State $State
$State.Controls.chkCleanupAppsISO.IsChecked = $State.Defaults.generalDefaults.CleanupAppsISO $State.Controls.chkCleanupAppsISO.IsChecked = $State.Defaults.generalDefaults.CleanupAppsISO
$State.Controls.chkCleanupCaptureISO.IsChecked = $State.Defaults.generalDefaults.CleanupCaptureISO
$State.Controls.chkCleanupDeployISO.IsChecked = $State.Defaults.generalDefaults.CleanupDeployISO $State.Controls.chkCleanupDeployISO.IsChecked = $State.Defaults.generalDefaults.CleanupDeployISO
$State.Controls.chkCleanupDrivers.IsChecked = $State.Defaults.generalDefaults.CleanupDrivers $State.Controls.chkCleanupDrivers.IsChecked = $State.Defaults.generalDefaults.CleanupDrivers
$State.Controls.chkRemoveFFU.IsChecked = $State.Defaults.generalDefaults.RemoveFFU $State.Controls.chkRemoveFFU.IsChecked = $State.Defaults.generalDefaults.RemoveFFU
$State.Controls.chkRemoveApps.IsChecked = $State.Defaults.generalDefaults.RemoveApps $State.Controls.chkRemoveApps.IsChecked = $State.Defaults.generalDefaults.RemoveApps
$State.Controls.chkRemoveUpdates.IsChecked = $State.Defaults.generalDefaults.RemoveUpdates $State.Controls.chkRemoveUpdates.IsChecked = $State.Defaults.generalDefaults.RemoveUpdates
$State.Controls.chkRemoveDownloadedESD.IsChecked = $State.Defaults.generalDefaults.RemoveDownloadedESD
$State.Controls.chkVerbose.IsChecked = $State.Defaults.generalDefaults.Verbose $State.Controls.chkVerbose.IsChecked = $State.Defaults.generalDefaults.Verbose
$State.Controls.usbSection.Visibility = if ($State.Controls.chkBuildUSBDriveEnable.IsChecked) { 'Visible' } else { 'Collapsed' }
$State.Controls.usbSelectionPanel.Visibility = if ($State.Controls.chkSelectSpecificUSBDrives.IsChecked) { 'Visible' } else { 'Collapsed' } $State.Controls.usbSelectionPanel.Visibility = if ($State.Controls.chkSelectSpecificUSBDrives.IsChecked) { 'Visible' } else { 'Collapsed' }
$State.Controls.chkSelectSpecificUSBDrives.IsEnabled = $State.Controls.chkBuildUSBDriveEnable.IsChecked $State.Controls.chkSelectSpecificUSBDrives.IsEnabled = $State.Controls.chkBuildUSBDriveEnable.IsChecked
$State.Controls.chkPromptExternalHardDiskMedia.IsEnabled = $State.Controls.chkAllowExternalHardDiskMedia.IsChecked $State.Controls.chkPromptExternalHardDiskMedia.IsEnabled = $State.Controls.chkAllowExternalHardDiskMedia.IsChecked
$State.Controls.chkCopyAdditionalFFUFiles.IsChecked = $State.Defaults.generalDefaults.CopyAdditionalFFUFiles
$State.Controls.additionalFFUPanel.Visibility = if ($State.Controls.chkCopyAdditionalFFUFiles.IsChecked) { 'Visible' } else { 'Collapsed' }
Update-BitsPrioritySetting -State $State
# Hyper-V Settings defaults from General Defaults # Hyper-V Settings defaults from General Defaults
$State.Controls.chkEnableVMNetworking.IsChecked = $State.Defaults.generalDefaults.EnableVMNetworking
Initialize-VMSwitchData -State $State Initialize-VMSwitchData -State $State
$State.Controls.spVMNetworkingSettings.IsEnabled = $true -eq $State.Controls.chkEnableVMNetworking.IsChecked
$State.Controls.txtDiskSize.Text = $State.Defaults.generalDefaults.DiskSizeGB $State.Controls.txtDiskSize.Text = $State.Defaults.generalDefaults.DiskSizeGB
$State.Controls.txtMemory.Text = $State.Defaults.generalDefaults.MemoryGB $State.Controls.txtMemory.Text = $State.Defaults.generalDefaults.MemoryGB
$State.Controls.txtProcessors.Text = $State.Defaults.generalDefaults.Processors $State.Controls.txtProcessors.Text = $State.Defaults.generalDefaults.Processors
@@ -266,7 +448,12 @@ function Initialize-UIDefaults {
$State.Controls.cmbLogicalSectorSize.SelectedItem = ($State.Controls.cmbLogicalSectorSize.Items | Where-Object { $_.Content -eq $State.Defaults.generalDefaults.LogicalSectorSize.ToString() }) $State.Controls.cmbLogicalSectorSize.SelectedItem = ($State.Controls.cmbLogicalSectorSize.Items | Where-Object { $_.Content -eq $State.Defaults.generalDefaults.LogicalSectorSize.ToString() })
# Populate Windows Release, Version, and SKU comboboxes # Populate Windows Release, Version, and SKU comboboxes
Get-WindowsSettingsCombos -isoPath $State.Defaults.windowsSettingsDefaults.DefaultISOPath -State $State # Initialize Windows settings combos based on media source mode
$initIsoPath = $State.Defaults.windowsSettingsDefaults.DefaultISOPath
if ($null -ne $State.Controls.rbProvideISO -and -not $State.Controls.rbProvideISO.IsChecked) {
$initIsoPath = ''
}
Get-WindowsSettingsCombos -isoPath $initIsoPath -State $State
# Windows Settings tab defaults # Windows Settings tab defaults
$State.Controls.cmbWindowsLang.ItemsSource = $State.Defaults.windowsSettingsDefaults.AllowedLanguages $State.Controls.cmbWindowsLang.ItemsSource = $State.Defaults.windowsSettingsDefaults.AllowedLanguages
@@ -292,6 +479,7 @@ function Initialize-UIDefaults {
$State.Controls.chkInstallApps.IsChecked = $State.Defaults.generalDefaults.InstallApps $State.Controls.chkInstallApps.IsChecked = $State.Defaults.generalDefaults.InstallApps
$State.Controls.txtApplicationPath.Text = $State.Defaults.generalDefaults.ApplicationPath $State.Controls.txtApplicationPath.Text = $State.Defaults.generalDefaults.ApplicationPath
$State.Controls.txtAppListJsonPath.Text = $State.Defaults.generalDefaults.AppListJsonPath $State.Controls.txtAppListJsonPath.Text = $State.Defaults.generalDefaults.AppListJsonPath
$State.Controls.txtUserAppListPath.Text = $State.Defaults.generalDefaults.UserAppListPath
$State.Controls.chkInstallWingetApps.IsChecked = $State.Defaults.generalDefaults.InstallWingetApps $State.Controls.chkInstallWingetApps.IsChecked = $State.Defaults.generalDefaults.InstallWingetApps
$State.Controls.chkBringYourOwnApps.IsChecked = $State.Defaults.generalDefaults.BringYourOwnApps $State.Controls.chkBringYourOwnApps.IsChecked = $State.Defaults.generalDefaults.BringYourOwnApps
@@ -309,15 +497,20 @@ function Initialize-UIDefaults {
$State.Controls.chkInstallDrivers.IsChecked = $State.Defaults.generalDefaults.InstallDrivers $State.Controls.chkInstallDrivers.IsChecked = $State.Defaults.generalDefaults.InstallDrivers
$State.Controls.chkCopyDrivers.IsChecked = $State.Defaults.generalDefaults.CopyDrivers $State.Controls.chkCopyDrivers.IsChecked = $State.Defaults.generalDefaults.CopyDrivers
$State.Controls.chkCopyPEDrivers.IsChecked = $State.Defaults.generalDefaults.CopyPEDrivers $State.Controls.chkCopyPEDrivers.IsChecked = $State.Defaults.generalDefaults.CopyPEDrivers
$State.Controls.chkUseDriversAsPEDrivers.IsChecked = $State.Defaults.generalDefaults.UseDriversAsPEDrivers
$State.Controls.chkCompressDriversToWIM.IsChecked = $State.Defaults.generalDefaults.CompressDownloadedDriversToWim $State.Controls.chkCompressDriversToWIM.IsChecked = $State.Defaults.generalDefaults.CompressDownloadedDriversToWim
# Drivers tab UI logic # Drivers tab UI logic
$makeList = @('Microsoft', 'Dell', 'HP', 'Lenovo') $makeList = @('Microsoft', 'Dell', 'HP', 'Lenovo')
foreach ($m in $makeList) { if ($null -ne $State.Controls.cmbMake) {
[void]$State.Controls.cmbMake.Items.Add($m) # Clear existing items to prevent duplication on re-initialization (e.g., after Restore Defaults)
} $State.Controls.cmbMake.Items.Clear()
if ($State.Controls.cmbMake.Items.Count -gt 0) { foreach ($m in $makeList) {
$State.Controls.cmbMake.SelectedIndex = 0 [void]$State.Controls.cmbMake.Items.Add($m)
}
if ($State.Controls.cmbMake.Items.Count -gt 0) {
$State.Controls.cmbMake.SelectedIndex = 0
}
} }
Update-DriverDownloadPanelVisibility -State $State Update-DriverDownloadPanelVisibility -State $State
@@ -327,31 +520,81 @@ function Initialize-UIDefaults {
# Set initial state for InstallApps checkbox based on updates # Set initial state for InstallApps checkbox based on updates
Update-InstallAppsState -State $State Update-InstallAppsState -State $State
# Set default theme mode and disable if Fluent is not supported
if ($null -ne $State.Controls.cmbThemeMode) {
$State.Controls.cmbThemeMode.SelectedItem = "System"
if (-not $State.Flags.isFluentSupported) {
$State.Controls.cmbThemeMode.IsEnabled = $false
$State.Controls.cmbThemeMode.Tag = "Fluent theme requires PowerShell 7.5+ (.NET 9+). Best experience on PowerShell 7.6+ (.NET 10)."
}
}
# Set default navigation selection to Home and initialize the shared page title
if ($null -ne $State.Controls.lstNavigation) {
$State.Controls.lstNavigation.SelectedIndex = 0
# Keep the shell header aligned with the selected navigation item on first render
if ($null -ne $State.Controls.txtPageTitle) {
$selectedNavigationItem = $State.Controls.lstNavigation.SelectedItem
if ($null -ne $selectedNavigationItem -and -not [string]::IsNullOrWhiteSpace([string]$selectedNavigationItem.Tag)) {
$State.Controls.txtPageTitle.Text = [string]$selectedNavigationItem.Tag
}
else {
$State.Controls.txtPageTitle.Text = 'Home'
}
}
}
# Set initial state for Office panel visibility # Set initial state for Office panel visibility
Update-OfficePanelVisibility -State $State Update-OfficePanelVisibility -State $State
# Set initial state for Application panel visibility # Set initial state for Application panel visibility
Update-ApplicationPanelVisibility -State $State Update-ApplicationPanelVisibility -State $State
# Set initial state for BYO Apps copy button # Set initial state for BYO Apps copy button
Update-CopyButtonState -State $State Update-CopyButtonState -State $State
}
# Apply accent color to primary action button only (per Windows design guidance)
if ($State.Flags.isFluentSupported) {
try {
$State.Controls.btnRun = $State.Window.FindName('btnRun')
if ($null -ne $State.Controls.btnRun) {
# Use SetResourceReference for live accent color updates when user changes Windows theme
$State.Controls.btnRun.SetResourceReference(
[System.Windows.Controls.Control]::BackgroundProperty,
[System.Windows.SystemColors]::AccentColorBrushKey
)
$State.Controls.btnRun.Foreground = [System.Windows.Media.Brushes]::White
}
}
catch {
WriteLog "Could not apply accent color to Build FFU button: $($_.Exception.Message)"
}
}
}
function Initialize-DynamicUIElements { function Initialize-DynamicUIElements {
param([PSCustomObject]$State) param([PSCustomObject]$State)
WriteLog "Initializing dynamic UI elements (Grids, Columns)..." WriteLog "Initializing dynamic UI elements (Grids, Columns)..."
# Get the Fluent base style for ListViewItem in GridView mode
# Must use GridViewItemContainerStyleKey (not the generic ListViewItem type key) because the
# generic Fluent ListViewItem style has a template without GridViewRowPresenter, which breaks
# column-based rendering and causes items to display their ToString() representation.
$listViewItemBaseStyle = $State.Window.TryFindResource([System.Windows.Controls.GridView]::GridViewItemContainerStyleKey)
# Driver Models ListView setup # Driver Models ListView setup
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell # Set ListViewItem style to stretch content horizontally so cell templates fill the cell
$itemStyleDriverModels = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem]) $itemStyleDriverModels = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
if ($null -ne $listViewItemBaseStyle) { $itemStyleDriverModels.BasedOn = $listViewItemBaseStyle }
$itemStyleDriverModels.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch))) $itemStyleDriverModels.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstDriverModels.ItemContainerStyle = $itemStyleDriverModels $State.Controls.lstDriverModels.ItemContainerStyle = $itemStyleDriverModels
$driverModelsGridView = New-Object System.Windows.Controls.GridView $driverModelsGridView = New-Object System.Windows.Controls.GridView
$State.Controls.lstDriverModels.View = $driverModelsGridView # Assign GridView to ListView first $State.Controls.lstDriverModels.View = $driverModelsGridView # Assign GridView to ListView first
# Add the selectable column using the new function # Add the selectable column and scope header select-all to visible filtered rows.
Add-SelectableGridViewColumn -ListView $State.Controls.lstDriverModels -State $State -HeaderCheckBoxKeyName "chkSelectAllDriverModels" -ColumnWidth 70 Add-SelectableGridViewColumn -ListView $State.Controls.lstDriverModels -State $State -HeaderCheckBoxKeyName "chkSelectAllDriverModels" -ColumnWidth 70 -HeaderSelectionAffectsVisibleItemsOnly
# Add other sortable columns with left-aligned headers # Add other sortable columns with left-aligned headers
Add-SortableColumn -gridView $driverModelsGridView -header "Make" -binding "Make" -width 100 -headerHorizontalAlignment Left Add-SortableColumn -gridView $driverModelsGridView -header "Make" -binding "Make" -width 100 -headerHorizontalAlignment Left
@@ -373,12 +616,16 @@ function Initialize-DynamicUIElements {
} }
) )
# Keep driver model columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels -FixedColumnIndexes @(0)
# Winget Search ListView setup # Winget Search ListView setup
$wingetGridView = New-Object System.Windows.Controls.GridView $wingetGridView = New-Object System.Windows.Controls.GridView
$State.Controls.lstWingetResults.View = $wingetGridView # Assign GridView to ListView first $State.Controls.lstWingetResults.View = $wingetGridView # Assign GridView to ListView first
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell # Set ListViewItem style to stretch content horizontally so cell templates fill the cell
$itemStyleWingetResults = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem]) $itemStyleWingetResults = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
if ($null -ne $listViewItemBaseStyle) { $itemStyleWingetResults.BasedOn = $listViewItemBaseStyle }
$itemStyleWingetResults.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch))) $itemStyleWingetResults.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstWingetResults.ItemContainerStyle = $itemStyleWingetResults $State.Controls.lstWingetResults.ItemContainerStyle = $itemStyleWingetResults
@@ -426,9 +673,11 @@ function Initialize-DynamicUIElements {
$binding.Mode = [System.Windows.Data.BindingMode]::TwoWay $binding.Mode = [System.Windows.Data.BindingMode]::TwoWay
$comboBoxFactory.SetBinding([System.Windows.Controls.ComboBox]::TextProperty, $binding) $comboBoxFactory.SetBinding([System.Windows.Controls.ComboBox]::TextProperty, $binding)
# Create a style to disable the ComboBox for 'msstore' source # Create a style to disable the ComboBox for 'msstore' source, inheriting the Fluent base style
$comboBoxStyle = New-Object System.Windows.Style $comboBoxStyle = New-Object System.Windows.Style
$comboBoxStyle.TargetType = [System.Windows.Controls.ComboBox] $comboBoxStyle.TargetType = [System.Windows.Controls.ComboBox]
$comboBoxBaseStyle = $State.Window.TryFindResource([System.Windows.Controls.ComboBox])
if ($null -ne $comboBoxBaseStyle) { $comboBoxStyle.BasedOn = $comboBoxBaseStyle }
$dataTrigger = New-Object System.Windows.DataTrigger $dataTrigger = New-Object System.Windows.DataTrigger
$dataTrigger.Binding = New-Object System.Windows.Data.Binding("Source") $dataTrigger.Binding = New-Object System.Windows.Data.Binding("Source")
@@ -443,6 +692,75 @@ function Initialize-DynamicUIElements {
$wingetGridView.Columns.Add($archColumn) $wingetGridView.Columns.Add($archColumn)
# --- END: Add Architecture Column --- # --- END: Add Architecture Column ---
# --- START: Add Additional Exit Codes Column ---
$exitCodesColumn = New-Object System.Windows.Controls.GridViewColumn
$exitCodesHeader = New-Object System.Windows.Controls.GridViewColumnHeader
$exitCodesHeader.Tag = "AdditionalExitCodes"
$exitCodesHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
$exitHeaderTextFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock])
$exitHeaderTextFactory.SetValue([System.Windows.Controls.TextBlock]::TextProperty, "Additional Exit Codes")
$exitHeaderTextFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, (New-Object System.Windows.Thickness(5, 2, 5, 2)))
$exitHeaderTextFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
$exitHeaderTemplate = New-Object System.Windows.DataTemplate
$exitHeaderTemplate.VisualTree = $exitHeaderTextFactory
$exitCodesHeader.ContentTemplate = $exitHeaderTemplate
$exitCodesColumn.Header = $exitCodesHeader
$exitCodesColumn.Width = 140
$exitCodesCellTemplate = New-Object System.Windows.DataTemplate
$exitCodesTextBoxFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBox])
$exitBinding = New-Object System.Windows.Data.Binding("AdditionalExitCodes")
$exitBinding.Mode = [System.Windows.Data.BindingMode]::TwoWay
$exitCodesTextBoxFactory.SetBinding([System.Windows.Controls.TextBox]::TextProperty, $exitBinding)
$exitCodesCellTemplate.VisualTree = $exitCodesTextBoxFactory
$exitCodesColumn.CellTemplate = $exitCodesCellTemplate
$wingetGridView.Columns.Add($exitCodesColumn)
# --- END: Add Additional Exit Codes Column ---
# --- START: Add Ignore Non-Zero Exit Codes Column ---
$ignoreColumn = New-Object System.Windows.Controls.GridViewColumn
$ignoreHeader = New-Object System.Windows.Controls.GridViewColumnHeader
$ignoreHeader.Tag = "IgnoreNonZeroExitCodes"
$ignoreHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
$ignoreHeaderTextFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock])
$ignoreHeaderTextFactory.SetValue([System.Windows.Controls.TextBlock]::TextProperty, "Ignore Exit Codes")
$ignoreHeaderTextFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, (New-Object System.Windows.Thickness(5, 2, 5, 2)))
$ignoreHeaderTextFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
$ignoreHeaderTemplate = New-Object System.Windows.DataTemplate
$ignoreHeaderTemplate.VisualTree = $ignoreHeaderTextFactory
$ignoreHeader.ContentTemplate = $ignoreHeaderTemplate
$ignoreColumn.Header = $ignoreHeader
$ignoreColumn.Width = 140
$ignoreCellTemplate = New-Object System.Windows.DataTemplate
# Center the checkbox in the cell
$ignoreCellGridFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.Grid])
$ignoreCellGridFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)
$ignoreCellGridFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Stretch)
$ignoreCheckFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.CheckBox])
$ignoreCheckFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Center)
$ignoreCheckFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
$ignoreBinding = New-Object System.Windows.Data.Binding("IgnoreNonZeroExitCodes")
$ignoreBinding.Mode = [System.Windows.Data.BindingMode]::TwoWay
$ignoreCheckFactory.SetBinding([System.Windows.Controls.Primitives.ToggleButton]::IsCheckedProperty, $ignoreBinding)
# Build the visual tree: Grid -> CheckBox
$ignoreCellGridFactory.AppendChild($ignoreCheckFactory)
$ignoreCellTemplate.VisualTree = $ignoreCellGridFactory
$ignoreColumn.CellTemplate = $ignoreCellTemplate
$wingetGridView.Columns.Add($ignoreColumn)
# --- END: Add Ignore Non-Zero Exit Codes Column ---
Add-SortableColumn -gridView $wingetGridView -header "Download Status" -binding "DownloadStatus" -width 150 -headerHorizontalAlignment Left Add-SortableColumn -gridView $wingetGridView -header "Download Status" -binding "DownloadStatus" -width 150 -headerHorizontalAlignment Left
$State.Controls.lstWingetResults.AddHandler( $State.Controls.lstWingetResults.AddHandler(
[System.Windows.Controls.GridViewColumnHeader]::ClickEvent, [System.Windows.Controls.GridViewColumnHeader]::ClickEvent,
@@ -460,12 +778,16 @@ function Initialize-DynamicUIElements {
} }
) )
# Keep Winget result columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults -FixedColumnIndexes @(0)
# BYO Applications ListView setup # BYO Applications ListView setup
$byoAppsGridView = New-Object System.Windows.Controls.GridView $byoAppsGridView = New-Object System.Windows.Controls.GridView
$State.Controls.lstApplications.View = $byoAppsGridView $State.Controls.lstApplications.View = $byoAppsGridView
# Set ListViewItem style to stretch content horizontally # Set ListViewItem style to stretch content horizontally
$itemStyleBYOApps = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem]) $itemStyleBYOApps = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
if ($null -ne $listViewItemBaseStyle) { $itemStyleBYOApps.BasedOn = $listViewItemBaseStyle }
$itemStyleBYOApps.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch))) $itemStyleBYOApps.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstApplications.ItemContainerStyle = $itemStyleBYOApps $State.Controls.lstApplications.ItemContainerStyle = $itemStyleBYOApps
@@ -482,12 +804,16 @@ function Initialize-DynamicUIElements {
Add-SortableColumn -gridView $byoAppsGridView -header "Ignore Exit Codes" -binding "IgnoreExitCodes" -width 120 -headerHorizontalAlignment Left Add-SortableColumn -gridView $byoAppsGridView -header "Ignore Exit Codes" -binding "IgnoreExitCodes" -width 120 -headerHorizontalAlignment Left
Add-SortableColumn -gridView $byoAppsGridView -header "Copy Status" -binding "CopyStatus" -width 150 -headerHorizontalAlignment Left Add-SortableColumn -gridView $byoAppsGridView -header "Copy Status" -binding "CopyStatus" -width 150 -headerHorizontalAlignment Left
# Keep BYO application columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstApplications -FixedColumnIndexes @(0)
# Apps Script Variables ListView setup # Apps Script Variables ListView setup
# Bind ItemsSource to the data list # Bind ItemsSource to the data list
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray() $State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell # Set ListViewItem style to stretch content horizontally so cell templates fill the cell
$itemStyleAppsScriptVars = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem]) $itemStyleAppsScriptVars = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
if ($null -ne $listViewItemBaseStyle) { $itemStyleAppsScriptVars.BasedOn = $listViewItemBaseStyle }
$itemStyleAppsScriptVars.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch))) $itemStyleAppsScriptVars.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstAppsScriptVariables.ItemContainerStyle = $itemStyleAppsScriptVars $State.Controls.lstAppsScriptVariables.ItemContainerStyle = $itemStyleAppsScriptVars
@@ -534,6 +860,9 @@ function Initialize-DynamicUIElements {
} }
} }
) )
# Keep apps script variable columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables -FixedColumnIndexes @(0)
} }
else { else {
WriteLog "Warning: lstAppsScriptVariables.View is not a GridView. Selectable column not added, and sorting cannot be enabled." WriteLog "Warning: lstAppsScriptVariables.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
@@ -550,6 +879,7 @@ function Initialize-DynamicUIElements {
# USB Drives ListView setup # USB Drives ListView setup
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell # Set ListViewItem style to stretch content horizontally so cell templates fill the cell
$itemStyleUSBDrives = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem]) $itemStyleUSBDrives = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
if ($null -ne $listViewItemBaseStyle) { $itemStyleUSBDrives.BasedOn = $listViewItemBaseStyle }
$itemStyleUSBDrives.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch))) $itemStyleUSBDrives.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstUSBDrives.ItemContainerStyle = $itemStyleUSBDrives $State.Controls.lstUSBDrives.ItemContainerStyle = $itemStyleUSBDrives
@@ -570,14 +900,14 @@ function Initialize-DynamicUIElements {
$modelColumn.Header = $modelHeader $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) { if ($usbDrivesGridView.Columns.Count -gt 2) {
$serialColumn = $usbDrivesGridView.Columns[2] $uniqueIdColumn = $usbDrivesGridView.Columns[2]
$serialHeader = New-Object System.Windows.Controls.GridViewColumnHeader $uniqueIdHeader = New-Object System.Windows.Controls.GridViewColumnHeader
$serialHeader.Content = "Serial Number" $uniqueIdHeader.Content = "Unique ID"
$serialHeader.Tag = "SerialNumber" # Property to sort by $uniqueIdHeader.Tag = "UniqueId" # Property to sort by
$serialHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left $uniqueIdHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
$serialColumn.Header = $serialHeader $uniqueIdColumn.Header = $uniqueIdHeader
} }
# Size Column (index 2 in XAML, now 3) # Size Column (index 2 in XAML, now 3)
@@ -606,10 +936,62 @@ function Initialize-DynamicUIElements {
} }
} }
) )
# Keep USB drive columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstUSBDrives -FixedColumnIndexes @(0)
} }
else { else {
WriteLog "Warning: lstUSBDrives.View is not a GridView. Selectable column not added, and sorting cannot be enabled." WriteLog "Warning: lstUSBDrives.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
} }
# Additional FFUs ListView setup
$itemStyleAdditionalFFUs = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
if ($null -ne $listViewItemBaseStyle) { $itemStyleAdditionalFFUs.BasedOn = $listViewItemBaseStyle }
$itemStyleAdditionalFFUs.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstAdditionalFFUs.ItemContainerStyle = $itemStyleAdditionalFFUs
if ($State.Controls.lstAdditionalFFUs.View -is [System.Windows.Controls.GridView]) {
Add-SelectableGridViewColumn -ListView $State.Controls.lstAdditionalFFUs -State $State -HeaderCheckBoxKeyName "chkSelectAllAdditionalFFUs" -ColumnWidth 70
$additionalFFUsGridView = $State.Controls.lstAdditionalFFUs.View
if ($additionalFFUsGridView.Columns.Count -gt 1) {
$nameColumn = $additionalFFUsGridView.Columns[1]
$nameHeader = New-Object System.Windows.Controls.GridViewColumnHeader
$nameHeader.Content = "FFU Name"
$nameHeader.Tag = "Name"
$nameHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
$nameColumn.Header = $nameHeader
}
if ($additionalFFUsGridView.Columns.Count -gt 2) {
$lastModColumn = $additionalFFUsGridView.Columns[2]
$lastModHeader = New-Object System.Windows.Controls.GridViewColumnHeader
$lastModHeader.Content = "Last Modified"
$lastModHeader.Tag = "LastModified"
$lastModHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
$lastModColumn.Header = $lastModHeader
}
$State.Controls.lstAdditionalFFUs.AddHandler(
[System.Windows.Controls.GridViewColumnHeader]::ClickEvent,
[System.Windows.RoutedEventHandler] {
param($eventSource, $e)
$header = $e.OriginalSource
if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) {
$listViewControl = $eventSource
$window = [System.Windows.Window]::GetWindow($listViewControl)
$uiStateFromWindowTag = $window.Tag
Invoke-ListViewSort -listView $eventSource -property $header.Tag -State $uiStateFromWindowTag
}
}
)
# Keep additional FFU columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstAdditionalFFUs -FixedColumnIndexes @(0)
}
else {
WriteLog "Warning: lstAdditionalFFUs.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
}
} }
+358 -30
View File
@@ -20,6 +20,7 @@ function Update-ListViewPriorities {
} }
} }
$ListView.Items.Refresh() $ListView.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $ListView
} }
# Function to move selected item to the top # Function to move selected item to the top
@@ -133,6 +134,7 @@ function Update-ListViewItemStatus {
if ($null -ne $itemToUpdate) { if ($null -ne $itemToUpdate) {
$itemToUpdate.$StatusProperty = $StatusValue $itemToUpdate.$StatusProperty = $StatusValue
$ListView.Items.Refresh() # Refresh the view to show the change $ListView.Items.Refresh() # Refresh the view to show the change
Request-ListViewColumnAutoResize -ListView $ListView
} }
else { else {
# Log if item not found (for debugging) # Log if item not found (for debugging)
@@ -220,6 +222,32 @@ function Invoke-ProgressUpdate {
$ProgressQueue.Enqueue(@{ Identifier = $Identifier; Status = $Status }) $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 # Add a function to create a sortable list view
function Add-SortableColumn { function Add-SortableColumn {
param( param(
@@ -303,7 +331,8 @@ function Add-SelectableGridViewColumn {
[string]$HeaderCheckBoxKeyName, [string]$HeaderCheckBoxKeyName,
[Parameter(Mandatory)] [Parameter(Mandatory)]
[double]$ColumnWidth, [double]$ColumnWidth,
[string]$IsSelectedPropertyName = "IsSelected" [string]$IsSelectedPropertyName = "IsSelected",
[switch]$HeaderSelectionAffectsVisibleItemsOnly
) )
# Ensure the ListView has a GridView # Ensure the ListView has a GridView
@@ -316,11 +345,13 @@ function Add-SelectableGridViewColumn {
# Create the "Select All" CheckBox for the header # Create the "Select All" CheckBox for the header
$headerCheckBox = New-Object System.Windows.Controls.CheckBox $headerCheckBox = New-Object System.Windows.Controls.CheckBox
$headerCheckBox.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center $headerCheckBox.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center
$headerCheckBox.VerticalAlignment = [System.Windows.VerticalAlignment]::Center
# MODIFICATION: Store the actual ListView object in the header's Tag # Store header metadata, including whether select-all should only affect visible rows.
$headerTagObject = [PSCustomObject]@{ $headerTagObject = [PSCustomObject]@{
PropertyName = $IsSelectedPropertyName PropertyName = $IsSelectedPropertyName
ListViewControl = $ListView ListViewControl = $ListView
HeaderSelectionAffectsVisibleItemsOnly = [bool]$HeaderSelectionAffectsVisibleItemsOnly
} }
$headerCheckBox.Tag = $headerTagObject $headerCheckBox.Tag = $headerTagObject
@@ -330,8 +361,24 @@ function Add-SelectableGridViewColumn {
$localPropertyName = $tagData.PropertyName $localPropertyName = $tagData.PropertyName
$actualListView = $tagData.ListViewControl $actualListView = $tagData.ListViewControl
$collectionToUpdate = if ($null -ne $actualListView.ItemsSource) { $actualListView.ItemsSource } else { $actualListView.Items } # Select either visible view items only (filtered scope) or the full backing list.
if ($null -ne $collectionToUpdate) { $collectionToUpdate = @()
if ($tagData.HeaderSelectionAffectsVisibleItemsOnly -and $null -ne $actualListView.ItemsSource) {
$collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($actualListView.ItemsSource)
if ($null -ne $collectionView) {
foreach ($visibleItem in $collectionView) {
$collectionToUpdate += $visibleItem
}
}
}
elseif ($null -ne $actualListView.ItemsSource) {
$collectionToUpdate = @($actualListView.ItemsSource)
}
elseif ($actualListView.HasItems) {
$collectionToUpdate = @($actualListView.Items)
}
if ($collectionToUpdate.Count -gt 0) {
foreach ($item in $collectionToUpdate) { $item.$($localPropertyName) = $true } foreach ($item in $collectionToUpdate) { $item.$($localPropertyName) = $true }
$actualListView.Items.Refresh() $actualListView.Items.Refresh()
} }
@@ -344,19 +391,57 @@ function Add-SelectableGridViewColumn {
$localPropertyName = $tagData.PropertyName $localPropertyName = $tagData.PropertyName
$actualListView = $tagData.ListViewControl $actualListView = $tagData.ListViewControl
$collectionToUpdate = if ($null -ne $actualListView.ItemsSource) { $actualListView.ItemsSource } else { $actualListView.Items } # Clear either visible view items only (filtered scope) or the full backing list.
if ($null -ne $collectionToUpdate) { $collectionToUpdate = @()
if ($tagData.HeaderSelectionAffectsVisibleItemsOnly -and $null -ne $actualListView.ItemsSource) {
$collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($actualListView.ItemsSource)
if ($null -ne $collectionView) {
foreach ($visibleItem in $collectionView) {
$collectionToUpdate += $visibleItem
}
}
}
elseif ($null -ne $actualListView.ItemsSource) {
$collectionToUpdate = @($actualListView.ItemsSource)
}
elseif ($actualListView.HasItems) {
$collectionToUpdate = @($actualListView.Items)
}
if ($collectionToUpdate.Count -gt 0) {
foreach ($item in $collectionToUpdate) { $item.$($localPropertyName) = $false } foreach ($item in $collectionToUpdate) { $item.$($localPropertyName) = $false }
$actualListView.Items.Refresh() $actualListView.Items.Refresh()
} }
} }
}) })
# Wrap the header checkbox in a stretched container so it centers the same way as row cells.
# Apply a small left inset to mirror the Fluent ListViewItem content padding used by data rows.
$headerBorder = New-Object System.Windows.Controls.Border
$headerBorder.Padding = New-Object System.Windows.Thickness(12, 0, 0, 0)
$headerBorder.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Stretch
$headerBorder.VerticalAlignment = [System.Windows.VerticalAlignment]::Stretch
$headerGrid = New-Object System.Windows.Controls.Grid
$headerGrid.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Stretch
$headerGrid.VerticalAlignment = [System.Windows.VerticalAlignment]::Stretch
$headerGrid.Children.Add($headerCheckBox) | Out-Null
$headerBorder.Child = $headerGrid
# Use an explicit GridViewColumnHeader so we can remove the default header padding
# and control the checkbox alignment explicitly.
$selectableHeader = New-Object System.Windows.Controls.GridViewColumnHeader
$selectableHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Stretch
$selectableHeader.VerticalContentAlignment = [System.Windows.VerticalAlignment]::Stretch
$selectableHeader.Padding = New-Object System.Windows.Thickness(0)
$selectableHeader.Margin = New-Object System.Windows.Thickness(0)
$selectableHeader.Content = $headerBorder
$State.Controls[$HeaderCheckBoxKeyName] = $headerCheckBox $State.Controls[$HeaderCheckBoxKeyName] = $headerCheckBox
WriteLog "Add-SelectableGridViewColumn: Stored header checkbox in State.Controls with key '$HeaderCheckBoxKeyName'." WriteLog "Add-SelectableGridViewColumn: Stored header checkbox in State.Controls with key '$HeaderCheckBoxKeyName'."
$selectableColumn = New-Object System.Windows.Controls.GridViewColumn $selectableColumn = New-Object System.Windows.Controls.GridViewColumn
$selectableColumn.Header = $headerCheckBox $selectableColumn.Header = $selectableHeader
$selectableColumn.Width = $ColumnWidth $selectableColumn.Width = $ColumnWidth
$cellTemplate = New-Object System.Windows.DataTemplate $cellTemplate = New-Object System.Windows.DataTemplate
@@ -411,6 +496,209 @@ function Add-SelectableGridViewColumn {
WriteLog "Add-SelectableGridViewColumn: Successfully added selectable column to '$($ListView.Name)'." WriteLog "Add-SelectableGridViewColumn: Successfully added selectable column to '$($ListView.Name)'."
} }
# Function to request a deferred GridView column auto-size pass
function Request-ListViewColumnAutoResize {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[System.Windows.Controls.ListView]$ListView
)
# Skip startup calls until the visual tree has finished loading.
if (-not $ListView.IsLoaded) {
return
}
# Ensure the ListView has registered auto-resize metadata before scheduling work.
$autoResizeStateKey = 'FFU.ListViewColumnAutoResizeState'
if (-not $ListView.Resources.Contains($autoResizeStateKey)) {
return
}
if (-not ($ListView.View -is [System.Windows.Controls.GridView])) {
return
}
$autoResizeState = $ListView.Resources[$autoResizeStateKey]
if ($autoResizeState.ResizePending) {
return
}
$autoResizeState.ResizePending = $true
$previousErrorActionPreference = $ErrorActionPreference
try {
$ErrorActionPreference = 'Stop'
$gridView = [System.Windows.Controls.GridView]$ListView.View
$fixedColumnIndexes = @($autoResizeState.FixedColumnIndexes)
$visibleItems = [System.Collections.Generic.List[object]]::new()
if ($null -ne $ListView.ItemsSource) {
$collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($ListView.ItemsSource)
if ($null -ne $collectionView) {
foreach ($visibleItem in $collectionView) {
$visibleItems.Add($visibleItem)
}
}
}
else {
foreach ($visibleItem in $ListView.Items) {
$visibleItems.Add($visibleItem)
}
}
$ListView.UpdateLayout()
$columnIndex = 0
foreach ($column in $gridView.Columns) {
if ($fixedColumnIndexes -contains $columnIndex) {
$columnIndex++
continue
}
if ($null -eq $column) {
$columnIndex++
continue
}
$headerText = ""
$propertyName = $null
if ($null -ne $column.DisplayMemberBinding -and $null -ne $column.DisplayMemberBinding.Path) {
$propertyName = [string]$column.DisplayMemberBinding.Path.Path
}
if ($column.Header -is [System.Windows.Controls.GridViewColumnHeader]) {
if (-not [string]::IsNullOrWhiteSpace([string]$column.Header.Content)) {
$headerText = [string]$column.Header.Content
}
if ([string]::IsNullOrWhiteSpace($propertyName) -and -not [string]::IsNullOrWhiteSpace([string]$column.Header.Tag)) {
$propertyName = [string]$column.Header.Tag
}
}
elseif (-not [string]::IsNullOrWhiteSpace([string]$column.Header)) {
$headerText = [string]$column.Header
}
if ([string]::IsNullOrWhiteSpace($headerText)) {
$headerText = $propertyName
}
$headerMeasureBlock = New-Object System.Windows.Controls.TextBlock
$headerMeasureBlock.Text = if ([string]::IsNullOrWhiteSpace($headerText)) { ' ' } else { $headerText }
$headerMeasureBlock.FontFamily = $ListView.FontFamily
$headerMeasureBlock.FontSize = $ListView.FontSize
$headerMeasureBlock.FontStyle = $ListView.FontStyle
$headerMeasureBlock.FontWeight = $ListView.FontWeight
$headerMeasureBlock.FontStretch = $ListView.FontStretch
$headerMeasureBlock.Measure([System.Windows.Size]::new([double]::PositiveInfinity, [double]::PositiveInfinity))
$calculatedWidth = [math]::Ceiling($headerMeasureBlock.DesiredSize.Width + 36)
foreach ($item in $visibleItems) {
if ($null -eq $item -or [string]::IsNullOrWhiteSpace($propertyName)) {
continue
}
$itemProperty = $null
if ($null -ne $item.PSObject -and $null -ne $item.PSObject.Properties) {
$matchedProperties = $item.PSObject.Properties.Match($propertyName)
if ($null -ne $matchedProperties -and $matchedProperties.Count -gt 0) {
$itemProperty = $matchedProperties | Select-Object -First 1
}
}
if ($null -eq $itemProperty) {
continue
}
$itemText = [string]$itemProperty.Value
$extraWidth = 28
switch ($propertyName) {
'Architecture' {
$extraWidth = 52
}
'AdditionalExitCodes' {
$extraWidth = 44
}
'IgnoreNonZeroExitCodes' {
$itemText = ' '
$extraWidth = 48
}
'IgnoreExitCodes' {
$extraWidth = 28
}
}
$itemMeasureBlock = New-Object System.Windows.Controls.TextBlock
$itemMeasureBlock.Text = if ([string]::IsNullOrWhiteSpace($itemText)) { ' ' } else { $itemText }
$itemMeasureBlock.FontFamily = $ListView.FontFamily
$itemMeasureBlock.FontSize = $ListView.FontSize
$itemMeasureBlock.FontStyle = $ListView.FontStyle
$itemMeasureBlock.FontWeight = $ListView.FontWeight
$itemMeasureBlock.FontStretch = $ListView.FontStretch
$itemMeasureBlock.Measure([System.Windows.Size]::new([double]::PositiveInfinity, [double]::PositiveInfinity))
$itemWidth = [math]::Ceiling($itemMeasureBlock.DesiredSize.Width + $extraWidth)
if ($itemWidth -gt $calculatedWidth) {
$calculatedWidth = $itemWidth
}
}
if ($propertyName -eq 'IgnoreNonZeroExitCodes') {
$calculatedWidth = [math]::Max($calculatedWidth, 120)
}
$column.Width = [math]::Max($calculatedWidth, 40)
$columnIndex++
}
}
catch {
WriteLog "Request-ListViewColumnAutoResize: Failed for '$($ListView.Name)': $($_.Exception.Message)"
if (-not [string]::IsNullOrWhiteSpace($_.InvocationInfo.PositionMessage)) {
WriteLog $_.InvocationInfo.PositionMessage
}
if (-not [string]::IsNullOrWhiteSpace($_.ScriptStackTrace)) {
WriteLog $_.ScriptStackTrace
}
}
finally {
$ErrorActionPreference = $previousErrorActionPreference
$autoResizeState.ResizePending = $false
}
}
# Function to enable reusable auto-resizing for GridView-backed ListViews
function Enable-ListViewColumnAutoResize {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[System.Windows.Controls.ListView]$ListView,
[int[]]$FixedColumnIndexes = @()
)
$autoResizeStateKey = 'FFU.ListViewColumnAutoResizeState'
# Only GridView-backed lists can participate in column auto-sizing.
if (-not ($ListView.View -is [System.Windows.Controls.GridView])) {
WriteLog "Enable-ListViewColumnAutoResize: ListView '$($ListView.Name)' is not using a GridView. Skipping registration."
return
}
if ($ListView.Resources.Contains($autoResizeStateKey)) {
return
}
$autoResizeState = [PSCustomObject]@{
FixedColumnIndexes = @($FixedColumnIndexes)
ResizePending = $false
}
$ListView.Resources[$autoResizeStateKey] = $autoResizeState
}
# Function to update the IsChecked state of a "Select All" header CheckBox # Function to update the IsChecked state of a "Select All" header CheckBox
function Update-SelectAllHeaderCheckBoxState { function Update-SelectAllHeaderCheckBoxState {
param( param(
@@ -420,24 +708,38 @@ function Update-SelectAllHeaderCheckBoxState {
[System.Windows.Controls.CheckBox]$HeaderCheckBox [System.Windows.Controls.CheckBox]$HeaderCheckBox
) )
$collectionToInspect = $null # Determine whether this header should evaluate only visible (filtered) rows.
if ($null -ne $ListView.ItemsSource) { $inspectVisibleItemsOnly = $false
if ($null -ne $HeaderCheckBox.Tag -and $null -ne $HeaderCheckBox.Tag.PSObject.Properties['HeaderSelectionAffectsVisibleItemsOnly']) {
$inspectVisibleItemsOnly = [bool]$HeaderCheckBox.Tag.HeaderSelectionAffectsVisibleItemsOnly
}
# Build the collection to inspect based on scope (visible view vs full source).
$collectionToInspect = @()
if ($inspectVisibleItemsOnly -and $null -ne $ListView.ItemsSource) {
$collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($ListView.ItemsSource)
if ($null -ne $collectionView) {
foreach ($visibleItem in $collectionView) {
$collectionToInspect += $visibleItem
}
}
}
elseif ($null -ne $ListView.ItemsSource) {
$collectionToInspect = @($ListView.ItemsSource) $collectionToInspect = @($ListView.ItemsSource)
} }
elseif ($ListView.HasItems) { elseif ($ListView.HasItems) {
# Check if Items collection has items and ItemsSource is null
$collectionToInspect = @($ListView.Items) $collectionToInspect = @($ListView.Items)
} }
# If no items to inspect (either ItemsSource was null and Items was empty, or ItemsSource was empty) # If no items are available in the selected scope, force unchecked.
if ($null -eq $collectionToInspect -or $collectionToInspect.Count -eq 0) { if ($collectionToInspect.Count -eq 0) {
$HeaderCheckBox.IsChecked = $false $HeaderCheckBox.IsChecked = $false
return return
} }
$selectedCount = ($collectionToInspect | Where-Object { $_.IsSelected }).Count $selectedCount = ($collectionToInspect | Where-Object { $_.IsSelected }).Count
WriteLog "Update-SelectAllHeaderCheckBoxState: Selected count is $selectedCount for ListView '$($ListView.Name)'." WriteLog "Update-SelectAllHeaderCheckBoxState: Selected count is $selectedCount for ListView '$($ListView.Name)'."
$totalItemCount = $collectionToInspect.Count # Get the total count from the collection being inspected $totalItemCount = $collectionToInspect.Count
WriteLog "Update-SelectAllHeaderCheckBoxState: Total item count is $totalItemCount for ListView '$($ListView.Name)'." WriteLog "Update-SelectAllHeaderCheckBoxState: Total item count is $totalItemCount for ListView '$($ListView.Name)'."
if ($totalItemCount -eq 0) { if ($totalItemCount -eq 0) {
@@ -476,6 +778,7 @@ function Invoke-ListViewItemToggle {
# Toggle the IsSelected property # Toggle the IsSelected property
$selectedItem.IsSelected = -not $selectedItem.IsSelected $selectedItem.IsSelected = -not $selectedItem.IsSelected
$ListView.Items.Refresh() $ListView.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $ListView
# Update the 'Select All' header checkbox state # Update the 'Select All' header checkbox state
$headerChk = $State.Controls[$HeaderCheckBoxKeyName] $headerChk = $State.Controls[$HeaderCheckBoxKeyName]
@@ -505,21 +808,29 @@ function Invoke-ListViewSort {
[PSCustomObject]$State [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 # Ensure $State.Flags is a hashtable and contains the required sort properties
if ($State.Flags -is [hashtable]) { if ($State.Flags -is [hashtable]) {
if (-not $State.Flags.ContainsKey('lastSortProperty')) { if (-not $State.Flags.ContainsKey('lastSortProperty')) {
$State.Flags['lastSortProperty'] = $null $State.Flags['lastSortProperty'] = $null
} }
if (-not $State.Flags.ContainsKey('lastSortAscending')) { if (-not $State.Flags.ContainsKey('lastSortAscending')) {
$State.Flags['lastSortAscending'] = $true # Default to ascending $State.Flags['lastSortAscending'] = $true
} }
} }
else { else {
Write-Warning "Invoke-ListViewSort: \$State.Flags is not a hashtable or is null. Sort state may not work correctly." 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 ($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('lastSortProperty')) { $State.Flags['lastSortProperty'] = $null }
if (-not $State.Flags.ContainsKey('lastSortAscending')) { $State.Flags['lastSortAscending'] = $true } if (-not $State.Flags.ContainsKey('lastSortAscending')) { $State.Flags['lastSortAscending'] = $true }
} }
@@ -534,10 +845,15 @@ function Invoke-ListViewSort {
} }
$State.Flags.lastSortProperty = $property $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 $currentItemsSource = $listView.ItemsSource
$itemsToSort = @() $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) $itemsToSort = @($currentItemsSource)
} }
else { else {
@@ -548,10 +864,11 @@ function Invoke-ListViewSort {
return return
} }
# Separate selected vs unselected for selected-first ordering
$selectedItems = @($itemsToSort | Where-Object { $_.IsSelected }) $selectedItems = @($itemsToSort | Where-Object { $_.IsSelected })
$unselectedItems = @($itemsToSort | Where-Object { -not $_.IsSelected }) $unselectedItems = @($itemsToSort | Where-Object { -not $_.IsSelected })
# Define the primary sort criterion # Define primary sort criterion
$primarySortDefinition = @{ $primarySortDefinition = @{
Expression = { Expression = {
$val = $_.$property $val = $_.$property
@@ -579,11 +896,11 @@ function Invoke-ListViewSort {
$secondarySortPropertyName = "Key" $secondarySortPropertyName = "Key"
} }
else { else {
# Default secondary sort for IsSelected or other properties
$secondarySortPropertyName = "Key" $secondarySortPropertyName = "Key"
} }
} }
# Add secondary sort definition if applicable
if ($null -ne $secondarySortPropertyName -and $property -ne $secondarySortPropertyName) { if ($null -ne $secondarySortPropertyName -and $property -ne $secondarySortPropertyName) {
$itemsHaveSecondaryProperty = $false $itemsHaveSecondaryProperty = $false
if ($unselectedItems.Count -gt 0) { if ($unselectedItems.Count -gt 0) {
@@ -598,35 +915,42 @@ function Invoke-ListViewSort {
} }
if ($itemsHaveSecondaryProperty) { if ($itemsHaveSecondaryProperty) {
# Create a scriptblock for the secondary sort expression dynamically
$expressionScriptBlock = [scriptblock]::Create("`$_.$secondarySortPropertyName") $expressionScriptBlock = [scriptblock]::Create("`$_.$secondarySortPropertyName")
$secondarySortDefinition = @{ $secondarySortDefinition = @{
Expression = { Expression = {
$val = Invoke-Command -ScriptBlock $expressionScriptBlock -ArgumentList $_ $val = Invoke-Command -ScriptBlock $expressionScriptBlock -ArgumentList $_
if ($null -eq $val) { '' } else { $val } if ($null -eq $val) { '' } else { $val }
} }
Ascending = $true # Secondary sort always ascending Ascending = $true
} }
$sortCriteria.Add($secondarySortDefinition) $sortCriteria.Add($secondarySortDefinition)
} }
} }
# Sort unselected items by combined sort criteria
$sortedUnselected = $unselectedItems | Sort-Object -Property $sortCriteria.ToArray() $sortedUnselected = $unselectedItems | Sort-Object -Property $sortCriteria.ToArray()
# Ensure $sortedUnselected is not null before attempting to add its range
if ($null -eq $sortedUnselected) { if ($null -eq $sortedUnselected) {
$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 = [System.Collections.Generic.List[object]]::new()
$newSortedList.AddRange($selectedItems) $newSortedList.AddRange($selectedItems)
$newSortedList.AddRange($sortedUnselected) $newSortedList.AddRange($sortedUnselected)
# Set the new sorted list as the ItemsSource # Reset ItemsSource and assign sorted list
# Try nulling out ItemsSource first to force a more complete refresh
$listView.ItemsSource = $null $listView.ItemsSource = $null
$listView.ItemsSource = $newSortedList.ToArray() $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
}
}
Request-ListViewColumnAutoResize -ListView $listView
} }
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -849,6 +1173,9 @@ function Invoke-BrowseAction {
if (-not [string]::IsNullOrWhiteSpace($InitialDirectory)) { if (-not [string]::IsNullOrWhiteSpace($InitialDirectory)) {
$dialog.InitialDirectory = $InitialDirectory $dialog.InitialDirectory = $InitialDirectory
} }
if (-not [string]::IsNullOrWhiteSpace($FileName)) {
$dialog.FileName = $FileName
}
if ($dialog.ShowDialog()) { if ($dialog.ShowDialog()) {
return $dialog.FileName return $dialog.FileName
} }
@@ -927,6 +1254,7 @@ function Clear-ListViewContent {
} }
$ListViewControl.Items.Refresh() $ListViewControl.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $ListViewControl
# Clear any specified textboxes # Clear any specified textboxes
if ($null -ne $TextBoxesToClear) { if ($null -ne $TextBoxesToClear) {
@@ -126,7 +126,7 @@ $script:mctWindowsReleases = @(
$script:windowsVersionMap = @{ $script:windowsVersionMap = @{
10 = @("22H2") 10 = @("22H2")
11 = @("22H2", "23H2", "24H2") 11 = @("25H2", "24H2", "23H2", "22H2")
2016 = @("1607") # Windows 10 LTSB 2016 & Server 2016 2016 = @("1607") # Windows 10 LTSB 2016 & Server 2016
2019 = @("1809") # Windows 10 LTSC 2019 & Server 2019 2019 = @("1809") # Windows 10 LTSC 2019 & Server 2019
# Note: Server 2016 and LTSB 2016 now share the key 2016, mapping to version "1607" # 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 # Logic for when an ISO is specified
$result.Versions = $validVersions $result.Versions = $validVersions
# Set default selection logic (e.g., latest for Win11) # Set default selection logic (e.g., latest for Win11)
if ($SelectedRelease -eq 11 -and $validVersions -contains "24H2") { if ($SelectedRelease -eq 11) {
$result.DefaultVersion = "24H2" 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.DefaultVersion = $validVersions[0]
} }
$result.IsEnabled = $true $result.IsEnabled = $true
@@ -280,7 +285,7 @@ function Get-AvailableWindowsVersions {
# Logic for when no ISO is specified (MCT scenario) # Logic for when no ISO is specified (MCT scenario)
switch ($SelectedRelease) { switch ($SelectedRelease) {
10 { $result.DefaultVersion = "22H2" } 10 { $result.DefaultVersion = "22H2" }
11 { $result.DefaultVersion = "24H2" } 11 { $result.DefaultVersion = "25H2" }
# Server versions typically require an ISO, but handle just in case # Server versions typically require an ISO, but handle just in case
2016 { $result.DefaultVersion = "1607" } 2016 { $result.DefaultVersion = "1607" }
2019 { $result.DefaultVersion = "1809" } 2019 { $result.DefaultVersion = "1809" }
@@ -515,7 +520,7 @@ function Update-WindowsArchCombo {
} }
else { else {
# Standard Windows 11 # Standard Windows 11
if ($versionValue -eq '24H2') { if ($versionValue -in @('24H2', '25H2')) {
$availableArchitectures = @('x64', 'arm64') $availableArchitectures = @('x64', 'arm64')
} }
else { else {
+93 -346
View File
@@ -59,6 +59,7 @@ function Search-WingetApps {
# Update the ListView's ItemsSource using the passed-in State object # Update the ListView's ItemsSource using the passed-in State object
$State.Controls.lstWingetResults.ItemsSource = $finalAppList.ToArray() $State.Controls.lstWingetResults.ItemsSource = $finalAppList.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
# Update status text # Update status text
$statusText = "" $statusText = ""
@@ -98,28 +99,38 @@ function Save-WingetList {
$appList = @{ $appList = @{
apps = @($selectedApps | ForEach-Object { apps = @($selectedApps | ForEach-Object {
[ordered]@{ [ordered]@{
name = $_.Name name = (ConvertTo-SafeName -Name $_.Name)
id = $_.Id id = $_.Id
source = $_.Source.ToLower() source = $_.Source.ToLower()
architecture = $_.Architecture architecture = $_.Architecture
AdditionalExitCodes = if ($_.PSObject.Properties['AdditionalExitCodes']) { $_.AdditionalExitCodes } else { "" }
IgnoreNonZeroExitCodes = if ($_.PSObject.Properties['IgnoreNonZeroExitCodes']) { [bool]$_.IgnoreNonZeroExitCodes } else { $false }
} }
}) })
} }
# Default the save dialog to the configured Winget app list path.
$currentPath = $State.Controls.txtAppListJsonPath.Text
$initialDirectory = if (-not [string]::IsNullOrWhiteSpace($currentPath)) { Split-Path -Path $currentPath -Parent } else { $State.Controls.txtApplicationPath.Text }
if ([string]::IsNullOrWhiteSpace($initialDirectory) -or -not (Test-Path -Path $initialDirectory -PathType Container)) {
$initialDirectory = $State.Controls.txtApplicationPath.Text
}
$fileName = if (-not [string]::IsNullOrWhiteSpace($currentPath)) { Split-Path -Path $currentPath -Leaf } else { "AppList.json" }
$sfd = New-Object System.Windows.Forms.SaveFileDialog $sfd = New-Object System.Windows.Forms.SaveFileDialog
$sfd.Filter = "JSON files (*.json)|*.json" $sfd.Filter = "JSON files (*.json)|*.json"
$sfd.Title = "Save App List" $sfd.Title = "Save Winget App List"
# Correctly get the path from the UI control via the State object $sfd.InitialDirectory = $initialDirectory
$sfd.InitialDirectory = $State.Controls.txtApplicationPath.Text $sfd.FileName = $fileName
$sfd.FileName = "AppList.json"
if ($sfd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { if ($sfd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
$appList | ConvertTo-Json -Depth 10 | Set-Content $sfd.FileName -Encoding UTF8 $appList | ConvertTo-Json -Depth 10 | Set-Content $sfd.FileName -Encoding UTF8
[System.Windows.MessageBox]::Show("App list saved successfully.", "Success", "OK", "Information") $State.Controls.txtAppListJsonPath.Text = $sfd.FileName
[System.Windows.MessageBox]::Show("Winget app list saved successfully.", "Success", "OK", "Information")
} }
} }
catch { catch {
[System.Windows.MessageBox]::Show("Error saving app list: $_", "Error", "OK", "Error") [System.Windows.MessageBox]::Show("Error saving Winget app list: $_", "Error", "OK", "Error")
} }
} }
@@ -130,11 +141,17 @@ function Import-WingetList {
[psobject]$State [psobject]$State
) )
try { try {
# Default the import dialog to the configured Winget app list path.
$currentPath = $State.Controls.txtAppListJsonPath.Text
$initialDirectory = if (-not [string]::IsNullOrWhiteSpace($currentPath)) { Split-Path -Path $currentPath -Parent } else { $State.Controls.txtApplicationPath.Text }
if ([string]::IsNullOrWhiteSpace($initialDirectory) -or -not (Test-Path -Path $initialDirectory -PathType Container)) {
$initialDirectory = $State.Controls.txtApplicationPath.Text
}
$ofd = New-Object System.Windows.Forms.OpenFileDialog $ofd = New-Object System.Windows.Forms.OpenFileDialog
$ofd.Filter = "JSON files (*.json)|*.json" $ofd.Filter = "JSON files (*.json)|*.json"
$ofd.Title = "Import App List" $ofd.Title = "Import Winget App List"
# Correctly get the path from the UI control via the State object $ofd.InitialDirectory = $initialDirectory
$ofd.InitialDirectory = $State.Controls.txtApplicationPath.Text
if ($ofd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { if ($ofd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
$importedAppsData = Get-Content $ofd.FileName -Raw | ConvertFrom-Json $importedAppsData = Get-Content $ofd.FileName -Raw | ConvertFrom-Json
@@ -142,30 +159,34 @@ function Import-WingetList {
$newAppListForItemsSource = [System.Collections.Generic.List[object]]::new() $newAppListForItemsSource = [System.Collections.Generic.List[object]]::new()
if ($null -ne $importedAppsData.apps) { if ($null -ne $importedAppsData.apps) {
# Get default architecture from the UI for fallback # Get default architecture from the UI for fallback.
$defaultArch = $State.Controls.cmbWindowsArch.SelectedItem $defaultArch = $State.Controls.cmbWindowsArch.SelectedItem
foreach ($appInfo in $importedAppsData.apps) { foreach ($appInfo in $importedAppsData.apps) {
$arch = if ($appInfo.source -eq 'msstore') { 'NA' } else { if ($appInfo.PSObject.Properties['architecture']) { $appInfo.architecture } else { $defaultArch } } $arch = if ($appInfo.source -eq 'msstore') { 'NA' } else { if ($appInfo.PSObject.Properties['architecture']) { $appInfo.architecture } else { $defaultArch } }
$newAppListForItemsSource.Add([PSCustomObject]@{ $newAppListForItemsSource.Add([PSCustomObject]@{
IsSelected = $true # Imported apps are marked as selected IsSelected = $true
Name = $appInfo.name Name = $appInfo.name
Id = $appInfo.id Id = $appInfo.id
Version = "" # Will be populated when searching or if data exists Version = ""
Source = $appInfo.source Source = $appInfo.source
Architecture = $arch Architecture = $arch
DownloadStatus = "" AdditionalExitCodes = if ($appInfo.PSObject.Properties['AdditionalExitCodes']) { $appInfo.AdditionalExitCodes } else { "" }
IgnoreNonZeroExitCodes = if ($appInfo.PSObject.Properties['IgnoreNonZeroExitCodes']) { [bool]$appInfo.IgnoreNonZeroExitCodes } else { $false }
DownloadStatus = ""
}) })
} }
} }
$State.Controls.lstWingetResults.ItemsSource = $newAppListForItemsSource.ToArray() $State.Controls.lstWingetResults.ItemsSource = $newAppListForItemsSource.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
$State.Controls.txtAppListJsonPath.Text = $ofd.FileName
[System.Windows.MessageBox]::Show("App list imported successfully.", "Success", "OK", "Information") [System.Windows.MessageBox]::Show("Winget app list imported successfully.", "Success", "OK", "Information")
} }
} }
catch { catch {
[System.Windows.MessageBox]::Show("Error importing app list: $_", "Error", "OK", "Error") [System.Windows.MessageBox]::Show("Error importing Winget app list: $_", "Error", "OK", "Error")
} }
} }
@@ -191,13 +212,15 @@ function Search-WingetPackagesPublic {
$output = $results | ForEach-Object -Parallel { $output = $results | ForEach-Object -Parallel {
$arch = if ($_.Source -eq 'msstore') { 'NA' } else { $using:DefaultArchitecture } $arch = if ($_.Source -eq 'msstore') { 'NA' } else { $using:DefaultArchitecture }
[PSCustomObject]@{ [PSCustomObject]@{
IsSelected = [bool]$false IsSelected = [bool]$false
Name = [string]$_.Name Name = [string]$_.Name
Id = [string]$_.Id Id = [string]$_.Id
Version = [string]$_.Version Version = [string]$_.Version
Source = [string]$_.Source Source = [string]$_.Source
Architecture = [string]$arch Architecture = [string]$arch
DownloadStatus = [string]::Empty AdditionalExitCodes = [string]::Empty
IgnoreNonZeroExitCodes = [bool]$false
DownloadStatus = [string]::Empty
} }
} -ThrottleLimit 20 } -ThrottleLimit 20
WriteLog "Winget search completed. Created $($output.Count) output objects." WriteLog "Winget search completed. Created $($output.Count) output objects."
@@ -372,323 +395,9 @@ function Confirm-WingetInstallationUI {
return $result 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 # Note: Start-WingetAppDownloadTask has been moved to FFU.Common.Winget.psm1
$appId = $ApplicationItemData.Id # to enable code reuse between UI and CLI builds. It is imported via the FFU.Common module.
$source = $ApplicationItemData.Source
$status = "Checking..." # Initial local status
$resultCode = -1 # Default to error/unknown
# Initial status update
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
WriteLog "Starting download task for $($appName) with ID $($appId) from source $($source)."
try {
# Define paths
$userAppListPath = Join-Path -Path $AppsPath -ChildPath "UserAppList.json"
$appFound = $false # Flag to track if the app is found locally
# WriteLog "UserAppList Path: $($userAppListPath)"
# WriteLog "Checking for existing app in UserAppList.json and content folder."
# 1. Check UserAppList.json and content
if (Test-Path -Path $userAppListPath) {
# WriteLog "UserAppList.json found at $($userAppListPath). Checking for app entry."
try {
$userAppListContent = Get-Content -Path $userAppListPath -Raw | ConvertFrom-Json
$userAppEntry = $userAppListContent | Where-Object { $_.Name -eq $appName }
if ($userAppEntry) {
$appFolder = Join-Path -Path "$AppsPath\Win32" -ChildPath $appName
if (Test-Path -Path $appFolder -PathType Container) {
$folderSize = (Get-ChildItem -Path $appFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($folderSize -gt 1MB) {
$appFound = $true
$status = "Not Downloaded: App in $userAppListPath and found in $appFolder"
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
WriteLog "Found '$appName' in $userAppListPath and content exists in '$appFolder'."
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 0 }
}
else {
$appFound = $true
$status = "App in '$userAppListPath' but content missing/small in '$appFolder'. Copy content or remove from UserAppList.json."
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
WriteLog $status
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
}
}
else {
$appFound = $true
$status = "App in '$userAppListPath' but content folder '$appFolder' not found. Copy content or remove from UserAppList.json."
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
WriteLog $status
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
}
}
}
catch {
WriteLog "Warning: Could not read or parse '$userAppListPath'. Error: $($_.Exception.Message)"
}
}
# 2. Check existing downloaded Win32 content (folder-based; no WinGetWin32Apps.json dependency)
if (-not $appFound -and $source -eq 'winget') {
$appFolder = Join-Path -Path "$AppsPath\Win32" -ChildPath $appName
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 $appName
if (Test-Path -Path $appFolder -PathType Container) {
$folderSize = (Get-ChildItem -Path $appFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($folderSize -gt 1MB) {
$appFound = $true
$status = "Already downloaded (MSStore)"
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
WriteLog "Found '$appName' content in '$appFolder'."
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 0 }
}
}
}
# 3. If not found locally, add to AppList.json and download
if (-not $appFound) {
# Add to AppList.json
$appListContent = $null
$appListDir = Split-Path -Path $AppListJsonPath -Parent
if (-not (Test-Path -Path $appListDir -PathType Container)) {
New-Item -Path $appListDir -ItemType Directory -Force | Out-Null
}
if (Test-Path -Path $AppListJsonPath) {
try {
$appListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
if (-not $appListContent.PSObject.Properties['apps']) {
$appListContent = @{ apps = @() }
}
}
catch {
WriteLog "Warning: Could not read or parse '$AppListJsonPath'. Creating new structure. Error: $($_.Exception.Message)"
$appListContent = @{ apps = @() }
}
}
else {
$appListContent = @{ apps = @() }
}
$appExistsInAppList = $false
if ($appListContent.apps) {
foreach ($app in $appListContent.apps) {
if ($app.id -eq $appId) {
$appExistsInAppList = $true
break
}
}
}
if (-not $appExistsInAppList) {
$newApp = @{ name = $appName; id = $appId; source = $source }
if (-not ($appListContent.apps -is [array])) { $appListContent.apps = @() }
$appListContent.apps += $newApp
try {
# Use a lock to prevent race conditions when writing to the same file
$lockName = "AppListJsonLock"
$lock = New-Object System.Threading.Mutex($false, $lockName)
try {
$lock.WaitOne() | Out-Null
# Re-read content inside lock to ensure latest version
if (Test-Path -Path $AppListJsonPath) {
$currentAppListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
if (-not ($currentAppListContent.apps | Where-Object { $_.id -eq $appId })) {
$currentAppListContent.apps += $newApp
$currentAppListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
WriteLog "Added '$appName' to '$AppListJsonPath'."
}
else {
WriteLog "'$appName' already exists in '$AppListJsonPath' (checked inside lock)."
}
}
else {
# File doesn't exist, write the initial content
$appListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
WriteLog "Created '$AppListJsonPath' and added '$appName'."
}
}
finally {
$lock.ReleaseMutex()
$lock.Dispose()
}
}
catch {
WriteLog "Error saving '$AppListJsonPath'. Error: $($_.Exception.Message)"
$status = "Failed to save AppList.json: $($_.Exception.Message)"
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
}
}
else {
WriteLog "'$appName' already exists in '$AppListJsonPath'."
}
# Proceed with download
$status = "Downloading..."
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
# Ensure variables needed by Get-Application are accessible
# (Assuming they are available via $using: scope or global scope from main script)
# $global:AppsPath = $AppsPath # Potentially redundant
# $global:WindowsArch = $ApplicationItemData.Architecture # Potentially redundant
# $global:orchestrationPath = $OrchestrationPath # Potentially redundant"
WriteLog "Orchestration Path: $($OrchestrationPath)"
if (-not (Test-Path -Path $OrchestrationPath -PathType Container)) {
New-Item -Path $OrchestrationPath -ItemType Directory -Force | Out-Null
}
$win32Folder = Join-Path -Path $AppsPath -ChildPath "Win32"
if ($source -eq "winget" -and -not (Test-Path -Path $win32Folder -PathType Container)) {
New-Item -Path $win32Folder -ItemType Directory -Force | Out-Null
}
$storeAppsFolder = Join-Path -Path $AppsPath -ChildPath "MSStore"
if ($source -eq "msstore" -and -not (Test-Path -Path $storeAppsFolder -PathType Container)) {
New-Item -Path $storeAppsFolder -ItemType Directory -Force | Out-Null
}
try {
# Call Get-Application
$resultCode = Get-Application -AppName $appName -AppId $appId -Source $source -AppsPath $AppsPath -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
}
function Invoke-WingetDownload { function Invoke-WingetDownload {
param( param(
@@ -714,15 +423,53 @@ function Invoke-WingetDownload {
$localOrchestrationPath = Join-Path -Path $State.Controls.txtApplicationPath.Text -ChildPath "Orchestration" $localOrchestrationPath = Join-Path -Path $State.Controls.txtApplicationPath.Text -ChildPath "Orchestration"
# Create hashtable for task-specific arguments to pass to Invoke-ParallelProcessing # Create hashtable for task-specific arguments to pass to Invoke-ParallelProcessing
# UI downloads skip WinGetWin32Apps.json creation - it's generated at build time
$taskArguments = @{ $taskArguments = @{
AppsPath = $localAppsPath AppsPath = $localAppsPath
AppListJsonPath = $localAppListJsonPath AppListJsonPath = $localAppListJsonPath
OrchestrationPath = $localOrchestrationPath OrchestrationPath = $localOrchestrationPath
WindowsArch = $localWindowsArch WindowsArch = $localWindowsArch
SkipWin32Json = $true
} }
# Select only necessary properties before passing to Invoke-ParallelProcessing # Select only necessary properties before passing to Invoke-ParallelProcessing
$itemsToProcess = $selectedApps | Select-Object Name, Id, Source, Version, Architecture # Include Version and Architecture if needed $itemsToProcess = $selectedApps | Select-Object Name, Id, Source, Version, Architecture # Include Version and Architecture if needed
# Before downloading, persist the selected apps to AppList.json including exit-code fields (parity with Save-WingetList)
try {
# Determine AppList.json path; default if empty
if ([string]::IsNullOrWhiteSpace($localAppListJsonPath)) {
$localAppListJsonPath = Join-Path -Path $localAppsPath -ChildPath "AppList.json"
$taskArguments.AppListJsonPath = $localAppListJsonPath
WriteLog "AppListJsonPath was empty. Defaulting to: $localAppListJsonPath"
}
# Build apps payload from current selection, preserving AdditionalExitCodes/IgnoreNonZeroExitCodes
$appListToSave = @{
apps = @($selectedApps | ForEach-Object {
[ordered]@{
name = (ConvertTo-SafeName -Name $_.Name)
id = $_.Id
source = $_.Source.ToLower()
architecture = $_.Architecture
AdditionalExitCodes = if ($_.PSObject.Properties['AdditionalExitCodes']) { $_.AdditionalExitCodes } else { "" }
IgnoreNonZeroExitCodes = if ($_.PSObject.Properties['IgnoreNonZeroExitCodes']) { [bool]$_.IgnoreNonZeroExitCodes } else { $false }
}
})
}
# Ensure destination directory exists and write AppList.json
$destDir = Split-Path -Parent $localAppListJsonPath
if (-not (Test-Path -LiteralPath $destDir)) {
[void][System.IO.Directory]::CreateDirectory($destDir)
}
$appListToSave | ConvertTo-Json -Depth 10 | Set-Content -Path $localAppListJsonPath -Encoding UTF8
WriteLog "Persisted AppList.json with selected apps and exit-code fields to: $localAppListJsonPath"
}
catch {
WriteLog "Warning: Failed to persist AppList.json prior to download. Error: $($_.Exception.Message)"
}
# Invoke the centralized parallel processing function # Invoke the centralized parallel processing function
# Pass task type and task-specific arguments # Pass task type and task-specific arguments
Invoke-ParallelProcessing -ItemsToProcess $itemsToProcess ` Invoke-ParallelProcessing -ItemsToProcess $itemsToProcess `
File diff suppressed because it is too large Load Diff
+2 -8
View File
@@ -28,7 +28,7 @@ function Write-ProgressLog {
} }
Function Get-RemovableDrive { Function Get-RemovableDrive {
writelog "Get information for all removable drives" writelog "Get information for all removable drives"
$USBDrives = Get-WmiObject Win32_DiskDrive | Where-Object {$_.MediaType -eq "Removable media"} $USBDrives = Get-WmiObject Win32_DiskDrive | Where-Object {$_.MediaType -eq "Removable media" -or $_.MediaType -eq "External hard disk media"}
If($USBDrives -and ($null -eq $USBDrives.count)) { If($USBDrives -and ($null -eq $USBDrives.count)) {
$USBDrivesCount = 1 $USBDrivesCount = 1
} else { } else {
@@ -62,6 +62,7 @@ Function Build-DeploymentUSB{
$ScriptBlock = { $ScriptBlock = {
param($DriveNumber) param($DriveNumber)
Clear-Disk -Number $DriveNumber -RemoveData -RemoveOEM -Confirm:$false Clear-Disk -Number $DriveNumber -RemoveData -RemoveOEM -Confirm:$false
Initialize-Disk -Number $DriveNumber
$Disk = Get-Disk -Number $DriveNumber $Disk = Get-Disk -Number $DriveNumber
$PartitionStyle = $Disk.PartitionStyle $PartitionStyle = $Disk.PartitionStyle
if($PartitionStyle -ne 'MBR'){ if($PartitionStyle -ne 'MBR'){
@@ -120,13 +121,6 @@ $Destination = $Drive + ":\"
Start-Job -ScriptBlock $jobScriptBlock -ArgumentList $ImagesPath, $Destination | Out-Null 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){ if($Drivers){
writelog "Copying driver files to all drives labeled deploy concurrently" writelog "Copying driver files to all drives labeled deploy concurrently"
foreach ($Drive in $DeployDrives) { foreach ($Drive in $DeployDrives) {
@@ -1,4 +0,0 @@
select disk 0
select partition 3
Assign letter="M"
exit
@@ -1,237 +0,0 @@
$VMHostIPAddress = '192.168.1.158'
$ShareName = 'FFUCaptureShare'
$UserName = 'ffu_user'
$Password = '23202eb4-10c3-47e9-b389-f0c462663a23'
$CustomFFUNameTemplate = '{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}'
$netuseCommand = "net use W: \\$VMHostIPAddress\$ShareName /user:$UserName $Password 2>&1"
# Connect to network share
try {
Write-Host "Connecting to network share via $netuseCommand"
$netUseResult = net use W: "\\$VMHostIPAddress\$ShareName" "/user:$UserName" "$Password" 2>&1
# Check if the result contains an error
if ($LASTEXITCODE -ne 0) {
# Extract the error code from the Exception Message
# Example message format: "System error 53 has occurred."
$message = $netUseResult.Exception.Message
$regex = [regex]'System error (\d+)'
$match = $regex.Match($message)
if ($match.Success) {
$errorCode = [int]$match.Groups[1].Value
$errorMessage = switch ($errorCode) {
53 { "Network path not found. Verify the IP address is correct and the server is accessible." }
67 { "Network name cannot be found. Verify the share name exists on the server." }
86 { "Password is incorrect for the specified username." }
1219 { "Multiple connections to the share exist."}
1326 { "Logon failure: unknown username or bad password." }
1385 { "Logon failure: the user has not been granted the requested logon type at this computer.
This is likely due to changes to the User Rights Assignment: Access this computer from the network local security policy
See: https://github.com/rbalsleyMSFT/FFU/issues/122 for more info" }
1792 { "Unable to connect. Verify the server is running and accepting connections." }
2250 { "Network connection attempt timed out." }
default { "Network connection failed with error code: $errorCode. Details: $message" }
}
# Write-Error $errorMessage
throw $errorMessage
}
}
} catch {
Write-Error "Failed to connect to network share: Error code: $errorcode $_"
Write-Host "Some things to try:"
Write-Host '1. If not using an external switch, change to using an external switch'
Write-Host '2. Make sure the VMHostIPAddress is correct for the VMSwitch that is being used'
Write-Host '3. Try disabling the Windows Firewall on the host machine as a test only. If that helps, there is a Windows firewall rule that is blocking SMB 445 into the VM host'
Write-Host '4. If this is a machine that is managed by your organization, try using another machine that is not managed. There could be security policies in place that are blocking the connection to the share.'
Write-Host '5. You can also try disabling Hyper-V and re-enabling it. This has helped some users in the past.'
Write-Host '6. If all else fails, open an issue on the github repo and attach screenshots of this message, your FFUDevelopment.log, your command line that you used to build the FFU, and/or the config file you used (if you used one).'
pause
throw
}
$AssignDriveLetter = 'x:\AssignDriveLetter.txt'
try {
Write-Host 'Assigning M: as Windows drive letter'
Start-Process -FilePath diskpart.exe -ArgumentList "/S $AssignDriveLetter" -Wait -ErrorAction Stop | Out-Null
}
catch {
Write-Error "Failed to assign drive letter using diskpart: $_"
}
#Load Registry Hive
$Software = 'M:\Windows\System32\config\software'
try {
Write-Host "Loading software registry hive to $Software"
if (-not (Test-Path -Path $Software)) {
throw "Software registry hive not found at $Software"
}
$regResult = reg load "HKLM\FFU" $Software 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Registry load failed with exit code $($LASTEXITCODE): $regResult"
}
Write-Host "Successfully loaded software registry hive."
}
catch {
Write-Error "Failed to load registry hive: $_"
}
try {
#Find Windows version values
Write-Host "Retrieving Windows information from the registry..."
$SKU = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'EditionID'
Write-Host "SKU: $SKU"
[int]$CurrentBuild = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'CurrentBuild'
Write-Host "CurrentBuild: $CurrentBuild"
if ($CurrentBuild -notin 14393, 17763) {
Write-Host "CurrentBuild is not 14393 or 17763, retrieving WindowsVersion..."
$WindowsVersion = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'DisplayVersion'
Write-Host "WindowsVersion: $WindowsVersion"
}
$InstallationType = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'InstallationType'
Write-Host "InstallationType: $InstallationType"
$BuildDate = Get-Date -uformat %b%Y
Write-Host "BuildDate: $BuildDate"
$SKU = switch ($SKU) {
Core { 'Home' }
CoreN { 'Home_N' }
CoreSingleLanguage { 'Home_SL' }
Professional { 'Pro' }
ProfessionalN { 'Pro_N' }
ProfessionalEducation { 'Pro_Edu' }
ProfessionalEducationN { 'Pro_Edu_N' }
Enterprise { 'Ent' }
EnterpriseN { 'Ent_N' }
EnterpriseS { 'Ent_LTSC' }
EnterpriseSN { 'Ent_N_LTSC' }
IoTEnterpriseS { 'IoT_Ent_LTSC' }
Education { 'Edu' }
EducationN { 'Edu_N' }
ProfessionalWorkstation { 'Pro_Wks' }
ProfessionalWorkstationN { 'Pro_Wks_N' }
ServerStandard { 'Srv_Std' }
ServerDatacenter { 'Srv_Dtc' }
}
if ($InstallationType -eq "Client") {
if ($CurrentBuild -ge 22000) {
$WindowsRelease = 'Win11'
Write-Host "WindowsRelease: $WindowsRelease"
}
else {
$WindowsRelease = 'Win10'
Write-Host "WindowsRelease: $WindowsRelease"
}
}
else {
$WindowsRelease = switch ($CurrentBuild) {
26100 { '2025' }
20348 { '2022' }
17763 { '2019' }
14393 { '2016' }
Default { $WindowsVersion }
}
Write-Host "WindowsRelease: $WindowsRelease"
if ($InstallationType -eq "Server Core") {
$SKU += "_Core"
Write-Host "InstallType is Server Core, changing SKU to: $SKU"
}
}
if ($CustomFFUNameTemplate) {
Write-Host 'Using custom FFU name template...'
$FFUFileName = $CustomFFUNameTemplate
$FFUFileName = $FFUFileName -replace '{WindowsRelease}', $WindowsRelease
$FFUFileName = $FFUFileName -replace '{WindowsVersion}', $WindowsVersion
$FFUFileName = $FFUFileName -replace '{SKU}', $SKU
$FFUFileName = $FFUFileName -replace '{BuildDate}', $BuildDate
$FFUFileName = $FFUFileName -replace '{yyyy}', (Get-Date -UFormat '%Y')
$FFUFileName = $FFUFileName -creplace '{MM}', (Get-Date -UFormat '%m')
$FFUFileName = $FFUFileName -replace '{dd}', (Get-Date -UFormat '%d')
$FFUFileName = $FFUFileName -creplace '{HH}', (Get-Date -UFormat '%H')
$FFUFileName = $FFUFileName -creplace '{hh}', (Get-Date -UFormat '%I')
$FFUFileName = $FFUFileName -creplace '{mm}', (Get-Date -UFormat '%M')
$FFUFileName = $FFUFileName -replace '{tt}', (Get-Date -UFormat '%p')
Write-Host "FFU File Name: $FFUFileName"
#If the custom FFU name template does not end with .ffu, append it
if ($FFUFileName -notlike '*.ffu') {
$FFUFileName += '.ffu'
Write-Host "Appended .ffu to FFU file name: $FFUFileName"
}
$dismArgs = "/capture-ffu /imagefile=W:\$FFUFileName /capturedrive=\\.\PhysicalDrive0 /name:$WindowsRelease$WindowsVersion$SKU /Compress:Default"
Write-Host "DISM arguments for capture: $dismArgs"
}
else {
#If Office is installed, modify the file name of the FFU
$Office = Get-ChildItem -Path 'M:\Program Files\Microsoft Office' -ErrorAction SilentlyContinue
if ($Office) {
$ffuFilePath = "W:\$WindowsRelease`_$WindowsVersion`_$SKU`_Office`_$BuildDate.ffu"
Write-Host "Office is installed, using modified FFU file name: $ffuFilePath"
}
else {
$ffuFilePath = "W:\$WindowsRelease`_$WindowsVersion`_$SKU`_Apps`_$BuildDate.ffu"
Write-Host "Office is not installed, using modified FFU file name: $ffuFilePath"
}
$dismArgs = "/capture-ffu /imagefile=$ffuFilePath /capturedrive=\\.\PhysicalDrive0 /name:$WindowsRelease$WindowsVersion$SKU /Compress:Default"
Write-Host "DISM arguments for capture: $dismArgs"
}
#Unload Registry
Set-Location X:\
Remove-Variable SKU
Remove-Variable CurrentBuild
if ($CurrentBuild -notin 14393, 17763) {
Remove-Variable WindowsVersion
}
if ($Office) {
Remove-Variable Office
}
try {
Write-Host "Unloading registry hive HKLM\FFU..."
$regUnloadResult = reg unload "HKLM\FFU" 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Registry unload failed with exit code $($LASTEXITCODE): $regUnloadResult"
}
Write-Host "Successfully unloaded registry hive."
}
catch {
Write-Error "Failed to unload registry hive: $_"
}
Write-Host "Sleeping for 60 seconds to allow registry to unload prior to capture"
Start-sleep 60
try {
Write-Host "Starting DISM FFU capture..."
$dismProcess = Start-Process -FilePath dism.exe -ArgumentList $dismArgs -Wait -PassThru -ErrorAction Stop
if ($dismProcess.ExitCode -ne 0) {
throw "DISM capture failed with exit code $($dismProcess.ExitCode)"
}
Write-Host "DISM FFU capture completed successfully."
}
catch {
Write-Error "FFU capture failed: $_"
}
try {
Write-Host "Copying DISM log to network share..."
xcopy X:\Windows\logs\dism\dism.log W:\ /Y | Out-Null
}
catch {
Write-Warning "Failed to copy DISM log: $_"
}
Write-Host "DISM log copied to network share, shutting down..."
wpeutil Shutdown
}
catch {
Write-Error "An unexpected error occurred: $_"
}
@@ -1,5 +0,0 @@
wpeinit
powercfg /s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c
powershell -Noprofile -ExecutionPolicy Bypass -File x:\CaptureFFU.ps1
exit
File diff suppressed because it is too large Load Diff
Binary file not shown.
@@ -0,0 +1,4 @@
SerialNumber,ComputerName
ABC12345,CORP-001
DEF67890,KIOSK-010
XYZ24680,STORE-015
1 SerialNumber ComputerName
2 ABC12345 CORP-001
3 DEF67890 KIOSK-010
4 XYZ24680 STORE-015
@@ -3,7 +3,7 @@
<settings pass="specialize"> <settings pass="specialize">
<!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it --> <!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it -->
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ComputerName>MYCOMPUTER</ComputerName><!--Leave Default will be renamed--> <ComputerName>*</ComputerName><!--Leave Default will be renamed-->
<TimeZone>Eastern Standard Time</TimeZone><!--Add Your Local TimeZone--> <TimeZone>Eastern Standard Time</TimeZone><!--Add Your Local TimeZone-->
</component> </component>
<!-- Place additional Components Elements and Settings below here: --> <!-- Place additional Components Elements and Settings below here: -->
+1 -1
View File
@@ -3,7 +3,7 @@
<settings pass="specialize"> <settings pass="specialize">
<!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it --> <!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it -->
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="arm64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="arm64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ComputerName>MyComputer</ComputerName> <ComputerName>*</ComputerName>
</component> </component>
<!--Place addtional Components Elements and settings below here. --> <!--Place addtional Components Elements and settings below here. -->
</settings> </settings>
+1 -1
View File
@@ -3,7 +3,7 @@
<settings pass="specialize"> <settings pass="specialize">
<!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it --> <!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it -->
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ComputerName>MyComputer</ComputerName> <ComputerName>*</ComputerName>
</component> </component>
<!--Place addtional Components Elements and settings below here. --> <!--Place addtional Components Elements and settings below here. -->
</settings> </settings>
+11 -79
View File
@@ -1,3 +1,9 @@
# Updates
## 2026-03-16 - [2603.2 Released](https://github.com/rbalsleyMSFT/FFU/releases)
Fixes an issue with devices not booting after applying an FFU. Highly recommended you update today.
# Using Full Flash Update (FFU) files to speed up Windows deployment # Using Full Flash Update (FFU) files to speed up Windows deployment
What if you could have a Windows image (Windows 10/11/Server/LTSC) that has: What if you could have a Windows image (Windows 10/11/Server/LTSC) that has:
@@ -18,88 +24,14 @@ And the best part: **it takes less than two minutes** to apply the image, even w
The Full-Flash update (FFU) process can automatically download the latest release of Windows 11, the updates mentioned above, and creates a USB drive that can be used to quickly reimage a machine. The Full-Flash update (FFU) process can automatically download the latest release of Windows 11, the updates mentioned above, and creates a USB drive that can be used to quickly reimage a machine.
# Updates
2507.1 has been released to preview! This is a major update that brings a new user interface to preview.
Docs are coming, but will take a bit to write them. The youtube video is a must watch for a complete demo on how to use the UI and the changes made to apps (InstallAppsAndSysprep.cmd is gone) and drivers. I'll be recording a more formalized deep dive with slides that go a bit deeper into how things work, but the UI walkthrough should get most people going.
# Getting Started # Getting Started
- Download the latest [release](https://github.com/rbalsleyMSFT/FFU/releases) If you're new to FFU Builder or new to the FFU Builder UI version, check out the [Quick Start Guide](https://rbalsleymsft.github.io/FFU/quickstart.html).
- Extract the FFUDevelopment folder from the ZIP file (recommend to C:\FFUDevelopment)
- Watch the Youtube video (updated docs for the UI coming soon)
## YouTube Detailed Walkthrough This will be the fastest way to create your first FFU. There's a new [FFU Builder Quickstart Youtube video](https://youtu.be/kOIK5OmDugc) based on the 2602.1 UI Preview release.
Here's a detailed overview of the new UI process. ## Older Youtube Videos
[![Reimage Windows Fast with FFU Builder 2507.1 Preview](https://img.youtube.com/vi/oozG1aVcg9M/maxresdefault.jpg)](https://youtu.be/oozG1aVcg9M "Reimage Windows Fast with FFU Builder 2507.1 Preview") [2507.1 UI Preview Video](https://www.youtube.com/watch?v=oozG1aVcg9M) - First UI Preview release video. This goes deeper than the quick start video, but is missing some features that have been added since 2507.1 was released.
Chapters: [2407.2 Video](https://www.youtube.com/watch?v=rqXRbgeeKSQ) - This was the main deep-dive video on FFU Builder (before it had that name). This is a good deep dive into how the BuildFFUVM.ps1 script works, but a lot has changed since that build.
[00:00](https://www.youtube.com/watch?v=oozG1aVcg9M&t=0s) Begin
[01:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=67s) Prereqs
[06:32](https://www.youtube.com/watch?v=oozG1aVcg9M&t=392s) Demo Begins
[07:16](https://www.youtube.com/watch?v=oozG1aVcg9M&t=436s) Running the BuildFFUVM_UI.ps1 script
[08:15](https://www.youtube.com/watch?v=oozG1aVcg9M&t=495s) UI Overview
[10:13](https://www.youtube.com/watch?v=oozG1aVcg9M&t=613s) Hyper-V Settings
[16:04](https://www.youtube.com/watch?v=oozG1aVcg9M&t=964s) Windows Settings
[22:35](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1355s) Updates
[24:49](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1489s) Applications
[29:39](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1779s) Install Winget Applications
[45:29](https://www.youtube.com/watch?v=oozG1aVcg9M&t=2729s) Bring Your Own Applications
[54:14](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3254s) Apps Script Variables
[57:43](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3463s) M365 Apps/Office
[59:01](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3541s) Drivers
[01:01:22](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3682s) Drivers.json example
[01:02:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3727s) DriverMapping.json explanation
[01:06:08](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3968s) Driver WIM Compression
[01:10:50](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4250s) Build
[01:12:41](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4361s) Build USB Drive
[01:20:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4807s) Monitor
[01:20:32](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4832s) Setting up the Demo Build
[01:24:10](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5050s) Save/Load Config Files
[01:25:11](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5111s) Kicking off the Demo Build/Going over the monitor tab
[01:32:26](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5546s) Demoing the new FFU Builder Orchestrator
[01:35:25](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5725s) New captureffu.ps1 console output
[01:42:29](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6149s) Demo Build Complete
[01:42:42](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6162s) How to configure a VM to test your newly built FFU
[01:48:58](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6538s) The moment of truth: What does the new deployment experience look like?
[01:53:13](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6793s) How to bypass OOBE using a provisioning package
[01:55:49](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6949s) Preview Focus Areas
[02:04:04](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7444s) Known Issues/Things to fix before GA
[02:05:38](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7538s) Providing Feedback
[02:06:43](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7603s) Thank you
+69
View File
@@ -0,0 +1,69 @@
---
title: M365 Apps/Office
nav_order: 8
prev_url: /appsscriptvariables.html
prev_label: Apps Script Variables
next_url: /drivers.html
next_label: Drivers
parent: UI Overview
---
# M365 Apps/Office
![1776379074990](image/M365appsoffice/1776379074990.png)
FFU Builder uses the Office Deployment Toolkit (ODT) to install Office. In the `.\FFUDevelopment\Apps\Office` folder you'll find two files:
* `DownloadFFU.xml`
* `DeployFFU.xml`
## DownloadFFU.xml
`DownloadFFU.xml` is responsible for the download of Office. It's invoked by `setup.exe /download .\DownloadFFU.xml` during the build process. It defaults to downloading the current channel 64-bit version of Office matching the current OS language to `C:\FFUDevelopment\Apps\Office`.
`DownloadFFU.xml` contents:
```
<Configuration ID="efa6df21-a106-428e-8eaa-d89a5dda6030">
<Add SourcePath="C:\FFUDevelopment\Apps\Office" OfficeClientEdition="64" Channel="Current">
<Product ID="O365ProPlusRetail">
<Language ID="MatchOS" />
</Product>
</Add>
</Configuration>
```
If you want to modify the language, you'll need to change the language ID to the language you wish to download and install.
For more information about deploying languages see: [Overview of deploying languages for Microsoft 365 Apps - Microsoft 365 Apps Microsoft Learn](https://learn.microsoft.com/en-us/microsoft-365-apps/deploy/overview-deploying-languages-microsoft-365-apps)
## DeployFFU.xml
`DeployFFU.xml` is responsible for customizing the installation of Office. If you don't provide a custom XML, it will default to using what's in `DeployFFU.xml`. The default configuration will install the 64-bit current channel version of Office with Word, Excel, Powerpoint. Below is what's currently in `DeployFFU.xml`:
```
<Configuration ID="efa6df21-a106-428e-8eaa-d89a5dda6030">
<Add OfficeClientEdition="64" Channel="Current">
<Product ID="O365ProPlusRetail">
<Language ID="MatchOS" />
<ExcludeApp ID="Access" />
<ExcludeApp ID="Lync" />
<ExcludeApp ID="Publisher" />
<ExcludeApp ID="Bing" />
<ExcludeApp ID="Teams" />
<ExcludeApp ID="Outlook" />
</Product>
</Add>
<Property Name="SharedComputerLicensing" Value="0" />
<Property Name="FORCEAPPSHUTDOWN" Value="FALSE" />
<Property Name="DeviceBasedLicensing" Value="0" />
<Property Name="SCLCacheOverride" Value="0" />
<Updates Enabled="TRUE" />
<Display Level="None" AcceptEULA="TRUE" />
</Configuration>
```
## Copy Office Configuration XML
If you want to include your own custom XML file for office, check **Copy Office Configuration XML** and browse to the location of your custom XML file. The path to your custom Office configuration XML file is stored in the `OfficeConfigXMLFile` parameter. This file gets added to the `.\FFUDevelopment\Apps\Office` folder and is referenced in the `.\FFUDevelopment\Apps\Orchestration\Install-Office.ps1` file.
{% include page_nav.html %}
+25
View File
@@ -0,0 +1,25 @@
title: FFU Builder
description: Build and deploy Windows FFU images
remote_theme: just-the-docs/just-the-docs@v0.10.1
plugins:
- jekyll-remote-theme
- jekyll-seo-tag
search_enabled: true
# Canonical host for absolute URLs (sitemap/robots/canonical tags)
url: "https://rbalsleymsft.github.io"
# Because youll publish as a project site at /FFU
baseurl: "/FFU"
callouts:
note:
title: Note
color: purple
tip:
title: Tip
color: green
warning:
title: Warning
color: yellow
+292
View File
@@ -0,0 +1,292 @@
{% if page.url == '/' or page.url == '/index.html' %}
<meta name="google-site-verification" content="2O8GqDcQF_fvyvZjeTz-YlTaN2p62FfWd9w-xHU4Zbc" />
{% endif %}
{% if page.url == '/' or page.url == '/index.html' %}
<meta name="google-site-verification" content="2O8GqDcQF_fvyvZjeTz-YlTaN2p62FfWd9w-xHU4Zbc" />
{% endif %}
<!-- docs/_includes/head_custom.html -->
<style>
/* Layout: remove Just-the-Docs "centered narrow" constraints on wide screens */
@media (min-width: 50rem) {
.main {
max-width: none !important;
}
}
@media (min-width: 66.5rem) {
.side-bar {
width: 16.5rem !important;
min-width: 16.5rem !important;
}
.side-bar+.main {
margin-left: 16.5rem !important;
}
}
/* Readability: wider column + slightly larger, less-thin text */
@media (min-width: 66.5rem) {
.main-content {
max-width: 1100px;
}
}
@media (min-width: 90rem) {
.main-content {
max-width: 1280px;
}
}
/* Typography: approximate Microsoft Learn (Segoe UI Variable + regular body + semibold headings) */
body,
.main-content {
font-family: "Segoe UI Variable Text", "Segoe UI Variable", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
.main-content {
margin-right: auto;
margin-left: auto;
font-size: 1rem;
/* 16px-ish, closer to Learn */
line-height: 1.6;
font-weight: 400;
/* Just-the-Docs defaults body text to a mid-grey; make it closer to Learn */
color: #242424;
}
.main-content p,
.main-content li {
line-height: 1.65;
}
/* Lists: increase indentation (Learn-like) */
/* Just-the-Docs draws bullets/numbers via ::before with negative offsets */
.main-content ul,
.main-content ol {
padding-left: 2.25em;
}
.main-content h1,
.main-content h2,
.main-content h3 {
font-weight: 600;
letter-spacing: -0.01em;
}
.main-content code,
.main-content pre code {
font-size: 0.95em;
}
/* Wrapping: prevent long code/paths from overflowing into the page TOC */
.main-content {
overflow-wrap: anywhere;
word-break: break-word;
}
.main-content :not(pre)>code {
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.main-content a {
overflow-wrap: anywhere;
word-break: break-word;
}
/* Images: make it obvious they're zoomable (opt-out via class="no-zoom") */
.main-content img:not(.no-zoom) {
cursor: zoom-in;
}
/* Image zoom: ensure the zoom overlay sits above the right TOC */
.medium-zoom-overlay {
z-index: 9999 !important;
}
.medium-zoom-image--opened {
z-index: 10000 !important;
}
/* Right-side page TOC (desktop only) */
@media (min-width: 66.5rem) {
.main-content-wrap.has-page-toc {
display: grid;
grid-template-columns: minmax(0, 1fr) 16rem;
grid-template-rows: auto 1fr;
grid-template-areas:
"breadcrumb breadcrumb"
"content toc";
column-gap: 2rem;
align-items: start;
}
/* Breadcrumbs (when present) always span full width */
.main-content-wrap.has-page-toc .breadcrumb-nav {
grid-area: breadcrumb;
}
/* Main content always stays in the left column */
.main-content-wrap.has-page-toc .main-content {
grid-area: content;
/* Prevent wide tables/code from forcing overlap */
min-width: 0;
/* Force the content to respect the grid column width (no centering/max-width overflow) */
width: 100%;
max-width: 100%;
margin-left: 0;
margin-right: 0;
justify-self: stretch;
/* Keep heading permalink icons visible */
/* (Just-the-Docs positions .anchor-heading to the left at desktop widths) */
overflow-x: visible;
}
/* Safety net: prevent long code/tables from overlapping the TOC */
.main-content-wrap.has-page-toc .main-content pre,
.main-content-wrap.has-page-toc .main-content .highlighter-rouge,
.main-content-wrap.has-page-toc .main-content .table-wrapper {
overflow-x: auto;
}
/* TOC always stays in the right column */
.page-toc {
grid-area: toc;
position: sticky;
top: 5.5rem;
max-height: calc(100vh - 6.5rem);
overflow: auto;
padding-left: 1rem;
border-left: 1px solid #eeebee;
font-size: 0.875rem;
/* Ensure the TOC doesnt visually blend with overflowing content */
background-color: #fff;
z-index: 1;
}
.page-toc__title {
font-weight: 600;
color: #27262b;
margin-bottom: 0.75rem;
}
.page-toc__list {
list-style: none;
padding-left: 0;
margin: 0;
}
.page-toc__item {
margin: 0.4rem 0;
}
.page-toc__item--h3 {
padding-left: 0.75rem;
}
.page-toc__link {
color: inherit;
text-decoration: none;
display: block;
padding: 0.125rem 0 0.125rem 0.75rem;
border-left: 3px solid transparent;
}
.page-toc__link:hover {
text-decoration: underline;
}
.page-toc__link.is-active {
font-weight: 600;
color: #1a1a1a;
border-left-color: #2563eb;
}
}
/* Inline "In this article" TOC (narrow viewports; Learn-like) */
.page-toc.page-toc--inline {
margin: 1.5rem 0;
padding-left: 1rem;
border-left: 1px solid #eeebee;
font-size: 0.875rem;
}
.page-toc--inline .page-toc__title {
font-weight: 600;
color: #27262b;
margin-bottom: 0.75rem;
}
.page-toc--inline .page-toc__list {
list-style: none !important;
padding-left: 0;
margin: 0;
}
.page-toc--inline .page-toc__item {
list-style: none !important;
margin: 0.4rem 0;
}
.page-toc--inline .page-toc__item::marker {
content: "";
}
.page-toc--inline .page-toc__item--h3 {
padding-left: 0.75rem;
}
.page-toc--inline .page-toc__link {
color: inherit;
text-decoration: none;
display: block;
padding: 0.125rem 0 0.125rem 0.75rem;
border-left: 3px solid transparent;
}
.page-toc--inline .page-toc__link:hover {
text-decoration: underline;
}
.page-toc__item.is-hidden {
display: none;
}
/* Just-the-Docs renders UL bullets via li::before; disable for inline TOC */
.page-toc--inline ul > li::before,
.page-toc--inline .page-toc__list > li::before,
.page-toc--inline .page-toc__item::before {
content: "" !important;
display: none !important;
}
.page-toc__toggle {
background: none;
border: 0;
padding: 0.25rem 0 0 0.75rem;
font: inherit;
color: inherit;
cursor: pointer;
text-decoration: none;
}
.page-toc__toggle:hover {
text-decoration: underline;
}
</style>
<meta name="ffu-right-toc" content="{% if page.right_toc == false %}false{% else %}true{% endif %}">
<script src="{{ '/assets/js/vendor/medium-zoom.min.js' | relative_url }}" defer></script>
<script src="{{ '/assets/js/image-zoom.js' | relative_url }}" defer></script>
<script src="{{ '/assets/js/page-toc.js' | relative_url }}" defer></script>
<script src="{{ '/assets/js/external-links.js' | relative_url }}" defer></script>
+17
View File
@@ -0,0 +1,17 @@
<!-- docs/_includes/page_nav.html -->
<div class="d-flex flex-justify-between mt-6">
{% assign prev_url = include.prev_url | default: page.prev_url %}
{% assign prev_label = include.prev_label| default: page.prev_label | default: 'Home' %}
{% assign next_url = include.next_url | default: page.next_url %}
{% assign next_label = include.next_label| default: page.next_label | default: 'Next' %}
{% if prev_url %}
<a class="btn btn-outline" href="{{ prev_url | relative_url }}">← {{ prev_label }}</a>
{% else %}
<span></span>
{% endif %}
{% if next_url %}
<a class="btn btn-blue" href="{{ next_url | relative_url }}">{{ next_label }} →</a>
{% endif %}
</div>
+21
View File
@@ -0,0 +1,21 @@
---
title: Applications
nav_order: 4
prev_url: /updates.html
prev_label: Updates
next_url: /winget.html
next_label: Install Winget Applications
parent: UI Overview
has_toc: false
---
# Applications
![1776378755854](image/applications/1776378755854.png)
Applications can be installed in three different ways:
* Winget (using an AppList.json file)
* Bring Your Own Applications (using files you provide - can also be used to run command lines with or without content)
* Apps Script Variables (key/value pairs used in conjunction with a PowerShell script to install custom applications)
{% include page_nav.html %}
+131
View File
@@ -0,0 +1,131 @@
---
title: Apps Script Variables
nav_order: 7
prev_url: /byoapps.html
prev_label: BYO Applications
next_url: /M365appsoffice.html
next_label: M365 Apps Office
parent: Applications
grand_parent: UI Overview
---
# Apps Script Variables
![1776379026213](image/appsscriptvariables/1776379026213.png)
Apps Script Variables are key value pairs that are used to create a hashtable that is passed to the `BuildFFUVM.ps1` script (stored in the `$AppScriptVariables` parameter as a hashtable). At build time, `BuildFFUVM.ps1` will export the `$AppsScriptVariables` hashtable to an `AppsScriptVariables.json` file in the `$OrchestrationPath` folder (`$AppsPath\Orchestration`). You can also manually create your own `AppsScriptVariables.json `file and place it in the `$AppsPath\Orchestration` folder.
In the VM, the `Orchestrator.ps1` file will call `Invoke-AppsScript.ps1` if `AppsScriptVariables.json` exists. `Invoke-AppsScript.ps1` must be modified to handle your variables.
`Invoke-AppsScript.ps1` has the following commented example of how to modify the file:
```
# Example of how to use the AppsScriptVariables hashtable to control script execution
# Note: The UI saves the values as strings, so if you type true for a value, it'll save to the config file as a string, not boolean
# Example: Check if a variable named 'foo' is set to string 'bar' and run a script accordingly
# if ($AppsScriptVariables['foo'] -eq 'bar') {
# Write-Host "Foo would have installed"
# }
# else {
# Write-Host "Foo would not have installed"
# }
# Example: Check if a variable named 'Teams' is set to string 'true' and run a script accordingly
# if ($AppsScriptVariables['Teams'] -eq 'true') {
# Write-Host "Teams would have been installed"
# }
# else {
# Write-Host "Teams would not have been installed"
# }
```
## Why use Apps Script Variables?
This allows for you to create a dynamic task sequence via a PowerShell script with simple if statements to run apps, commands, etc. This is all driven by your `FFUConfig.json` file. For example, the following `FFUConfig.json` file contains an AppsScriptVariables hashtable of foo and vmwaretools like the screenshot above. If you build servers that require vmware tools, you may set the value to true. However there may be situations where you don't need vmwaretools installed. If that's the case, you set vmwaretools to false. This allows for your `Invoke-AppsScript.ps1` file to stay the same and all you have to do is adjust the variables.
```
s{
"AdditionalFFUFiles": [],
"AllowExternalHardDiskMedia": false,
"AllowVHDXCaching": false,
"AppListPath": "C:\\FFUDevelopment\\Apps\\AppList.json",
"AppsPath": "C:\\FFUDevelopment\\Apps",
"AppsScriptVariables": {
"foo": "bar",
"vmwaretools": "true"
},
"BuildUSBDrive": false,
"CleanupAppsISO": true,
"CleanupDeployISO": true,
"CleanupDrivers": false,
"CompactOS": true,
"CompressDownloadedDriversToWim": false,
"CopyAdditionalFFUFiles": false,
"CopyAutopilot": false,
"CopyDrivers": false,
"CopyOfficeConfigXML": false,
"CopyPEDrivers": false,
"CopyPPKG": false,
"CopyUnattend": false,
"CreateDeploymentMedia": true,
"CustomFFUNameTemplate": "{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}",
"Disksize": 53687091200,
"DownloadDrivers": false,
"DriversFolder": "C:\\FFUDevelopment\\Drivers",
"DriversJsonPath": "C:\\FFUDevelopment\\Drivers\\Drivers.json",
"FFUCaptureLocation": "C:\\FFUDevelopment\\FFU",
"FFUDevelopmentPath": "C:\\FFUDevelopment",
"FFUPrefix": "_FFU",
"InjectUnattend": false,
"InstallApps": true,
"InstallDrivers": false,
"InstallOffice": false,
"InstallWingetApps": false,
"ISOPath": "",
"LogicalSectorSizeBytes": 512,
"MaxUSBDrives": 5,
"MediaType": "Consumer",
"Memory": 4294967296,
"OfficeConfigXMLFile": "",
"OfficePath": "C:\\FFUDevelopment\\Apps\\Office",
"Optimize": true,
"OptionalFeatures": "",
"OrchestrationPath": "C:\\FFUDevelopment\\Apps\\Orchestration",
"PEDriversFolder": "C:\\FFUDevelopment\\PEDrivers",
"Processors": 4,
"ProductKey": "",
"PromptExternalHardDiskMedia": true,
"RemoveApps": false,
"RemoveFFU": false,
"RemoveUpdates": false,
"Threads": 5,
"UpdateADK": true,
"UpdateEdge": true,
"UpdateLatestCU": true,
"UpdateLatestDefender": true,
"UpdateLatestMicrocode": false,
"UpdateLatestMSRT": true,
"UpdateLatestNet": true,
"UpdateOneDrive": true,
"UpdatePreviewCU": false,
"USBDriveList": {},
"UseDriversAsPEDrivers": false,
"UserAppListPath": "C:\\FFUDevelopment\\Apps\\UserAppList.json",
"Verbose": false,
"VMLocation": "C:\\FFUDevelopment\\VM",
"VMSwitchName": "External",
"WindowsArch": "x64",
"WindowsLang": "en-us",
"WindowsRelease": 11,
"WindowsSKU": "Pro",
"WindowsVersion": "25H2"
}
```
Example command line to run with vmwaretools set to false and foo set to foo. This will create the `AppsScriptVariables.json` file in the Orchestration folder with the updated values of `foo=foo` and `vmwaretools=false` without the need to modify the config file.
`.\BuildFFUVM.ps1 -configFile 'C:\FFUDevelopment\config\FFUConfig.json' -appsScriptVariables @{foo='foo'; vmwaretools='false'}`
{% include page_nav.html %}
+91
View File
@@ -0,0 +1,91 @@
(function () {
'use strict';
function HasToken(tokens, token) {
for (var i = 0; i < tokens.length; i++) {
if (tokens[i] === token) {
return true;
}
}
return false;
}
function AddRelToken(anchor, token) {
var rel = (anchor.getAttribute('rel') || '').trim();
var tokens = rel ? rel.split(/\s+/) : [];
if (!HasToken(tokens, token)) {
tokens.push(token);
}
anchor.setAttribute('rel', tokens.join(' ').trim());
}
function IsExternalHttpLink(url) {
if (!url) {
return false;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return false;
}
return url.origin !== window.location.origin;
}
function InitExternalLinksNewTab() {
var mainContent = document.querySelector('.main-content');
if (!mainContent) {
return;
}
var anchors = mainContent.querySelectorAll('a[href]');
for (var i = 0; i < anchors.length; i++) {
var anchor = anchors[i];
var href = (anchor.getAttribute('href') || '').trim();
if (!href) {
continue;
}
if (href.charAt(0) === '#') {
continue;
}
if (href.indexOf('mailto:') === 0 || href.indexOf('tel:') === 0 || href.indexOf('javascript:') === 0) {
continue;
}
var url = null;
try {
url = new URL(href, window.location.href);
} catch (e) {
continue;
}
if (!IsExternalHttpLink(url)) {
continue;
}
var target = (anchor.getAttribute('target') || '').trim();
if (!target) {
anchor.setAttribute('target', '_blank');
target = '_blank';
}
if (target === '_blank') {
AddRelToken(anchor, 'noopener');
AddRelToken(anchor, 'noreferrer');
}
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', InitExternalLinksNewTab);
return;
}
InitExternalLinksNewTab();
})();
+22
View File
@@ -0,0 +1,22 @@
(function () {
'use strict';
function InitImageZoom() {
if (window.mediumZoom === undefined) {
return;
}
window.mediumZoom('.main-content img:not(.no-zoom):not([src$=".svg"])', {
margin: 24,
background: 'rgba(0,0,0,0.80)',
scrollOffset: 0
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', InitImageZoom);
return;
}
InitImageZoom();
})();
+403
View File
@@ -0,0 +1,403 @@
(function () {
'use strict';
var scrollSpyDispose = null;
var resizeReinitTimerId = null;
var inlineMaxVisibleItems = 4;
function IsRightTocEnabled() {
var meta = document.querySelector('meta[name="ffu-right-toc"]');
if (meta && meta.content && meta.content.toLowerCase() === 'false') {
return false;
}
return true;
}
function IsDesktopViewport() {
try {
return window.matchMedia && window.matchMedia('(min-width: 66.5rem)').matches;
} catch (e) {
return false;
}
}
function RemoveExistingToc() {
if (scrollSpyDispose) {
scrollSpyDispose();
scrollSpyDispose = null;
}
var existingTocs = document.querySelectorAll('.page-toc');
for (var i = 0; i < existingTocs.length; i++) {
existingTocs[i].remove();
}
var wrap = document.querySelector('.main-content-wrap');
if (wrap) {
wrap.classList.remove('has-page-toc');
}
}
function InsertInlineToc(main, toc) {
if (!main || !toc) {
return;
}
var title = main.querySelector('h1');
if (title && title.parentNode === main) {
if (title.nextSibling) {
main.insertBefore(toc, title.nextSibling);
return;
}
main.appendChild(toc);
return;
}
if (main.firstChild) {
main.insertBefore(toc, main.firstChild);
return;
}
main.appendChild(toc);
}
function GetHeadings(container) {
var headings = container.querySelectorAll('h2, h3');
var results = [];
for (var i = 0; i < headings.length; i++) {
var heading = headings[i];
if (heading.classList.contains('no_toc')) {
continue;
}
var id = heading.getAttribute('id');
if (!id) {
continue;
}
var text = (heading.textContent || '').trim();
if (!text) {
continue;
}
results.push({
level: heading.tagName.toLowerCase(),
id: id,
text: text
});
}
return results;
}
function BuildToc(headings, options) {
var variant = (options && options.variant) ? options.variant : 'right';
var maxVisible = (options && options.maxVisible) ? options.maxVisible : 0;
var isInline = 'inline' === variant;
var nav = document.createElement('nav');
nav.className = 'page-toc' + (isInline ? ' page-toc--inline' : '');
nav.setAttribute('aria-label', 'On this page');
var title = document.createElement('div');
title.className = 'page-toc__title';
title.textContent = 'In this article';
nav.appendChild(title);
var list = document.createElement('ul');
list.className = 'page-toc__list';
list.id = 'page-toc-list';
for (var i = 0; i < headings.length; i++) {
var item = headings[i];
var li = document.createElement('li');
li.className = 'page-toc__item page-toc__item--' + item.level;
var a = document.createElement('a');
a.className = 'page-toc__link';
a.href = '#' + item.id;
a.textContent = item.text;
li.appendChild(a);
list.appendChild(li);
if (isInline && maxVisible > 0 && i >= maxVisible) {
li.classList.add('is-hidden');
}
}
nav.appendChild(list);
if (isInline && maxVisible > 0 && headings.length > maxVisible) {
var hiddenCount = headings.length - maxVisible;
var isExpanded = false;
var toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'page-toc__toggle';
toggle.setAttribute('aria-controls', list.id);
toggle.setAttribute('aria-expanded', 'false');
function SetToggleText() {
if (isExpanded) {
toggle.textContent = 'Show less';
} else {
toggle.textContent = 'Show ' + hiddenCount + ' more';
}
}
function SetHiddenState() {
var items = list.querySelectorAll('.page-toc__item');
for (var j = 0; j < items.length; j++) {
if (j >= maxVisible) {
if (isExpanded) {
items[j].classList.remove('is-hidden');
} else {
items[j].classList.add('is-hidden');
}
}
}
toggle.setAttribute('aria-expanded', isExpanded ? 'true' : 'false');
SetToggleText();
}
toggle.addEventListener('click', function () {
isExpanded = !isExpanded;
SetHiddenState();
});
SetHiddenState();
nav.appendChild(toggle);
}
return nav;
}
function SetActiveTocLink(toc, activeId, keepVisibleInPanel) {
if (!toc) {
return;
}
var links = toc.querySelectorAll('.page-toc__link');
for (var i = 0; i < links.length; i++) {
var link = links[i];
var href = link.getAttribute('href') || '';
var isActive = ('#' + activeId) === href;
if (isActive) {
link.classList.add('is-active');
if (keepVisibleInPanel) {
/* Keep the active item visible inside the TOC panel (desktop/right TOC only) */
try {
link.scrollIntoView({ block: 'nearest' });
} catch (e) {
link.scrollIntoView();
}
}
} else {
link.classList.remove('is-active');
}
}
}
function SetupScrollSpy(main, toc, headings) {
if (!main || !toc || !headings || headings.length < 1) {
return null;
}
/* Scrollspy is desktop-only */
if (!IsDesktopViewport()) {
return null;
}
var headingElements = [];
for (var i = 0; i < headings.length; i++) {
var el = document.getElementById(headings[i].id);
if (el) {
headingElements.push(el);
}
}
if (headingElements.length < 1) {
return null;
}
var activeId = null;
var ticking = false;
var lockActiveUntilMs = 0;
function IsNearBottomOfPage() {
var thresholdPx = 24;
var scrollY = window.scrollY || window.pageYOffset || 0;
var viewportBottom = scrollY + window.innerHeight;
var pageHeight = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight);
return viewportBottom >= (pageHeight - thresholdPx);
}
function GetCurrentHeadingId() {
/* If we're at the bottom, force the last heading active */
if (IsNearBottomOfPage()) {
return headingElements[headingElements.length - 1].getAttribute('id');
}
/* Choose the heading closest to the top "activation line" */
var activationLine = 16;
var current = null;
for (var i = 0; i < headingElements.length; i++) {
var rectTop = headingElements[i].getBoundingClientRect().top;
if (rectTop <= activationLine) {
current = headingElements[i];
continue;
}
if (null === current) {
current = headingElements[i];
}
break;
}
if (null === current) {
current = headingElements[0];
}
return current.getAttribute('id');
}
function Update() {
ticking = false;
/* If the viewport becomes narrow after load, avoid scroll fighting */
if (!IsDesktopViewport()) {
return;
}
if (Date.now() < lockActiveUntilMs) {
return;
}
var currentId = GetCurrentHeadingId();
if (!currentId || currentId === activeId) {
return;
}
activeId = currentId;
SetActiveTocLink(toc, activeId, true);
}
function OnScrollOrResize() {
if (ticking) {
return;
}
ticking = true;
window.requestAnimationFrame(Update);
}
function OnTocClick(evt) {
var target = evt.target;
if (!target || !target.classList || !target.classList.contains('page-toc__link')) {
return;
}
var href = target.getAttribute('href') || '';
if (href.charAt(0) !== '#') {
return;
}
var id = href.substring(1);
if (!id) {
return;
}
/* Prevent scrollspy from immediately overriding the clicked section */
lockActiveUntilMs = Date.now() + 800;
activeId = id;
SetActiveTocLink(toc, activeId, true);
}
window.addEventListener('scroll', OnScrollOrResize, { passive: true });
window.addEventListener('resize', OnScrollOrResize);
toc.addEventListener('click', OnTocClick);
Update();
return function DisposeScrollSpy() {
window.removeEventListener('scroll', OnScrollOrResize);
window.removeEventListener('resize', OnScrollOrResize);
toc.removeEventListener('click', OnTocClick);
};
}
function InitRightToc() {
if (!IsRightTocEnabled()) {
RemoveExistingToc();
return;
}
var main = document.querySelector('.main-content main');
if (!main) {
return;
}
var headings = GetHeadings(main);
if (headings.length < 2) {
RemoveExistingToc();
return;
}
if (IsDesktopViewport()) {
RemoveExistingToc();
var wrap = document.querySelector('.main-content-wrap');
if (!wrap) {
return;
}
wrap.classList.add('has-page-toc');
var toc = BuildToc(headings, { variant: 'right' });
wrap.appendChild(toc);
scrollSpyDispose = SetupScrollSpy(main, toc, headings);
return;
}
/* Narrow viewports: place TOC at the top of the article (Learn-like) */
RemoveExistingToc();
var inlineToc = BuildToc(headings, { variant: 'inline', maxVisible: inlineMaxVisibleItems });
InsertInlineToc(main, inlineToc);
}
function OnViewportResize() {
if (null !== resizeReinitTimerId) {
window.clearTimeout(resizeReinitTimerId);
}
resizeReinitTimerId = window.setTimeout(InitRightToc, 150);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
InitRightToc();
window.addEventListener('resize', OnViewportResize);
});
return;
}
InitRightToc();
window.addEventListener('resize', OnViewportResize);
})();
File diff suppressed because one or more lines are too long
+917
View File
@@ -0,0 +1,917 @@
---
title: Build
nav_order: 10
prev_url: /drivers.html
prev_label: Drivers
next_url: /monitor.html
next_label: Monitor
parent: UI Overview
---
# Build
![1776379303045](image/build/1776379303045.png)
The Build tab is where the magic happens
## FFU Development Path
The FFU Development path (`$FFUDevelopmentPath`) is the root path of where most other paths are derived. The default is `$PSScriptRoot`, which is the location the script is currently running from and can be changed to another location from within the UI.
If you want to download and test new releases, or want to create a new FFUDevelopment folder without modifying your existing one, you can always download the source files and put them in another location.
The recommendation is to run from `C:\FFUDevelopment` and in most cases the path shouldn't need to be changed.
## Custom FFU Name Template
Controls the `-CustomFFUNameTemplate` parameter. This allows you to define a custom naming convention for the captured FFU file using placeholders that are replaced at build time.
If left blank, the default FFU naming convention is used.
### Available Placeholders
| Placeholder | Description | Example |
| -------------------- | ---------------------- | ------------------------------------------------------------------------------ |
| `{WindowsRelease}` | Windows release number | `10`, `11`, `2016`, `2019`, `2022`, `2025` |
| `{WindowsVersion}` | Windows version | `1607`, `1809`, `21h2`, `22h2`, `23h2`, `24h2` |
| `{SKU}` | Windows edition | `Home`, `Pro`, `Enterprise`, `Education`, `Standard`, `Datacenter` |
| `{BuildDate}` | Month and year | `Nov2025` |
| `{yyyy}` | 4-digit year | `2025` |
| `{MM}` | 2-digit month | `11` (for November) |
| `{dd}` | 2-digit day | `28` |
| `{HH}` | Hour in 24-hour format | `14` (for 2 PM) |
| `{hh}` | Hour in 12-hour format | `02` (for 2 PM) |
| `{mm}` | 2-digit minute | `09` |
| `{tt}` | AM/PM designator | `AM` or `PM` |
### Examples
**Basic template with date and time:**
```
{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}
```
Result: `Win11_24h2_Pro_2025-11-28_1425.ffu`
**Template with static text (e.g., indicating Office is installed):**
```
{WindowsRelease}_{WindowsVersion}_{SKU}_Office_{yyyy}-{MM}-{dd}_{HH}{mm}
```
Result: `Win11_24h2_Pro_Office_2025-11-28_1425.ffu`
**Simple template with build date:**
```
{WindowsRelease}_{WindowsVersion}_{SKU}_{BuildDate}
```
Result: `Win11_24h2_Pro_Nov2025.ffu`
{: .note-title}
> Note
>
> The `.ffu` extension is automatically appended if not included in the template.
## FFU Capture Location
The FFU Capture Location sets the `-FFUCaptureLocation` parameter that determines where completed `.ffu` images are written. By default it points to `$FFUDevelopmentPath\FFU`, and the build script creates the folder automatically if it does not already exist.
When apps are installed in a VM, the build still uses the VM for application installs and sysprep, but the actual FFU capture now happens on the host after the VHDX is optimized and remounted. That means completed images are written directly to this folder without creating a temporary SMB share, temporary local account, or capture ISO.
Choose a path on fast storage with plenty of free space—the directory must be local to the host running `BuildFFUVM.ps1`, and large captures can easily exceed 2530 GB. This location also feeds other options such as **Copy Additional FFU Files**, **Build USB Drive**, and **Remove FFU**, so keeping all finished images here keeps those workflows simple.
## Threads
Controls the `-Threads` parameter, which sets the number of parallel threads used for concurrent operations throughout FFU Builder. The default value is **5**.
### Operations Affected by Threads
The Threads value applies to the following parallel operations:
| Operation | Description |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| **Winget Application Downloads** | When downloading multiple Winget applications, each application download runs as a parallel task |
| **BYO Application Copy** | When copying multiple Bring Your Own (BYO) applications to the Apps folder, each copy operation runs in parallel |
| **Driver Downloads** | When downloading drivers for multiple device models, each driver download and extraction runs as a parallel task |
### Recommended Values
| Threads | Use Case |
| -------------- | ------------------------------------------------------------------------- |
| **1** | Minimal system impact; useful for troubleshooting or low-resource systems |
| **5** | Default; balanced performance for most systems |
| **8-10** | Higher concurrency for systems with fast storage and network connections |
{: .note-title}
> Note
>
> Setting a higher thread count may improve download times but will increase resource utilization. If you experience stability issues or resource constraints, try reducing the thread count.
### Validation
The UI validates that the Threads value is a valid integer greater than or equal to 1. If an invalid value is entered, it automatically resets to **1**.
## BITS Priority
Controls the `-BitsPriority` parameter, which determines the priority level for Background Intelligent Transfer Service (BITS) downloads. The default value is **Normal**.
If you want faster downloads, change the priority to Foreground. Normal priority will significantly slow down downloads since BITS treats non-Foreground downloads as synchronous and queues each download. This means multiple driver or winget application downloads will go much slower than using Foreground. Normal is default as per Microsoft best practice guidance for using BITS.
## General Build Options Expander
This expander groups the core build behaviors that affect how the FFU is created, optimized, cached, and prepared for deployment.
### Compact OS
Controls the `-CompactOS` parameter. When checked, the Windows image is applied using compressed files. The default is **checked**.
#### How It Works
When enabled, the build script uses the `-Compact` switch with `Expand-WindowsImage` when applying the Windows image to the OS partition. This compresses Windows system files using Compact OS compression, which reduces the disk footprint of the operating system. On an x64 image, space savings is ~3.5-4GB.
#### Benefits
| Benefit | Description |
| ------------------------------- | ---------------------------------------------------------------------------------------------------- |
| **Reduced Disk Space** | Windows files are stored in a compressed state, saving several gigabytes of storage |
| **Smaller FFU Size** | The captured FFU file is smaller because the OS partition contains compressed files |
| **Faster Deployment** | Smaller FFU files transfer more quickly to USB drives and deploy faster to target devices |
| **No Performance Impact** | Modern CPUs decompress files faster than they can be read from storage, so performance is maintained |
#### When to Disable
You may want to disable Compact OS in the following scenarios:
- **Windows Server builds**: The script automatically disables Compact OS for Windows Server operating systems because the Windows Overlay Filter (wof.sys) is not included in Server SKUs
- **Troubleshooting**: If you experience issues with specific applications that are incompatible with compressed files
- **Maximum performance requirements**: In rare cases where every CPU cycle matters
{: .note-title}
> Note
>
> Compact OS is automatically disabled when building Windows Server images, regardless of this setting. The script detects Server operating systems and applies the Windows image without compression.
### Update ADK
Controls the `-UpdateADK` parameter. When checked, the script checks for and installs or updates to the latest Windows ADK and WinPE add-on before starting the build. The default is **checked**.
#### How It Works
When enabled, the build process performs the following checks before starting:
1. **Version Check**: Queries the [Microsoft ADK installation page](https://learn.microsoft.com/en-us/windows-hardware/get-started/adk-install) to determine the latest available ADK version
2. **Compare Versions**: Compares the installed ADK and WinPE add-on versions (if present) against the latest available version
3. **Update if Needed**: If an older version is detected:
- Uninstalls the existing Windows ADK
- Uninstalls the existing WinPE add-on
- Downloads and installs the latest Windows ADK with Deployment Tools feature
- Downloads and installs the latest WinPE add-on
#### Features Installed
When installing or updating the ADK, the following features are included:
| Component | Feature ID | Description |
| ---------------------------------- | ---------------------------------------------- | --------------------------------------------------------------- |
| **Windows Deployment Tools** | `OptionId.DeploymentTools` | Includes DISM, Oscdimg, and other deployment-related tools |
| **WinPE Environment** | `OptionId.WindowsPreinstallationEnvironment` | Windows Preinstallation Environment used for capture and deploy |
#### Installation Location
The ADK is installed to the default location: `C:\Program Files (x86)\Windows Kits\10`
#### When to Disable
You may want to disable Update ADK in the following scenarios:
- **Offline or air-gapped environments**: When internet access is not available to download the latest ADK
- **Controlled ADK versions**: When you need to maintain a specific ADK version for compatibility or compliance reasons
- **Faster builds**: When you have already verified you are running the latest ADK version and want to skip the version check
{: .warning-title}
> Warning
>
> If Update ADK is disabled and the Windows ADK or WinPE add-on is not installed, the build will fail. Ensure you have manually installed the required components before disabling this option.
#### Manual ADK Installation
If you prefer to manually install the ADK, visit:
[Download and install the Windows ADK](https://learn.microsoft.com/en-us/windows-hardware/get-started/adk-install)
You must install both:
- Windows Assessment and Deployment Kit (with Deployment Tools feature)
- Windows PE add-on for the Windows ADK
### Optimize
Controls the `-Optimize` parameter. When enabled, FFU Builder runs the Windows ADK version of DISM to optimize the captured `.ffu` file:
- `DISM /Optimize-FFU /ImageFile:<path-to-ffu>`
This post-processing step typically takes a few minutes and is intended to make FFU images faster to deploy and easier to deploy to differently-sized disks (by allowing the Windows partition to expand or shrink during apply).
**Default:** Enabled (`-Optimize $true`)
#### When to Disable
You may want to disable Optimize (`-Optimize $false`) if you are troubleshooting, or if you want to skip the extra post-processing time.
{: .warning-title}
> Warning
>
> If you plan to deploy the same FFU to devices with different storage sizes (especially smaller disks), keep `-Optimize` enabled. Non-optimized FFUs are more likely to require additional partition management during deployment.
{: .note-title}
> Note
>
> FFU Builder also performs a separate “optimize VHDX before capture” step. That VHDX optimization is independent of `-Optimize`, so you may still see “Optimizing VHDX before capture…” even when `-Optimize` is disabled.
### Allow VHDX Caching
Controls the `-AllowVHDXCaching` parameter. When enabled, FFU Builder caches the base VHDX it creates in `$FFUDevelopmentPath\VHDXCache` and writes a matching `*_config.json` file alongside it. On later builds, if a cached VHDX exists that matches your selected Windows settings and update set, the script reuses it to avoid re-applying the base image and integrating updates again.
**Default:** Disabled (`-AllowVHDXCaching $false`)
#### Cache Matching
A cached VHDX is reused only when the cache metadata matches your current build inputs, including:
- Windows release, version, and SKU
- Logical sector size (512 vs 4096)
- Optional features selection
- The exact set of update payload file names downloaded for that run (SSU/CU/.NET/etc.)
#### Disk Usage and Cleanup
VHDX caching trades disk space for speed. The `VHDXCache` folder can grow over time as you build different combinations. Periodically check the folder and remove old cached vhdx and config json files as necessary.
{: .note-title}
> Note
>
> To force a full rebuild, delete the contents of `$FFUDevelopmentPath\VHDXCache` (or disable **Allow VHDX Caching**) and run the build again.
### Create Deployment Media
Controls the `-CreateDeploymentMedia` parameter.
When enabled, FFU Builder creates WinPE deployment media that is used to deploy an FFU image to a physical device. This media contains the WinPE environment and deployment scripts needed to boot a target machine and apply the FFU image.
The deployment media is saved as an ISO file at `$FFUDevelopmentPath\WinPE_FFU_Deploy_x64.iso` (or `WinPE_FFU_Deploy_arm64.iso` for ARM64 builds). This ISO can then be used with the **Build USB Drive** option to create bootable USB media for physical deployments.
**Default:** Enabled (`-CreateDeploymentMedia $true`)
{: .note-title}
> Note
>
> If you only need to capture FFUs from VMs and do not plan to deploy to physical devices, you can disable this option to save time during the build process. However, most scenarios require deployment media for the final step of applying the FFU to target hardware.
{: .tip-title}
> Tip
>
> If you just need to re-create deployment media, you can use the `Create-PEMedia.ps1` script to regenerate the deploy ISO without running a full build.
### Verbose
Controls the `-Verbose` common parameter. When checked, enables detailed verbose output during the build process. The default is **unchecked**.
In prior builds it was necessary to enable `-verbose` output to track in real-time the build process if you didn't have `cmtrace.exe` or some other log-monitoring tool. With the UI, you can now watch the build in real-time using the monitor tab. Enabling verbose shouldn't be necessary but is available for those who wish to use it.
## Unattend.xml Options Expander
Use the **Unattend.xml Options** expander to choose how unattend content is staged and which source XML file FFU Builder should use for x64 and arm64 builds.
### x64 Unattend File Path
Use **x64 Unattend File Path** to browse to the source XML file for x64 builds. The default path is `.\FFUDevelopment\unattend\unattend_x64.xml`.
### arm64 Unattend File Path
Use **arm64 Unattend File Path** to browse to the source XML file for arm64 builds. The default path is `.\FFUDevelopment\unattend\unattend_arm64.xml`.
### Inject Unattend.xml
Controls the `-InjectUnattend` parameter. When checked, stages the XML file selected for the current architecture in **Unattend.xml Options** into the Apps ISO so it's baked into the FFU during the VM build process. The default is **unchecked**.
This option is used only when **Install Apps** is checked.
`Copy Unattend.xml` and `Inject Unattend.xml` are mutually exclusive. Select only one.
#### How It Works
When enabled, the build process:
1. Uses the x64 or arm64 source file selected in **Unattend.xml Options** for the current build architecture
2. Creates an `Unattend` folder inside `.\FFUDevelopment\Apps` if it doesn't exist
3. Copies that file to `.\FFUDevelopment\Apps\Unattend\Unattend.xml`
4. Includes the unattend file in the Apps ISO, making it available to sysprep during the VM build
The unattend file is then used by sysprep during the specialize phase and/or other OOBE phases when the FFU is deployed.
#### Creating Your Unattend Files
You can keep the default architecture-specific files in the `.\FFUDevelopment\unattend` folder or browse to another XML file in the UI:
| File | Description |
| ---------------------------- | ----------------------------------- |
| **unattend_x64.xml** | Unattend file used for x64 builds |
| **unattend_arm64.xml** | Unattend file used for arm64 builds |
{: .warning-title}
> Important
>
> The default paths use the architecture suffix file names shown above. FFU Builder still renames the selected file to `Unattend.xml` when it stages it into the Apps folder.
#### When to Use This Option
This option is primarily intended for scenarios where:
* You are **not using the USB drive** to deploy the FFU and use other deployment methods (e.g., network deployment, disk cloning, etc)
* You want the unattend configuration **baked directly into the FFU** rather than applied at deployment time
#### Limitations
| Limitation | Description |
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **No prefixes.txt, SerialComputerNames.csv, or %serial% support** | Unlike**Copy Unattend.xml**, this method does not support `prefixes.txt`, `SerialComputerNames.csv`, or the `%serial%` variable for deployment-time device naming |
| **Fixed configuration** | The unattend settings are baked into the FFU at build time and cannot be changed at deployment time |
| **Requires VM to be built** | This option only works when**Install Apps** is `$true` because the unattend file is included in the Apps ISO |
{: .note-title}
> Note
>
> Most users should continue using the **Copy Unattend** option via the USB drive, which provides more flexibility including support for `prefixes.txt`, `SerialComputerNames.csv`, and `%serial%` device naming. Use **Inject Unattend.xml** only when you won't be using the USB drive for deployment.
{: .tip-title}
> Tip
>
> If you're using this option, you can disable **Build Deploy ISO** to save time during the build process since the deployment ISO is not needed when you're not using the USB drive method.
### Copy Unattend.xml
Controls the `-CopyUnattend` parameter. When checked, stages the XML file selected for the current architecture in **Unattend.xml Options** to an `Unattend` folder on the Deployment partition of the USB drive. The default is **unchecked**.
Use this option when you plan to build deployment USB media.
When enabled, the build process copies:
- The selected x64 or arm64 unattend XML file -> renamed to **Unattend.xml** on the USB drive
- **prefixes.txt** -> created from the **Device Naming** prefixes list when that mode is selected
- **SerialComputerNames.csv** -> created from the **Device Naming** serial mapping list when that mode is selected
If you keep the default file paths in place, FFU Builder uses `unattend_x64.xml` for x64 builds and `unattend_arm64.xml` for arm64 builds.
During deployment, `ApplyFFU.ps1` applies `Unattend.xml` whenever it is present. Device naming only happens when the **Device Naming** setting requires it, or when older media still uses the legacy prompt-based workflow.
See **Device Naming Expander** below for the available computer-name modes and naming-file behavior.
## Device Naming Expander
Use the **Device Naming** expander to decide whether `ComputerName` should be set at deployment time when unattend is applied. There are some major benefits to doing this:
1. Total deployment time is reduced if naming is set at FFU deployment time since there is no additional reboot done during OOBE.
2. Reduces the need for multiple provisioning packages or autopilot profiles. This means you can use a single PPKG or autopilot profile.
### No Device Name
This is the default radio selection in the UI.
- If you leave device naming untouched, FFU Builder does not write `DeviceNamingMode` to the generated config. This preserves the script's `Legacy` default, so an existing `FFUDevelopment\Unattend\prefixes.txt` file is still copied to deployment media when present.
- If you explicitly select this option, FFU Builder sets `DeviceNamingMode = None`. The unattend file is still applied, but Windows generates a random computer name instead of forcing a prompt or a fixed name.
The active `unattend_*.xml` files in `FFUDevelopment\Unattend` use `<ComputerName>*</ComputerName>` in the current sample files.
### Prompt for Device Name
Use this option when you want the technician to enter the computer name during deployment.
- FFU Builder sets `DeviceNamingMode = Prompt`.
- This option requires **Copy Unattend.xml**.
- The source `unattend_*.xml` files can stay at `<ComputerName>*</ComputerName>`.
- During the build, FFU Builder rewrites only the staged deployment copy of `Unattend.xml` to the legacy prompt placeholder that `ApplyFFU.ps1` already recognizes.
- **Inject Unattend.xml** is not supported with this option.
### Specify Device Name
Use this option when you want a static device name or a template such as `Comp-%serial%`.
- FFU Builder sets `DeviceNamingMode = Template`.
- With **Copy Unattend.xml**, `%serial%` is resolved during deployment in PE.
- With **Inject Unattend.xml**, only static names are supported.
- **Copy Unattend.xml** and **Inject Unattend.xml** are mutually exclusive. Select only one.
### Specify a list of Prefixes
This option writes `prefixes.txt` from the list in the UI. Enter one prefix per line in the multiline prefixes box. If there is a single prefix, deployment uses it automatically. If there are multiple prefixes, the technician is prompted to select one. The selected prefix is combined with the device serial number to create the computer name.
- FFU Builder sets `DeviceNamingMode = Prefixes`.
For example, with a prefix of `CORP-` and a serial number of `ABC123`, the resulting computer name would be `CORP-ABC123` (truncated to 15 characters if necessary).
Sample `prefixes.txt` content:
```plaintext
CORP-
STORE-
KIOSK-
```
{: .note-title}
> Note
>
> If the technician skips prefix selection when multiple prefixes are available, `ApplyFFU.ps1` leaves the existing unattend `ComputerName` value unchanged. With the current unattend samples set to `<ComputerName>*</ComputerName>`, Windows falls back to its default random computer-name behavior, typically resulting in a name such as `WIN-*`.
### Prefixes File Path
Use **Prefixes File Path** to point the UI at the source text file for the prefixes list. The file can use any name. When you browse to a prefixes file in the UI, or when a saved configuration references a valid prefixes path, the UI loads that file and populates the multiline prefixes box from its contents.
### Save Prefixes
Use **Save Prefixes** to write the current multiline prefixes list back to the file specified in **Prefixes File Path**.
### Specify Serial to Device Name Mapping
This option writes `SerialComputerNames.csv` from the CSV content in the UI. Use `SerialNumber,ComputerName` as the header row, then add one row per device. During deployment, `ApplyFFU.ps1` compares the current BIOS serial number to the `SerialNumber` column and uses the matching `ComputerName` value.
- FFU Builder sets `DeviceNamingMode = SerialComputerNames`.
Sample `SerialComputerNames.csv` content:
```plaintext
SerialNumber,ComputerName
ABC12345,CORP-001
DEF67890,KIOSK-010
XYZ24680,STORE-015
```
- This option requires **Copy Unattend.xml**.
- **Inject Unattend.xml** is not supported with this option.
- If no matching serial number is found during deployment, `ApplyFFU.ps1` falls back to a random `FFU-*` computer name so setup can finish.
{: .note-title}
> Note
>
> If `prefixes.txt` and `SerialComputerNames.csv` are both staged manually on the same deployment media, `ApplyFFU.ps1` checks `prefixes.txt` first. FFU Builder avoids this conflict by only staging the naming file for the selected device-naming mode.
### Serial Computer Names CSV Mapping File Path
Use **Serial Computer Names CSV Mapping File Path** to point the UI at the source CSV file for the serial-to-device-name mapping. The file can use any name. When you browse to a mapping file in the UI, or when a saved configuration references a valid CSV path, the UI loads that file and populates the multiline CSV box from its contents.
### Save Serial Mapping
Use **Save Serial Mapping** to write the current CSV content back to the file specified in **Serial Computer Names CSV Mapping File Path**.
### Deployment Prompt Compatibility
Older deployment media that already has an unattend file with `ComputerName` set to the legacy placeholder value and no `prefixes.txt` file will still prompt for a device name during deployment.
{: .warning-title}
> Warning
>
> If using a provisioning package or autopilot json file, DO NOT specify a name in either of these. They will overwrite the name you have specified in the unattend.xml.
### Creating Your Unattend Files
The `.\FFUDevelopment\Unattend` folder includes sample files you can customize:
| File | Description |
| --------------------------------------- | ------------------------------------------ |
| **SampleUnattend_x64.xml** | Example unattend file for x64 systems |
| **unattend_x64.xml** | Active unattend file used for x64 builds |
| **unattend_arm64.xml** | Active unattend file used for arm64 builds |
| **SamplePrefixes.txt** | Example prefixes file for device naming |
| **SampleSerialComputerNames.csv** | Example serial-to-device-name CSV file |
Copy and customize the sample files to create your own `unattend_x64.xml`, `unattend_arm64.xml`, `prefixes.txt`, and `SerialComputerNames.csv` files.
{: .note-title}
> Note
>
> The unattend file must contain a `<ComputerName>` element in the `Microsoft-Windows-Shell-Setup` component for device naming to work. See the sample files for the correct structure.
## Build USB Drive Options Expander
This expander groups the settings used to create deployment USB drives after the FFU and deployment media are ready.
### Build USB Drive
Controls the `-BuildUSBDrive` parameter. When checked, FFU Builder partitions and formats selected USB drives and copies the captured FFU plus the enabled deployment assets to them. The default is **unchecked**.
The remaining settings in this expander apply only when **Build USB Drive** is enabled.
### Allow External Hard Disk Media
Controls the `-AllowExternalHardDiskMedia` parameter. When checked, allows the use of drives identified as "External hard disk media" via the WMI class `Win32_DiskDrive`. The default is **unchecked**.
Most USB thumb drives are identified by Windows as "Removable Media" and work with the default settings. However, faster USB drives—such as portable SSDs or high-speed USB 3.x drives—may be identified as "External hard disk media" instead. If you want to use these faster drives for imaging, enable this option.
{: .warning-title}
> Warning
>
> Enabling this option may expose external hard drives attached to your machine to the USB imaging process. To prevent accidental data loss, use the **Prompt for External Hard Disk Media** option (enabled by default when this option is checked) to confirm which drive to use before formatting.
### Prompt for External Hard Disk Media
Controls the `-PromptExternalHardDiskMedia` parameter. When checked, prompts for user confirmation before using any drive identified as "External hard disk media". The default is **checked** when **Allow External Hard Disk Media** is enabled.
This option is only available when **Allow External Hard Disk Media** is checked.
When enabled, the build process will:
1. Display a table listing all detected external hard disk media drives, including drive name, serial number, partition style, and status.
2. Prompt you to select which drive to use for imaging.
3. Only create a USB drive on the selected drive.
When disabled, the script will not prompt and can use multiple external hard disk drives simultaneously, similar to how removable USB drives function. This is useful for automated or batch imaging scenarios but increases the risk of accidental data loss.
{: .note-title}
> Note
>
> If you do not want to be prompted each time, you can disable this option after verifying that only your intended imaging drives are connected.
### Select Specific USB Drives
When checked, enables manual selection of specific USB drives for imaging. The default is **unchecked**.
This option is only available when **Build USB Drive** is checked.
When enabled, a **Check USB drives** button and a list view appear. Click **Check USB drives** to scan for connected USB drives. The list displays all detected drives with the following information:
| Column | Description |
| ------------------- | ----------------------------------------------------- |
| **Select** | Checkbox to include or exclude the drive from imaging |
| **Model** | The model name of the USB drive |
| **Unique ID** | A unique identifier for the drive |
| **Size (GB)** | The total capacity of the drive in gigabytes |
Select one or more drives by checking the checkbox in the **Select** column. Only selected drives will be formatted and used for imaging when the build completes.
Use the **Select All** checkbox in the column header to quickly select or deselect all drives.
{: .note-title}
> Note
>
> If **Select Specific USB Drives** is unchecked, the build process will automatically use all discovered USB drives.
### Copy Autopilot Profile
Controls the `-CopyAutopilot` parameter. When checked, copies the contents of `.\FFUDevelopment\Autopilot` to the `Autopilot` folder on the Deployment partition of the USB drive. The default is **unchecked**.
This option is only available when **Build USB Drive** is checked.
This leverages the Autopilot for existing devices json file. It's not recommended to use this method any longer as devices enrolled via this method are enrolled as personal instead of corporate.
### Copy Provisioning Package
Controls the `-CopyPPKG` parameter. When checked, copies the contents of `.\FFUDevelopment\PPKG` to the `PPKG` folder on the Deployment partition of the USB drive. The default is **unchecked**.
This option is only available when **Build USB Drive** is checked.
#### How It Works
1. **During Build**: The build process copies all `.ppkg` files from `.\FFUDevelopment\PPKG` to the USB drive.
2. **During Deployment**: When `ApplyFFU.ps1` runs, it detects the `PPKG` folder and the provisioning packages within it.
- If **multiple** `.ppkg` files are found, the technician is prompted to select which package to apply.
- If **one** `.ppkg` file is found, it is automatically selected.
3. **Application**: The selected provisioning package is copied to the root of the USB drive, where Windows picks it up during OOBE and applies the settings.
### Copy Additional FFU Files
Controls the `-CopyAdditionalFFUFiles` parameter. When checked, allows you to select existing FFU files from the FFU Capture Location to copy to the USB drive alongside the newly built FFU. The default is **unchecked**.
This option is only available when **Build USB Drive** is checked.
#### How It Works
When enabled, an **Additional FFU Files** panel appears below the checkbox with the following controls:
| Control | Description |
| ----------------------- | ----------------------------------------------------------------------------------------------- |
| **Refresh** | Scans the FFU Capture Location folder for existing `.ffu` files and populates the list |
| **FFU Name** | The filename of the FFU file |
| **Last Modified** | The date and time the FFU file was last modified, useful for identifying the most recent builds |
The list displays all `.ffu` files found in the FFU Capture Location (default `.\FFUDevelopment\FFU`). Click on individual rows to select which FFU files you want to include on the USB drive. Selected files are highlighted in the list.
#### Use Cases
- **Multiple device configurations**: Copy different FFU files for different windows/application configurations (e.g., different versions of windows, different application stacks) to a single USB drive, allowing technicians to choose during deployment.
- **Previous builds**: Include a known-good FFU from a previous build alongside the new build as a fallback option.
- **Multi-architecture imaging**: Include both x64 and arm64 FFU files on the same USB drive for mixed-architecture environments.
#### Command Line Usage
When running `BuildFFUVM.ps1` from the command line with `-CopyAdditionalFFUFiles $true` and no `-AdditionalFFUFiles` parameter specified, the script displays an interactive prompt listing all available FFU files in the capture folder. You can:
- Enter numbers separated by commas (e.g., `1,3,5`) to select specific files
- Enter `A` to select all available files
- Press **Enter** to skip and not include any additional files
Example command line usage with pre-selected files:
```powershell
.\BuildFFUVM.ps1 -configFile .\config\FFUConfig.json -CopyAdditionalFFUFiles $true -AdditionalFFUFiles @("C:\FFUDevelopment\FFU\Win11_24h2_Pro_Nov2025.ffu", "C:\FFUDevelopment\FFU\Win11_24h2_Enterprise_Nov2025.ffu")
```
{: .note-title}
> Note
>
> The newly captured FFU from the current build is always copied to the USB drive. Additional FFU files selected here are copied in addition to the new FFU.
### Max USB Drives
Controls the `-MaxUSBDrives` parameter, which sets the maximum number of USB drives to build in parallel. The default value is **5**.
This option is only available when **Build USB Drive** is checked.
When building USB drives, the script processes multiple drives concurrently to speed up imaging. This setting controls how many drives are formatted and copied to simultaneously.
## Post-Build Cleanup Expander
This expander groups the cleanup settings that run after a successful build completes.
### Cleanup Apps ISO
Controls the `-CleanupAppsISO` parameter. When checked, the Apps ISO file is automatically deleted after the FFU has been successfully captured. The default is **checked**.
During the build process, when apps are being installed, the script creates an `Apps.iso` file in the FFU Development Path (for example, `.\FFUDevelopment\Apps.iso`). This ISO contains the contents of the `.\FFUDevelopment\Apps` folder, including application installers, Office deployment files, and orchestration scripts, and is mounted to the VM during the build to install applications.
#### When to Disable
You may want to disable Cleanup Apps ISO in the following scenarios:
- **Debugging app installations**: When troubleshooting application installation issues and you want to manually inspect the ISO contents
- **Multiple builds with same apps**: If you're running consecutive builds with identical app configurations and want to reuse the existing ISO to save time (the script will recreate it if missing)
- **Archival purposes**: When you need to retain a copy of the exact Apps ISO used for a specific FFU build
{: .note-title}
> Note
>
> The Apps ISO is only created when applications are configured for installation. If no apps are being installed in the FFU, this option has no effect. Keeping this option enabled helps conserve disk space by removing temporary build artifacts.
### Cleanup Deploy ISO
Controls the `-CleanupDeployISO` parameter. When checked, the WinPE deployment ISO file is automatically deleted after the FFU has been successfully captured. The default is **checked**.
During the build process, when **Build Deploy ISO** is enabled, the script creates a `WinPE_FFU_Deploy.iso` file (for example, `.\FFUDevelopment\WinPE_FFU_Deploy.iso`). This ISO contains a customized Windows PE environment used to deploy captured FFU images to target devices. The deployment ISO is typically copied to a bootable USB drive along with the FFU files for field deployment.
#### When to Disable
You may want to disable Cleanup Deploy ISO in the following scenarios:
- **Creating deployment media separately**: When you want to create USB deployment drives at a later time, see [USB Imaging Tool Creator](/FFU/usb_imaging_tool_creator.html) for a staged workflow using `USBImagingToolCreator.ps1` with a deploy ISO, `FFU`, and `Drivers` folder (local path or network share)
- **Testing in Hyper-V**: When deploying FFU images to Hyper-V VMs for testing, you can attach the deploy ISO directly to a VM as a DVD drive
### Cleanup Drivers
Controls the `-CleanupDrivers` parameter. When checked, the contents of the Drivers folder are automatically deleted after the FFU has been successfully captured. The default is **unchecked**.
During the build process, when drivers are configured for installation, the script downloads and extracts driver packages into manufacturer-specific subfolders within the Drivers folder (for example, `.\FFUDevelopment\Drivers\HP`, `.\FFUDevelopment\Drivers\Dell`). These drivers are then injected into the FFU during the build.
#### When to Enable
You may want to enable Cleanup Drivers in the following scenarios:
- **Conserving disk space**: Driver packages can be large (several gigabytes per manufacturer), and removing them after a successful build frees up storage
- **Ensuring fresh drivers**: When you want each build to download the latest available drivers rather than reusing previously downloaded versions
- **Single-use builds**: When building an FFU for a one-time deployment and you don't need to retain the driver files
#### When to Disable
You may want to keep Cleanup Drivers disabled in the following scenarios:
- **Multiple builds with same drivers**: If you're running consecutive builds targeting the same hardware models, keeping drivers avoids re-downloading them each time
- **Debugging driver issues**: When troubleshooting driver injection problems and you want to manually inspect the downloaded driver contents
- **Offline builds**: When building in an environment with limited or no internet access, retaining drivers allows reuse across builds
- **Bring Your Own Drivers**: When you download and bring your own set of drivers from another source and don't want FFU Builder to remove them
{: .note-title}
> Note
>
> Only the contents within the Drivers folder are removed. The folder itself is preserved. If no drivers were downloaded during the build, this option has no effect.
### Remove FFU
Controls the `-RemoveFFU` parameter. When checked, all FFU files in the FFU Capture Location are automatically deleted after the build completes successfully. The default is **unchecked**.
During the build process, the captured FFU image is written to the FFU Capture Location (for example, `.\FFUDevelopment\FFU`). This option removes all `.ffu` files from that folder after the build finishes, including any previously captured FFU files that may exist in the folder.
#### When to Enable
You may want to enable Remove FFU in the following scenarios:
- **USB-only workflow**: When you're using **Build USB Drive** to copy the FFU directly to a USB drive and don't need to retain the FFU file on the host machine
- **Conserving disk space**: FFU files can be very large depending on what you're installing, and removing them after copying to USB frees up storage
- **Automated build pipelines**: When running automated builds where the FFU is immediately transferred to another location (such as a network share or deployment server) and no longer needed locally
#### When to Disable
You may want to keep Remove FFU disabled in the following scenarios:
- **Archival purposes**: When you want to retain captured FFU images for future deployments or as a backup
- **Multiple USB drives**: When you need to create additional USB deployment drives at a later time
- **Testing and validation**: When you want to test the FFU in Hyper-V or other environments before deploying to physical hardware
{: .warning-title}
> Warning
>
> This option removes **all** FFU files in the FFU Capture Location folder, not just the FFU from the current build. If you have previously captured FFU files stored in this folder that you want to keep, do not enable this option or move those files to a different location before building.
### Remove Apps Folder Content
Controls the `-RemoveApps` parameter. When checked, application content in the Apps folder is automatically deleted after the FFU has been successfully captured. The default is **unchecked**.
During the build process, application content accumulates in several subfolders within the Apps folder (for example, `.\FFUDevelopment\Apps`):
| Folder | Contents |
| ----------- | ------------------------------------------------------------------------------------------------------------------------ |
| `Win32` | Winget source applications and Bring Your Own Apps content copied using the**Copy Apps** button or manually copied |
| `MSStore` | Microsoft Store applications downloaded via Winget |
| `Office` | Microsoft 365 Apps installer files downloaded by the Office Deployment Tool |
Additionally, the `WinGetWin32Apps.json` orchestration file in `.\FFUDevelopment\Apps\Orchestration` is removed. This file is automatically regenerated at build time based on downloaded applications.
When this option is enabled, the cleanup process removes:
- The entire `Win32` folder and its contents
- The entire `MSStore` folder and its contents
- The Office download subfolder (`Office\Office`) and the `setup.exe` file within the `Office` folder
#### When to Enable
You may want to keep Remove Apps Folder Content enabled in the following scenarios:
- **Conserving disk space**: Downloaded application installers can consume significant storage, and removing them after a successful build frees up space
- **Ensuring fresh downloads**: When you want each build to download the latest available application versions rather than reusing previously downloaded content
- **Single-use builds**: When building an FFU for a one-time deployment and you don't need to retain the application files
#### When to Disable
You may want to disable Remove Apps Folder Content in the following scenarios:
- **Multiple builds with same apps**: If you're running consecutive builds with identical application configurations, keeping the downloaded content avoids re-downloading applications each time
- **Debugging app installations**: When troubleshooting application installation issues and you want to manually inspect the downloaded content
- **Offline builds**: When building in an environment with limited or no internet access, retaining downloaded applications allows reuse across builds
- **Preserving Bring Your Own Apps**: When you've manually copied application content into the `Win32` folder and don't want FFU Builder to remove it
{: .note-title}
> Note
>
> Only the application content subfolders are removed. The `Apps` folder itself and configuration files such as `AppList.json` and `UserAppList.json` are preserved. If no applications were configured for the build, this option has no effect.
### Remove Downloaded Update Files
Controls the `-RemoveUpdates` parameter. When checked, downloaded Windows updates and application update payloads are automatically deleted after the FFU has been successfully captured. The default is **unchecked**.
During the build process, update files are downloaded to specific locations within the `FFUDevelopment` folder:
| Folder | Contents |
| ----------------- | ---------------------------------------------------------- |
| `KB` | Windows Cumulative Updates (CU) and .NET Framework updates |
| `Apps\Defender` | Microsoft Defender definition updates |
| `Apps\Edge` | Microsoft Edge browser installer |
| `Apps\MSRT` | Malicious Software Removal Tool updates |
| `Apps\OneDrive` | Microsoft OneDrive installer |
When this option is enabled, the cleanup process removes the entire `KB` folder and the specific update subfolders within the `Apps` directory.
#### When to Enable
You may want to keep Remove Downloaded Update Files enabled in the following scenarios:
- **Conserving disk space**: Windows Cumulative Updates can be several gigabytes in size, and removing them after a successful build frees up significant storage
- **Ensuring latest updates**: When you want each build to download the absolute latest available updates rather than potentially reusing older cached versions
#### When to Disable
You may want to disable Remove Downloaded Update Files in the following scenarios:
- **Multiple builds**: If you're running consecutive builds, keeping the downloaded updates avoids re-downloading large Cumulative Update files each time
- **Offline builds**: When building in an environment with limited or no internet access, retaining downloaded updates allows reuse across builds
- **Testing and validation**: When you want to manually inspect the update files that were included in the build
{: .note-title}
> Note
>
> Only the update-specific subfolders are removed. The `Apps` folder itself and other application content, unless **Remove Apps Folder Content** is also selected, are preserved.
### Remove Downloaded ESD file(s)
Controls the `-RemoveDownloadedESD` parameter. When checked, downloaded Windows ESD files are automatically deleted after they have been applied. The default is **checked**.
This setting applies to builds that use downloaded Windows ESD media instead of a provided ISO. When enabled, the build removes the downloaded `.esd` file after it has been used. When disabled, the downloaded `.esd` is kept for reuse on later builds.
#### When to Enable
You may want to keep Remove Downloaded ESD file(s) enabled in the following scenarios:
- **Conserving disk space**: Downloaded ESD files can be large, and removing them after a successful build frees up storage
- **Ensuring fresh media**: When you want each build to download the latest available ESD for the selected release and version
- **Single-use builds**: When you do not expect to reuse the same downloaded source media again
#### When to Disable
You may want to disable Remove Downloaded ESD file(s) in the following scenarios:
- **Multiple builds with the same source media**: Keeping the ESD avoids re-downloading it each time
- **Offline or bandwidth-constrained environments**: Retaining the ESD allows reuse across builds
- **Troubleshooting source-media issues**: When you want to preserve the downloaded ESD for inspection or repeat testing
{: .note-title}
> Note
>
> This option only applies when the build used a downloaded `.esd` file. If you provide a Windows ISO instead, this setting has no effect.
## Build Page Actions
These buttons sit below the Build tab expanders and operate on the overall page state rather than a single expander.
### Restore Defaults
Use this to restore FFU Builder to its default state. When clicked:
- A confirmation dialog lists what will be removed before anything is deleted.
- Generated JSON files are removed (`config\FFUConfig.json`, `Apps\AppList.json`, `Apps\UserAppList.json`, `Drivers\Drivers.json`).
- Capture, Deploy, and Apps ISO files are deleted.
- Downloaded artifacts are cleared: Apps payloads (Win32, MSStore, Office downloads), update folders under Apps (Defender, Edge, MSRT, OneDrive), driver downloads, and all `.ffu` files in the FFU capture folder.
- UI list views (drivers, apps, Winget search results, AppScript variables) are cleared and all controls are reset to their default values.
{: .note-title}
> Note
>
> VHDX cache and any custom config files in the `FFUDevelopment\config` folder, and `Drivers\DriverMapping.json` will remain. DriverMapping.json is retained because you may have made custom changes to it and we want to retain those.
>
> If you want to keep any content prior to restoring defaults, copy it out first.
### Save Config File
Saves all current UI selections to a JSON file so you can reload the same settings later or run `BuildFFUVM.ps1` from the command line with `-configFile` (e.g. `BuildFFUVM.ps1 -configFile C:\FFUDevelopment\config\FFUConfig.json`)
### How it works
- Collects the full UI state (paths, toggles, driver/app selections, build options) into a single JSON.
- Defaults the save location to `FFUDevelopmentPath\config` and suggests `FFUConfig.json` as the file name. You can browse and pick a different file name or folder.
- Creates the `config` folder if it does not exist and confirms the save when finished.
### Load Config File
Loads a previously saved configuration JSON and repopulates the UI.
### How it works
- Click **Load Config File** to browse for a JSON file (for example, `FFUDevelopment\config\FFUConfig.json`).
- The UI updates with everything from the file: paths, checkboxes, build options, driver/app selections, and USB settings.
- Supplemental files referenced in the config (Winget `AppList.json`, BYO `UserAppList.json`, `Drivers.json`) are also imported if they exist. Missing helper files are treated as optional and noted for you.
- If the file is empty, unreadable, or invalid JSON, the load is stopped and an error message is shown.
### Build FFU
Use **Build FFU** to run `BuildFFUVM.ps1` with the current UI selections.
### What happens when you click Build FFU
- The UI gathers all current settings and saves them to `FFUDevelopment\config\FFUConfig.json`, and launches `BuildFFUVM.ps1 -configFile` pointing to that file in a background job. `FFUConfig.json` persists between builds and is read on each opening of `BuildFFUVM_UI.ps1` so you can continue where you left off on each new run.
- The window switches to the **Monitor** tab so you can watch progress in real time.
- The progress bar shows overall completion
- When the job finishes, the button returns to **Build FFU** and the UI is ready for the next run.
### Cancelling a Build
The Build FFU button will change to Cancel while a build is running. Cancelling will do the following:
- The UI stops the background build job and kills any child processes so DISM, downloads, and other tools exit.
- The in-progress download is always removed to avoid partial or corrupt content.
- Youre prompted to decide whether to remove other items downloaded during this run. Selecting **Yes** removes only this runs downloads. Any previously downloaded content stays in place.
- When cleanup is finished, the Cancel button reverts to Build FFU and a new build can begin
{% include page_nav.html %}
+98
View File
@@ -0,0 +1,98 @@
---
title: Bring Your Own Applications
nav_order: 6
prev_url: /winget.html
prev_label: Install Winget Applications
next_url: /appsscriptvariables.html
next_label: Apps Script Variables
parent: Applications
grand_parent: UI Overview
---
# Bring Your Own Applications
![1776378975827](image/byoapps/1776378975827.png)
Bring Your Own Applications allows you to run any command line you want in the virtual machine to include in your FFU to install an application, run a script, etc. As the name implies, you'll provide the content, command line, arguments, and additional exit codes.
All applications are stored in the `$AppsPath` parent folder which defaults to `C:\FFUDevelopment\Apps`. Winget source applications and BYO Apps that you select Copy Apps are stored in `$AppsPath\Win32`. MSStore source apps from Winget are stored in `$AppsPath\MSStore`.
At build time, an `Apps.iso` file is created of the `$AppsPath` folder. This ISO gets mounted to the VM. It shows up in the VM as the `D:\` drive. When creating your command line or arguments, you must make sure to reference `D:\`.
## Name
The name of the application. The name is also used when selecting **Copy Apps** to copy apps from a source location to the `$AppsPath\Win32\<Name>` folder (e.g. `C:\FFUDevelopment\Apps\Win32\Google Chrome`)
## Command Line
This is the full path to the command line to install the application, script, or to run a command. If the content was included in the `$AppsPath` this should start with `D:\` (e.g. `D:\Win32\Mozilla Firefox\Mozilla Firefox_136.0.3_Machine_X64_exe_en-US.exe`)
For MSI applications, this should only include msiexec. The rest of the command line will be specified in arguments.
## Arguments
These are the command line arguments for the application. Using the Mozilla Firefox example above, the arguments would be `/S /PreventRebootRequired=true`.
For MSI applications, this will include `/i` and the full-path to the MSI file plus any additional command line parameters (e.g. `/i "D:\Win32\Google Chrome\Google Chrome_134.0.6998.178_Machine_X64_wix_en-US.msi" /quiet /norestart`)
## Source
This is an optional parameter. This is the local source to the content. It is used by the Copy Apps button to copy from the source location to the `$AppsPath\Win32\<Name>` folder. If you don't use the **Copy Apps** button, then you must put the conent in the `$AppsPath` folder manually.
## Additional Exit Codes
This is an optional parameter. Enter a comma-separated list of additional success exit codes if necessary.
## Ignore all non-zero exit codes
If checked, any non-zero exit code will be considered a success.
## Save UserAppList.json
When you're done adding your apps, you must save the `UserAppList.json` file to your `$AppsPath` folder. If you click **Copy Apps**, the `UserAppList.json` file is also saved. The `UserAppList.json` is used by the FFU Builder Orchestrator in the VM to know what to install and when based on the priority of the application.
Below is the `UserAppList.json` of Chrome and Firefox using the example above.
```json
[
{
"Priority": 1,
"Name": "Google Chrome",
"CommandLine": "msiexec",
"Arguments": "/i \"D:\\Win32\\Google Chrome\\Google Chrome_134.0.6998.178_Machine_X64_wix_en-US.msi\" /quiet /norestart",
"Source": "C:\\temp\\source\\Google Chrome",
"AdditionalExitCodes": "",
"IgnoreNonZeroExitCodes": false
},
{
"Priority": 2,
"Name": "Mozilla Firefox",
"CommandLine": "D:\\Win32\\Mozilla Firefox\\Mozilla Firefox_136.0.3_Machine_X64_exe_en-US.exe",
"Arguments": "/S /PreventRebootRequired=true",
"Source": "C:\\temp\\source\\Mozilla Firefox",
"AdditionalExitCodes": "",
"IgnoreNonZeroExitCodes": false
}
]
```
## Import UserAppList.json
You can import a saved `UserAppList.json`
## Edit Application
When you select a single application you can select the **Edit Application** button. This allows you to edit the application information and update the application.
## Copy Apps
If the application source is provided, click **Copy Apps** to copy the application content to the `$AppsPath\Win32` folder (e.g. `C:\FFUDevelopment\Apps\Win32\<Name>`). Network shares are supported. When clicking **Copy Apps** the `UserAppList.json` file is automatically created.
## Remove Selected
Removes the selected applications from the list view. Click **Save UserAppList.json** to save the application list.
## Clear List
The **Clear List** button will clear the list view of whats currently in it. It will not clear the `UserAppList.json` file if it exists.
{% include page_nav.html %}
+85
View File
@@ -0,0 +1,85 @@
---
title: Create PE Media
nav_order: 1
prev_url: /helper_scripts.html
prev_label: Helper Scripts
next_url: /usb_imaging_tool_creator.html
next_label: USB Imaging Tool Creator
parent: Helper Scripts
---
# Create PE Media
`Create-PEMedia.ps1` is a standalone helper script that creates WinPE deployment ISO files outside the main build flow.
This is useful when admins need to quickly generate a deploy ISO for a share (or local staging folder) that technicians will use with `USBImagingToolCreator.ps1`.
## Common use case
If your staging location does not already have a deployment ISO, run `Create-PEMedia.ps1` to generate one, then copy that ISO to the staging folder used by your technicians.
## Prerequisites
- Run from an elevated PowerShell session.
- Windows ADK + WinPE add-on must be installed (default path: `C:\Program Files (x86)\Windows Kits\10\`).
- Script should be run from the `FFUDevelopment` folder (or provide explicit paths via parameters).
## Quick start (deploy ISO)
From `FFUDevelopment`, this creates a deploy ISO by default:
```powershell
.\Create-PEMedia.ps1
```
Default output file:
- `.\WinPE_FFU_Deploy_x64.iso`
## Useful commands
Create deploy ISO for x64:
```powershell
.\Create-PEMedia.ps1 -WindowsArch 'x64'
```
Create deploy ISO for ARM64:
```powershell
.\Create-PEMedia.ps1 -WindowsArch 'arm64' -DeployISO "$PSScriptRoot\WinPE_FFU_Deploy_arm64.iso"
```
Create deploy ISO and include PE drivers from `.\PEDrivers`:
```powershell
.\Create-PEMedia.ps1 -CopyPEDrivers $true
```
## Stage output for USB imaging
After creating the deploy ISO, place it in the same staging root used for USB media creation.
Example:
```text
\\Server\FFUStaging\
WinPE_FFU_Deploy_x64.iso
FFU\
<image files>.ffu
Drivers\
<optional driver content>
```
Then technicians can run:
```powershell
.\USBImagingToolCreator.ps1 -DeployISOPath "\\Server\FFUStaging\WinPE_FFU_Deploy_x64.iso" -DisableAutoPlay
```
## Logging
`Create-PEMedia.ps1` writes log output to:
- `.\Create-PEMedia.log` (or custom path via `-LogFile`)
{% include page_nav.html %}
+410
View File
@@ -0,0 +1,410 @@
---
title: Drivers
nav_order: 9
prev_url: /M365appsoffice.html
prev_label: M365 Apps Office
next_url: /build.html
next_label: Build
parent: UI Overview
---
# Drivers
![1776379108391](image/drivers/1776379108391.png)
FFU Builder supports adding drivers directly to the FFU file at build time, or adding them as folders on your USB drive which can be serviced offline after the FFU has been applied to your device.
The UI allows you to download the drivers prior to build and/or create a `Drivers.json` file which can be used to automatically download the drivers at build time. This allows for flexibility in downloading drivers whenever you need them. It supports downloading multiple driver models at once in parallel.
## Drivers Folder
This is the location where drivers are downloaded to, or where you'll manually copy drivers to. The default is `.\FFUDevelopment\Drivers`
## PE Drivers Folder
Path to the folder containing drivers to be injected into the WinPE deployment media. Default is `.\FFUDevelopment\PEDrivers`.
## Drivers.json Path
Path to a JSON file that specifies which drivers to download. Default is `.\FFUDevelopment\Drivers\Drivers.json`
## Download Drivers
FFU Builder can download drivers from the following OEMs:
* Dell
* HP
* Lenovo
* Microsoft
Clicking the **Download Drivers** exposes a **Make:** drop down which lists the above four OEMs and a **Get Models** button
Clicking **Get Models** downloads the list of models from the selected OEM.
The **Model Filter** box allows you to type in a string to filter on the model. The filter should match on any portion of text in the model name.
The model column lists the model name and the System ID (for Dell and HP) or the Machine Type (for Lenovo) in parenthesis. The SystemID/Machine Type values are required to know exactly which set of drivers to download for your model. There typically is a lot of overlap, and sometimes the drivers for the various SystemID/MachineTypes for the same model might be exactly the same, it's still best to grab the SystemID/MachineType before downloading drivers.
To get the System ID:
**HP**
* BIOS/UEFI: Either under Main or System Information (it's going to be different depending on the model) you're looking for the **System Board ID** and it should be a four-character code.
* PowerShell:`(Get-CimInstance -Namespace 'root\WMI' -Class MS_SystemInformation).BaseboardProduct`
**Dell**
* BIOS/UEFI: I'm not sure if it's possible to get the System ID from the BIOS/UEFI. I seem to recall in some BIOS screenshots that System SKU is listed in some BIOS/UEFI implementations, but it may not be consistent.
* PowerShell: `(Get-CIMInstance -ClassName "MS_SystemInformation" -NameSpace "root\WMI").SystemSku`
or
```
[string]$OEMString = Get-WmiObject -Class "Win32_ComputerSystem" | Select-Object -ExpandProperty OEMStringArray
$ComputerDetails.FallbackSKU = [regex]::Matches($OEMString, '\[\S*]')[0].Value.TrimStart("[").TrimEnd("]")
```
**Lenovo**
To find the Machine Type for Lenovo devices, check the bottom/back of the device for the MTM field and capture the first four characters.
* BIOS/UEFI: Look for MTM and grab the first four characters
* PowerShell: `(Get-CIMInstance -ClassName "MS_SystemInformation" -NameSpace "root\WMI").SystemProductName`
You can multi-select different models within the same make, or mix and match different makes. The screenshot below shows different Dell, HP, Lenovo, and Microsoft models selected
![1776379241359](image/drivers/1776379241359.png)
## Save Drivers.json
After selecting the drivers you want to download, clicking **Save Drivers.json** will prompt you for a location to save the `Drivers.json` file to. The `Drivers.json` file is responsible for telling `BuildFFUVM.ps1` what drivers to download during the build process.
Below is an example of `Drivers.json`:
```
{
"HP": {
"Models": [
{
"Name": "HP EliteBook 865 16 inch G11 Notebook PC",
"SystemId": "8d03"
}
]
},
"Dell": {
"Models": [
{
"Name": "Dell Pro Max Desktops Dell Pro Max Micro FCM2250,Dell Pro Max Micro XE FCM2250",
"CabUrl": "https://downloads.dell.com/FOLDER13898125M/1/Dell_Pro_Max_Desktops_0D14.cab",
"SystemId": "0D14"
}
]
},
"Lenovo": {
"Models": [
{
"Name": "Lenovo 300w Yoga Gen 4",
"MachineType": "82VN"
}
]
},
"Microsoft": {
"Models": [
{
"Name": "Surface Pro for Business (11th Edition)",
"Link": "https://www.microsoft.com/download/details.aspx?id=108013"
}
]
}
}
```
## Import Drivers.json
Import Drivers.json allows you to import a previously saved Drivers.json file. The models in the Drivers.json file will show up in the list view pre-selected. This will allow you to select additional models and save an updated version of Drivers.json, or to download the selected models by clicking Download Selected.
## Download Selected
Download Selected will download the selected models to the Drivers Folder path (default .\FFUDevelopment\Drivers). Drivers will download the the .\FFUDevelopment\Drivers\Make\Model folder. Download select also interacts with the Compress Driver Model Folder to WIM checkbox which will download and compress the drivers to WIM.
If you've previously downloaded a driver model and want to compress it to a WIM, you can check he Compress Driver Model Folder to WIM checkbox and click Download Selected again. This will skip the download and compress the driver folder to a WIM file.
Download Selected leverages BITS and the BITS Priority can be controlled by the BITS Priority drop down on the Build tab. If driver downloads via the UI feel slow, change BITS Priority to Foreground to speed them up.
## Clear List
Clears the list view of the previous model list
## Install Drivers to FFU
Install Drivers to FFU will recursively add the drivers in the FFUDevelopment\Drivers folder to the FFU file.
It's recommended to only include a single model's drivers in the FFU. This is because dism will add the drivers to the drivers store in the FFU and any additional models that aren't necessary will bloat the drivers store, using up disk space.
If you're dealing with multiple models, it's recommended to select Copy Drivers to USB drive instead.
## Copy Drivers to USB drive
Copy Drivers to USB drive will copy the drivers to the .\Drivers folder on the deploy partition of the USB drive (e.g. D:\Drivers\Make\Model)
If you're manually copying drivers to the .\FFUDevelopment\Drivers folder, you must copy them to the FFUDevelopment\Drivers\Make\Model folder (e.g. FFUDevelopment\Drivers\Lenovo\Lenovo 300w). Prior releases referenced using just .\FFUDevelopment\Drivers\Model, however for better organization and consistency, the code has been updated to require the make folder.
## Compress Driver Model Folder to WIM
Enabling this checkbox compresses the driver model folder to a WIM file after each model finishes downloading (or when an existing model is detected). Every `Drivers\<Make>\<Model>` directory is captured into a single `<Drivers folder>\<Make>\<Model>.wim` using DISM with `Compress:Max`, which dramatically reduces the space required on your USB drive.
1. Select the models you need, check **Compress Driver Model Folder to WIM**, then click **Download Selected**. Fresh downloads are extracted as usual and immediately compressed into their companion `.wim`.
2. If the model already exists, the download phase is skipped and only the compression runs, so you can rebuild the `.wim` whenever you refresh the folder contents.
By default the extracted folder is deleted after a successful capture so that the `.wim` becomes the canonical artifact. When **Use Drivers Folder as PE Drivers Source** is also checked, the UI keeps the folder in place, writes a `__PreservedForPEDrivers.txt` marker, and lets WinPE driver harvesting reuse the loose INF set.
Additional guidance:
- `DriverMapping.json` is updated to reference the `.wim`, so `Copy Drivers to USB drive`, `BuildFFUVM.ps1 -CopyDrivers`, and the WinPE `ApplyFFU.ps1` flow mount the compressed archive automatically.
- Watch the Drivers tab status column or `FFUDevelopment_UI.log` for DISM progress and troubleshooting details per model.
- Ensure the volume hosting `FFUDevelopment\Drivers` has enough free space for both the source folder and the resulting `.wim`.
- Only applies to drivers from Dell, HP, Lenovo, or Microsoft that are specified in the Drivers.json file. It will not compress models you manually copy to the Drivers folder.
## Copy PE Drivers
When **Copy PE Drivers** is enabled, drivers will be injected into the WinPE deployment media. This ensures that WinPE has the necessary drivers to recognize hardware components like storage controllers, network adapters, and input devices during FFU deployment.
By default, drivers are sourced from the **PE Drivers Folder** (default `.\FFUDevelopment\PEDrivers`). You can manually place drivers in this folder, and they will be injected into the WinPE media during the build process.
### Use Drivers Folder as PE Drivers Source
When **Copy PE Drivers** is checked, an additional sub-option becomes visible: **Use Drivers Folder as PE Drivers Source** .
When this option is enabled, the script bypasses the PE Drivers Folder and instead dynamically builds the WinPE driver set from the main **Drivers Folder**. The script scans all available drivers in the Drivers folder, parses their INF files, and copies only the essential driver types needed for WinPE, including:
* System devices
* SCSI, RAID, and NVMe controllers
* Keyboards
* Mice and other pointing devices
* Human Interface Devices (HID) for touch support
This eliminates the need to maintain a separate, manually curated `PEDrivers` folder and ensures that WinPE has the necessary drivers based on what you've already downloaded for your target devices.
{: .note-title}
> Note
>
> If the PE Drivers folder already contains content when using this option, it will be cleared before the new driver set is copied in.
>
> Some drivers may fail to be added during injection, which is expected behavior and can be safely ignored.
>
> Network adapters are not included when using the drivers folder as PE drivers source, so if you're using WDS or another network-based solution to copy your FFU and you've modified ApplyFFU.ps1, it's best to not use the **Use Drivers Folder as PE Driver Source** option and just copy in your required PE Drivers to the PE Drivers folder.
## DriverMapping.json
`DriverMapping.json` is an automatically generated file that maps hardware identifiers (like System IDs or Machine Types) to specific driver packages. This file enables the WinPE deployment script (`ApplyFFU.ps1`) to automatically detect your device hardware and apply the correct drivers without manual intervention.
### How it gets created
`DriverMapping.json` is created and updated automatically when you download drivers using the **Download Selected** button on the Drivers tab of the UI, or when drivers are downloaded during the FFU build. Each time you successfully download drivers for a model, the file is updated with the mapping information for that model.
### Automatic Driver Selection During Deployment
When you deploy an FFU using the WinPE media, `ApplyFFU.ps1` looks for `DriverMapping.json` on the USB drive at `D:\Drivers\DriverMapping.json` (where D: is your USB deploy partition). If found, the script:
1. Detects the hardware identifiers of the current device (System ID, Machine Type, etc.)
2. Searches `DriverMapping.json` for a matching entry
3. Automatically selects and applies the correct driver package
4. Falls back to manual driver selection if no match is found
### Required Fields by Manufacturer
Each entry in `DriverMapping.json` contains different required fields depending on the manufacturer:
**All Manufacturers:**
* **Manufacturer** The OEM name (e.g., "Dell", "HP", "Lenovo", "Microsoft")
* **Model** The full model name as it appears in the driver download catalog
* **DriverPath** The relative path to the driver folder or WIM file on the USB drive under the Drivers folder (e.g., "Dell\\\Dell Latitude 7490" or "HP\\\HP EliteBook 865 16 inch G11 Notebook PC.wim").
Relative paths are used since we don't know the drive letter of the USB drive when the `DriverMapping.json` file is created. And since this uses json, the double backslash is intentional since the first slash is an escape character.
**Dell:**
* **SystemId** The System SKU identifier (e.g., "0819", "0D14"). This is the primary matching field used during deployment. To find your Dell System SKU via PowerShell, run: `(Get-CIMInstance -ClassName "MS_SystemInformation" -NameSpace "root\WMI").SystemSku`
**HP:**
* **SystemId** The System Board ID, a four-character code (e.g., "8d03", "83D2"). This is the primary matching field used during deployment. To find your HP System Board ID via PowerShell, run: `(Get-CimInstance -Namespace 'root\WMI' -Class MS_SystemInformation).BaseboardProduct`
**Lenovo:**
* **MachineType** The first four characters of the MTM (Machine Type Model) field (e.g., "82VN", "21JD"). This is the primary matching field used during deployment. To find your Lenovo Machine Type via PowerShell, run: `(Get-CIMInstance -ClassName "MS_SystemInformation" -NameSpace "root\WMI").SystemProductName`
**Microsoft:**
* No additional fields required beyond Manufacturer, Model, and DriverPath. Matching is performed based on the normalized model name.
### Example DriverMapping.json
Below is an example of `DriverMapping.json` with entries for multiple manufacturers:
```
[
{
"Manufacturer": "Dell",
"Model": "Dell Latitude 7490",
"DriverPath": "Dell\\Dell Latitude 7490",
"SystemId": "0819"
},
{
"Manufacturer": "Dell",
"Model": "Dell Pro Max Desktops Dell Pro Max Micro FCM2250,Dell Pro Max Micro XE FCM2250",
"DriverPath": "Dell\\Dell Pro Max Desktops Dell Pro Max Micro FCM2250,Dell Pro Max Micro XE FCM2250.wim",
"SystemId": "0D14"
},
{
"Manufacturer": "HP",
"Model": "HP EliteBook 865 16 inch G11 Notebook PC",
"DriverPath": "HP\\HP EliteBook 865 16 inch G11 Notebook PC.wim",
"SystemId": "8D03"
},
{
"Manufacturer": "Lenovo",
"Model": "Lenovo 300w Yoga Gen 4",
"DriverPath": "Lenovo\\Lenovo 300w Yoga Gen 4",
"MachineType": "82VN"
},
{
"Manufacturer": "Microsoft",
"Model": "Surface Pro for Business (11th Edition)",
"DriverPath": "Microsoft\\Surface Pro for Business (11th Edition)"
}
]
```
## Bring Your Own Drivers
If you manage models that aren't from Dell, HP, Lenovo or Microsoft, or you want to use different drivers from what FFU Builder downloads, you can copy your own drivers to the `.\FFUDevelopment\Drivers` folder using the `.\FFUDevelopment\Drivers\Make\Model` format, or simply change the Drivers Folder path to the location of your drivers content.
You can also manually create your own DriverMapping.json file for the following makes/manufacturers
| Manufacturer | Match Field | WMI Class | Property |
| ------------------------------- | ----------- | ------------------------------------------- | ----------------------------------------------- |
| **Dell** | SystemId | `MS_SystemInformation` (via `root\WMI`) | `SystemSku` |
| **Dell** (fallback) | SystemId | `Win32_ComputerSystem` | `OEMStringArray` (parsed for bracketed value) |
| **HP** | SystemId | `MS_SystemInformation` (via `root\WMI`) | `BaseBoardProduct` |
| **Lenovo** | MachineType | `Win32_ComputerSystem` | `Model` |
| **Microsoft** | Model | `Win32_ComputerSystem` | `Model` |
| **Panasonic Corporation** | SystemId | `MS_SystemInformation` (via `root\WMI`) | `BaseBoardProduct` |
| **Viglen** | SystemId | `Win32_BaseBoard` | `SKU` |
| **AZW** | SystemId | `MS_SystemInformation` (via `root\WMI`) | `BaseBoardProduct` |
| **Fujitsu** | SystemId | `Win32_BaseBoard` | `SKU` |
| **Getac** | SystemId | `MS_SystemInformation` (via `root\WMI`) | `BaseBoardProduct` |
| **Intel** | Model | `Win32_ComputerSystem` | `Model` |
| **ByteSpeed** | Model | `Win32_ComputerSystem` | `Model` |
| **Other** (default) | Model | `Win32_ComputerSystem` | `Model` |
**Notes:**
* Match Field is the name of the field in the `DriverMapping.json` file (e.g. SystemID, MachineType, Model)
* SystemId is a catch-all term for a unique identifier, however each manufacturer calls this something different and stores them in different places within WMI
* The Dell (fallback) is used for models where the systemSKU isn't available and the OEMStringArray is parsed via Win32_ComputerSystem
* The `MS_SystemInformation` class is queried from the `root\WMI` namespace
* Unless noted, the other WMI classes use the `root\cimv2` namespace
* All identifiers are normalized to uppercase for matching
* ByteSpeed systems with "NUC" in the model name are re-mapped to Intel and use `BaseBoardProduct` instead
* For manufacturers that aren't listed, the default behavior is to use the `Win32_ComputerSystem` `model` string
Below is an example `DriverMapping.json` that includes the additional manufacturers. Note that the model and systemID information is made up and is used only as an example to show how to format the file. You'll need to collect the model or system ID from the locations in the table above and include it in your custom `DriverMapping.json` file. Each entry includes both a WIM and drivers folder for each manufacturer. If you want to include driver WIM files for manufacturers other than Dell, HP, Lenovo, or Microsoft, you'll need to manually compress the drivers folder to a WIM file.
```
[
{
"Manufacturer": "Panasonic",
"Model": "Toughbook CF-33",
"SystemId": "CF-33LEHAGT1",
"DriverPath": "Panasonic\\CF-33.wim"
},
{
"Manufacturer": "Panasonic",
"Model": "Toughbook FZ-55",
"SystemId": "FZ-55DZ0KVM",
"DriverPath": "Panasonic\\FZ-55"
},
{
"Manufacturer": "Viglen",
"Model": "Genie Desktop Pro",
"SystemId": "VGN-GDP-2024",
"DriverPath": "Viglen\\GeniePro"
},
{
"Manufacturer": "Viglen",
"Model": "Omnino Mini",
"SystemId": "VGN-OMN-M1",
"DriverPath": "Viglen\\OmninoMini.wim"
},
{
"Manufacturer": "AZW",
"Model": "SER5 Pro",
"SystemId": "SER5-5800H",
"DriverPath": "AZW\\SER5Pro.wim"
},
{
"Manufacturer": "AZW",
"Model": "U59 Mini PC",
"SystemId": "U59-N5095",
"DriverPath": "AZW\\U59"
},
{
"Manufacturer": "Fujitsu",
"Model": "LIFEBOOK U9312",
"SystemId": "FPCM52921",
"DriverPath": "Fujitsu\\LIFEBOOK-U9312"
},
{
"Manufacturer": "Fujitsu",
"Model": "ESPRIMO D7010",
"SystemId": "D3644-A1",
"DriverPath": "Fujitsu\\D7010.wim"
},
{
"Manufacturer": "Getac",
"Model": "F110 G6",
"SystemId": "F110G6",
"DriverPath": "Getac\\F110G6.wim"
},
{
"Manufacturer": "Getac",
"Model": "B360 Pro",
"SystemId": "B360PRO",
"DriverPath": "Getac\\B360Pro"
},
{
"Manufacturer": "Intel",
"Model": "NUC13ANHi7",
"DriverPath": "Intel\\NUC13"
},
{
"Manufacturer": "Intel",
"Model": "NUC12WSHi5",
"DriverPath": "Intel\\NUC12.wim"
},
{
"Manufacturer": "ByteSpeed",
"Model": "Tera 2450",
"DriverPath": "ByteSpeed\\Tera2450.wim"
},
{
"Manufacturer": "ByteSpeed",
"Model": "Celeritas X1",
"DriverPath": "ByteSpeed\\CeleritasX1"
},
{
"Manufacturer": "Acer",
"Model": "TravelMate P214-53",
"DriverPath": "Acer\\TMP214"
},
{
"Manufacturer": "ASUS",
"Model": "ExpertBook B5402CVA",
"DriverPath": "ASUS\\B5402.wim"
}
]
```
{% include page_nav.html %}
+20
View File
@@ -0,0 +1,20 @@
---
title: Helper Scripts
nav_order: 4
prev_url: /ui_overview.html
prev_label: UI Overview
next_url: /create_pemedia.html
next_label: Create PE Media
has_children: true
has_toc: false
---
# Helper Scripts
This section documents standalone helper scripts used outside the primary UI-driven build workflow.
## Available helper scripts
- [Create PE Media](/create_pemedia.html)
- [USB Imaging Tool Creator](/usb_imaging_tool_creator.html)
{% include page_nav.html %}
+52
View File
@@ -0,0 +1,52 @@
---
title: Hyper-V Settings
nav_order: 1
prev_url: /ui_overview.html
prev_label: UI Overview
next_url: /windows_settings.html
next_label: Windows Settings
parent: UI Overview
---
# Hyper-V Settings
![1776378277110](image/hyperv_settings/1776378277110.png)
## Enable VM Networking (Experimental)
Controls whether the build VM is connected to a Hyper-V switch during provisioning.
Leave this off for the default offline build path. Turn it on only if you want to test internet-connected builds and understand there may be Sysprep or capture issues.
## VM Switch Name
Drop down of detected VM Switches. There's also an **Other** option which allows you to specify a VM Switch Name. The other option is useful in scenarios where the machine you're running the UI from isn't going to be the machine where you plan to build the FFU from.
This setting is only used when **Enable VM Networking (Experimental)** is turned on. VM-based builds still capture from the host-side VHDX after the VM shuts down, so you only need a switch when the VM requires network connectivity during provisioning.
## Disk Size (GB)
Size of the virtual hard disk for the virtual machine. Default is a 50GB dynamic disk. You may want to increase the size if you're installing many apps.
## Memory (GB)
Amount of memory to allocate for the virtual machine. Recommended to use 8GB if possible, especially for Windows 11. Default is 4GB.
## Processors
Number of virtual processors for the virtual machine. Recommended to use at least 4. Default is 4.
## VM Location
Default is `$FFUDevelopmentPath\VM`. This is the location of the VHDX that gets created where Windows will be installed to.
## VM Name Prefix
Prefix for the generated VM. Default is _FFU.
## Logical Sector Size
Uint32 value of 512 or 4096. Useful for 4Kn drives or devices shipping with UFS drives. Default is 512.
There is some error-handling in the script that will call out mismatch issues with logical sector size. Unfortunately you will need to create a new FFU with the correct logical sector size as you can't convert a previously created FFU. Most should be fine with 512, but lower-end devices that used to ship with eMMC drives have now shifted to using UFS.
{% include page_nav.html %}
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Some files were not shown because too many files have changed in this diff Show More