Compare commits

..

268 Commits

Author SHA1 Message Date
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 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 bf27da5c66 Updates script version to 2508.1Preview
Aligns the version number in the FFU build and deployment scripts with the new preview release.
2025-08-26 17:14:37 -07:00
rbalsleyMSFT 1010b9fce7 Adds cleanup for disabled update artifacts
Introduces a new function to remove residual artifacts for updates that are disabled via script flags.

If updates for Defender, MSRT, OneDrive, or Edge are turned off, this change ensures that any related files are deleted from the build environment. This prevents unnecessary files from being included in the final image.
2025-08-26 17:11:57 -07:00
rbalsleyMSFT 3e34bd6bff Refactor architecture handling for app downloads
Clarifies the distinction between the application architecture to be downloaded and the target Windows architecture for installer pruning.

Renames the `SelectedWindowsArch` parameter to `WindowsArch` and introduces a new `ApplicationArch` parameter. This makes the download and subsequent installer selection logic more explicit and easier to understand, especially for MS Store apps where multiple installers might be available.
2025-08-22 18:47:43 -07:00
rbalsleyMSFT 3f892493c0 Improves Winget installer selection logic
Refines the post-download cleanup process for applications with multiple installers.

When multiple installers are downloaded for an application, this change introduces logic to select the most appropriate one based on the target Windows architecture. It prioritizes universal installers first, then architecture-specific installers, before falling back to the previous method of selecting the newest file by signature date.

This ensures a more accurate installer is chosen, especially when Winget provides multiple architecture options for a single package.
2025-08-22 17:33:09 -07:00
rbalsleyMSFT 7d4567efbe Refactor WinGet app handling and add command overrides
Improves the reliability of WinGet app processing by making several key changes.

The build process now deletes the `WinGetWin32Apps.json` file before each run to ensure it is always freshly generated.

The UI no longer relies on `WinGetWin32Apps.json` to detect previously downloaded content. Instead, it checks directly for the application's content on disk, preventing unnecessary re-downloads.

This change also introduces a feature allowing users to override the default silent install commands for Win32 apps. By specifying `CommandLine` or `Arguments` properties in `AppList.json`, these values will be used to update the corresponding entries in `WinGetWin32Apps.json` during the build process.
2025-08-21 16:46:17 -07:00
rbalsleyMSFT 9aed707a77 Removes redundant optional features textbox
Eliminates the read-only field and derives the features list directly from the checked items, producing a sorted semicolon string when collecting config. Avoids duplicated state, prevents desynchronization between UI elements, and yields deterministic ordering for persistence.
2025-08-19 18:40:09 -07:00
rbalsleyMSFT 0c373e6b2c fix: Fixes an issue with BYO apps that had null arguments. 2025-08-19 17:34:41 -07:00
rbalsleyMSFT a501b32a03 feat: Implement per-user Appx package detection and removal for Sysprep automation
- Added logic to check for per-user Appx packages that are not provisioned for all users, which could block the Sysprep process.
- Introduced a hash set to store provisioned package families and collect current user Appx packages while excluding frameworks and non-removable packages.
- Implemented removal of non-provisioned Appx packages with error handling and re-checking to ensure all blockers are resolved before proceeding with Sysprep.
2025-08-18 15:54:11 -07:00
rbalsleyMSFT 8ab6603999 feat: Add new checkbox to Inject Unattend.xml to VM.
- Creates a new parameter [bool]InjectUnattend

- This will take the FFUDevelopment\Unattend\unattend_[arch].xml file and copy it to the FFUDevelopment\Apps\Unattend and rename the file to unattend.xml.

- This is useful for situations where you don't use the USB drive for deploying the FFU but still have Unattend-related customizations that you want to apply.
2025-08-14 16:20:55 -07:00
rbalsleyMSFT 85383f989a fix: - Improved handling of special characters with driver model names when doing automatic driver/model matching when applying the FFU to a device.
- Added some console output when drivers are being installed from a folder
2025-08-13 18:48:28 -07:00
rbalsleyMSFT 0423ac31d9 feat: Enhance installer selection logic in Add-Win32SilentInstallCommand function
- Improved detection of installer candidates by reading YAML configuration for NestedInstallerFiles.
- Added logic to resolve silent install switches from YAML, prioritizing block-level settings.
- Enhanced fallback mechanisms for selecting installers when multiple candidates are found.
- Updated command construction to accommodate new installer resolution logic.
2025-08-13 16:48:18 -07:00
rbalsleyMSFT 35f37f3a36 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:30:28 -07:00
rbalsleyMSFT 78d7bb9262 feat: Add VM switch selection logic and persist custom names/IPs in UI
- Implemented Select-VMSwitchFromConfig function to handle VM switch selection based on configuration.
- Enhanced Register-EventHandlers to persist custom VM switch name and IP address when 'Other' is selected.
- Improved user experience by ensuring relevant fields are populated correctly based on user input and configuration settings.
2025-08-12 12:14:03 -07:00
rbalsleyMSFT 3c545be5c5 Hardens driver downloads and cleanup
Adds in‑progress markers around OEM driver downloads to enable recovery and reliable post‑run cleanup.

Refactors driver cleanup to be run‑aware: maps download targets to model folders, removes temp/model content created during the run, prunes empties, and preserves existing make roots via creation‑time checks.

Includes the Drivers folder in current‑run cleanup with safer rules to avoid deleting pre‑existing content.

Improves Office process termination by resolving the Office path (prefers UI value) and only acting when a valid folder exists.
2025-08-11 19:36:58 -07:00
rbalsleyMSFT c1983f75e6 fix: fixed unattend file copying logic for USB deployment based on architecture 2025-08-08 19:29:50 -07:00
rbalsleyMSFT 7c3de6d77f feat: Add cleanup functionality and improve build cancellation process in UI
- Introduced flags to track if a build is in progress and if cleanup is running.
- Enhanced the button click handler to allow users to cancel an ongoing build and initiate a cleanup process.
- Implemented a mechanism to stop background jobs and terminate associated processes during cancellation.
- Added logic to manage log file reading during cleanup and ensure proper UI updates.
- Updated the state management to reflect the current operation status accurately.
2025-08-08 18:22:40 -07:00
rbalsleyMSFT 17dc80f11b fix: Fix computer name being echoed during device naming 2025-08-07 18:08:25 -07:00
rbalsleyMSFT 846d449aac feat: Add MaxUSBDrives parameter for parallel USB drive processing
- Introduced a new parameter `MaxUSBDrives` to control the maximum number of USB drives that can be built in parallel, with a default value of 5.
- Updated UI to include a textbox for setting `MaxUSBDrives`.
- Implemented validation to ensure the value is a non-negative integer.
- Adjusted the deployment function to respect the `MaxUSBDrives` limit during USB drive creation.
2025-08-07 16:32:25 -07:00
rbalsleyMSFT db9b7335f2 refactor: Inject unattend file after VHDX caching for audit-mode boot
- Moved unattend file injection logic to occur after VHDX caching to ensure the cached VHDX does not contain audit-mode unattend.
- Simplified the logic to determine if the VHDX is already mounted, reducing redundant mount/dismount cycles.
- Ensured the unattend file is copied to the correct directory based on the Windows architecture.
2025-08-07 13:58:09 -07:00
rbalsleyMSFT 6f98473009 Refines driver mapping logic and adds diagnostic logging
Improves JSON parsing of the driver mapping file to handle different file structures more reliably.

Adds logging to show all potential driver mapping rules that match the current system, making it easier to diagnose rule selection.
2025-08-06 17:46:13 -07:00
rbalsleyMSFT 357261ec73 Fix: Clarify computer name is set on reboot
Updates the logging messages to more accurately reflect that the computer name change takes effect after a restart. The `Set-Computername` command stages the change, so the log now indicates the name "will be set" instead of "was set".
2025-08-06 17:01:04 -07:00
rbalsleyMSFT 5bef901295 Deduplicate device name section header output
Moves the 'Device Name Selection' section header out of the conditional blocks to a common location. This refactoring avoids code repetition and improves maintainability.
2025-08-06 16:42:54 -07:00
rbalsleyMSFT 59e247c012 Fixes LocalAccounts module issue in PowerShell 7
Applies a workaround for an issue where the `Microsoft.PowerShell.LocalAccounts` module fails to load in PowerShell 7 on Windows 11 23H2 and earlier.

The script now checks the OS build number and imports the module using the Windows PowerShell compatibility layer on affected systems.

Fixes: https://github.com/PowerShell/PowerShell/issues/21645
2025-08-06 15:56:21 -07:00
rbalsleyMSFT a87c4796b5 feat: Implement multi-select and editing for BYO apps
Introduces multi-selection capabilities to the "Bring Your Own" applications list, allowing users to select and remove multiple applications at once.

This change also adds the ability to edit an existing application's details directly from the UI. The application list view is now dynamically generated to support these new features, including selectable rows.

Key changes:
- Adds "Edit Application" and "Remove Selected" buttons.
- Enables/disables action buttons based on the number of selected items.
- Modifies the "Add Application" form to function as an "Update" form when editing.
- Implements selection via mouse click, checkboxes, and the spacebar.
2025-08-05 19:07:41 -07:00
rbalsleyMSFT 4d289ee14a Add detailed flowchart for BuildFFUVM process in Markdown format 2025-08-05 14:51:01 -07:00
rbalsleyMSFT 08feb7c9dd Removes drivers.json file requirement for local drivers 2025-08-04 18:34:26 -07:00
rbalsleyMSFT 9cb06cb71e Updates the UI logic to automatically enable the 'Install Apps' option when 'Install Winget Apps' or 'Define Apps Script Variables' is selected.
This ensures the main application installation feature remains active when any of its dependent options are chosen, creating a more consistent user experience.
2025-08-04 17:50:12 -07:00
rbalsleyMSFT 5ec607d94a Enforce 'Install Apps' selection for BYO apps
Updates UI logic to automatically select the main 'Install Apps' option whenever the 'Bring Your Own Apps' option is selected.

This ensures the parent category is always active when a user opts to bring their own applications, preventing an inconsistent UI state. The logic is applied both when updating panel visibility and when restoring checkbox states.
2025-08-04 17:39:17 -07:00
rbalsleyMSFT ac7ef119e0 Feat: Add option to ignore non-zero application exit codes
Introduces a new feature allowing application installations to succeed regardless of their exit code. This is useful for installers that may return non-standard exit codes which should be treated as successful.

Changes include:
- A new checkbox in the UI to enable this option for an application.
- Updates to the application installation script to handle the new setting.
- Modifications to save and load this setting in the application list.
2025-08-04 17:21:34 -07:00
rbalsleyMSFT 03c8127bd3 Feat: Add support for custom success exit codes
Refactors the process invocation logic to use the .NET Process class for more robust output stream handling and to avoid temporary files.

Introduces a feature allowing users to specify additional success exit codes for applications in the UI. These codes are now considered successful during the installation process.

Adds a 'PAUSE' command to halt script execution and wait for user input.
2025-08-04 13:17:52 -07:00
rbalsleyMSFT eb001e59b3 Refine build cleanup and script variable handling
Updates the build process to remove any existing Apps.iso during cleanup, ensuring a fresh build without stale artifacts.

Clarifies in the app script template that configuration variables are treated as strings. The examples are updated to reflect this, preventing potential errors in custom scripts when checking for boolean-like values.
2025-08-01 19:42:04 -07:00
rbalsleyMSFT 3e46d4b280 Bumps script version to 2507.2
Updates the version number in the build and deployment scripts.
2025-08-01 14:44:12 -07:00
rbalsleyMSFT eae07fcad0 Improves ADK FWLink resolution for robustness
Refactors the ADK URL retrieval logic to let `Invoke-WebRequest` handle the forward link redirection directly. This approach is more reliable than manually parsing the redirect response.

Adds a try/catch block to provide better error handling and logging during the URL resolution process.
2025-08-01 12:36:45 -07:00
rbalsleyMSFT 41b65a76c1 Update README.md
Docs: Updated chapter spacing for better readability
2025-07-30 22:31:42 -07:00
rbalsleyMSFT 67c992806f Update README.md
Docs: Updated chapter spacing for better readability
2025-07-30 22:31:10 -07:00
rbalsleyMSFT 33672315d2 docs: Update changelog for 2507.1 UI Preview
Adds an entry for the new 2507.1 UI Preview release.

Also improves the formatting and readability of the changelog by adding consistent spacing.
2025-07-30 20:12:29 -07:00
rbalsleyMSFT 26fc0c67a5 Docs: Update YouTube walkthrough video for new UI
Replaces the previous YouTube walkthrough video with a new one. The new video and corresponding chapter links cover the new UI process and the 2507.1 preview release.
2025-07-30 20:11:25 -07:00
rbalsleyMSFT 4032ed4e3f Docs: Update README with UI documentation status
Sets expectations that formal documentation for the new UI is still being written.

Directs users to the YouTube video for a demonstration and highlights the removal of `InstallAppsAndSysprep.cmd`.
2025-07-30 17:07:31 -07:00
rbalsleyMSFT 2147746ff9 Remove sample inbox app list
Deletes the sample JSON file containing a list of inbox applications as it is no longer required.
2025-07-30 16:39:05 -07:00
rbalsleyMSFT 819b3b4de7 Docs: Update and clarify README
Adds LTSC to the list of supported Windows versions and emphasizes the fast deployment time with bold formatting. Also includes minor typographic corrections.
2025-07-30 16:37:14 -07:00
rbalsleyMSFT 3a909c76e0 Implements dynamic retrieval of Lenovo PSREF API token
Adds a new function to programmatically retrieve the required authentication token for the Lenovo PSREF API. This change is necessary as Lenovo is now restricting API access without a JavaScript-generated token.

The new function launches a headless browser instance, uses the DevTools protocol to extract the token from local storage, and then terminates the browser. This ensures continued access to the comprehensive model data available through the PSREF API, which is not fully present in other catalogs.

The User-Agent string has also been updated.
2025-07-30 11:47:05 -07:00
rbalsleyMSFT 30c7f6f705 Refactor Win32 app installation to process sources separately
Updates the Win32 app installation script to handle WinGet-sourced apps and user-defined apps in two separate batches instead of merging them. This ensures all WinGet apps are installed before any user-defined apps.

The core installation logic is extracted into a reusable function, improving script structure and readability.

Additionally, removes a pause command and a redundant console message from other scripts to improve automation flow.
2025-07-25 18:35:19 -07:00
rbalsleyMSFT b4f1985c99 Refactor dependency resolution to be app-specific
Change the dependency resolution logic from a single global scan to an isolated, per-application scan.

This prevents potential version conflicts that could occur if multiple applications require different versions of the same dependency package. Each application now resolves its dependencies only from its own dedicated 'Dependencies' folder, ensuring the correct versions are used and making the installation process more robust and reliable.
2025-07-25 17:33:16 -07:00
rbalsleyMSFT c57e7ebdfe Improve architecture handling for msstore applications
Refactors the application download logic to correctly handle packages from the 'msstore' source, which does not support architecture specification.

- The download process no longer passes an architecture parameter for msstore apps.
- The architecture selection dropdown in the UI is now disabled for msstore apps to prevent invalid configurations.
- Imported or searched msstore apps will display 'NA' for their architecture.
2025-07-25 15:29:25 -07:00
rbalsleyMSFT fc79251f66 Overhauls Store app installation with dependency resolution
Refactors the Store app installation script to be significantly more robust and intelligent. The new implementation automatically resolves application dependencies instead of relying on a simple folder structure.

Key improvements include:
- Pre-scans all application folders to create a central catalog of available dependencies.
- Parses the `AppxManifest.xml` from each main app package (including from within bundles) to determine its true dependencies.
- Resolves the required dependencies by finding the best available package from the catalog that meets version and OS architecture requirements.
- Adds support for extracting zipped dependency packages.
- Improves temporary file management and logging.
2025-07-25 14:31:03 -07:00
rbalsleyMSFT 93f6eeac87 Improves Winget search performance and user feedback
Refactors the Winget search functionality to enhance performance and provide a better user experience.

- Updates the UI to show a wait cursor, disable the search button, and display a status message during the search operation.
- Improves the post-search status message to differentiate between new and total applications found.
- Utilizes `ForEach-Object -Parallel` to speed up the processing of search results on multi-core systems.
2025-07-24 15:24:01 -07:00
rbalsleyMSFT 138fd1184c Remove debugging code
Removes temporary debug logging from the UI window's closed event handler.
2025-07-24 10:38:08 -07:00
rbalsleyMSFT 0f0e8e9f98 Reduces log noise for monitor tab auto-scroll
Removes the log message that is generated when auto-scrolling is enabled. This reduces log spam, as this can be a frequent event.

The message for when a user manually disables auto-scrolling is retained.
2025-07-24 09:39:10 -07:00
rbalsleyMSFT 576c0a82f8 Improve script output readability
Adds a blank line after each application installation command. This visually separates the output for each app, making the logs easier to read in the terminal.
2025-07-23 20:56:36 -07:00
rbalsleyMSFT 57cb349371 Improve driver injection logic and user feedback
Enhances the driver mapping logic to ensure JSON files with a single entry are handled correctly. The model matching is now more flexible, allowing for partial matches in either direction between the system's reported model and the mapping file.

Adds a user-facing message to indicate that the driver injection process may take a significant amount of time, preventing confusion about the script stalling.
2025-07-23 20:42:30 -07:00
rbalsleyMSFT 791040364b Improves error reporting for failed FFU builds
Refactors the error handling for failed build jobs to more reliably capture the root cause of the failure.

The logic now uses `Receive-Job` with an `ErrorVariable` to directly capture the error stream from the job, which provides a more accurate terminating error message than the previous method.

Additionally, the error message box now includes the full path to the log file, making it easier for the user to locate.
2025-07-23 19:37:51 -07:00
rbalsleyMSFT defd744ef0 Refactor winget download logic and error handling
Improves the reliability and clarity of the application download process.

- Introduces specific error codes for download failures, such as publisher restrictions from the Microsoft Store.
- Blocks downloading the Company Portal app from the `winget` source due to packaging issues and provides a user-friendly message.
- Refines status messages in the UI for better user feedback.
- Installs the WinGet PowerShell module for all users to prevent context-related issues.
2025-07-23 17:28:12 -07:00
rbalsleyMSFT 298809686b Add zip extraction and improve UWP app detection
Enhances the application download logic to automatically extract zip archives. This allows for handling packages that are delivered in a compressed format.

Improves the reliability of identifying UWP applications by iterating through all downloaded files instead of checking only the first one. This prevents errors when multiple files are present after download or extraction.
2025-07-22 16:00:11 -07:00
rbalsleyMSFT 1b80d008ef Stops running build job when UI is closed
Adds cleanup logic to the window's `Closed` event handler to properly terminate any active background build job.

This prevents the build process from becoming an orphaned job if the user closes the UI while a build is in progress. The change ensures the job, its polling timer, and log streams are all stopped and disposed of correctly.
2025-07-22 13:54:41 -07:00
rbalsleyMSFT 0e53e43c77 Improves Win32 app detection and logging
Refines the logic for verifying existing Win32 applications by checking for architecture-specific names (x86, x64, arm64).

Enhances validation by ensuring the application folder contains files of a sufficient size, not just that the folder exists.

Adds more detailed logging to aid in troubleshooting cases where an app is not found or its content is missing.
2025-07-21 19:12:10 -07:00
rbalsleyMSFT bb76d2edcf Refactor WinGet app existence check for multi-arch support
Improves the pre-download check to correctly identify existing multi-architecture applications by looking for both x86 and x64 subfolders.

This prevents unnecessary re-downloads of apps that have separate builds for each architecture. The logic is also consolidated to remove a redundant code block.
2025-07-21 17:48:59 -07:00
rbalsleyMSFT c9499d839c Improve performance of update comparison in cache lookup
Replaces the `Compare-Object` cmdlet with a faster, manual loop comparison on pre-sorted arrays to optimize the VHDX cache check for matching updates.

This change also refactors the extraction of update filenames for better readability and adds checks for empty collections to improve script robustness.
2025-07-21 16:13:14 -07:00
rbalsleyMSFT ba1dd3df6b Refactor: Simplify update package file lookup
Streamlines the logic for finding Cumulative Update (CU) and Cumulative Update Preview (CUP) files by removing redundant code.

The file search now relies directly on a wildcard search using the KB article ID, instead of first attempting a match with a specific filename. This change simplifies the script and makes the discovery process more direct. Also removes commented-out code.
2025-07-21 11:24:44 -07:00
rbalsleyMSFT 721f93d82d feat: Add per-app architecture selection and multi-arch downloads
Introduces the ability to specify and download multiple architectures for a single application.

- Adds an "Architecture" dropdown column to the Winget UI, allowing users to select the desired architecture(s) for each app (e.g., x86, x64, arm64, or 'x86 x64').
- Updates the download logic to process each specified architecture, creating separate subfolders for multi-architecture Win32 apps.
- Modifies the app list format to save and load the selected architecture for each application.
- Improves the download process by checking if an application has already been downloaded before attempting a new download.
2025-07-18 20:12:05 -07:00
rbalsleyMSFT 9df663dc9b Refactor to eliminate global variable dependencies
Removes the use of global variables for paths and architecture settings. Instead, these values are now passed explicitly as parameters to the relevant functions.

This change improves code clarity, reduces the risk of side effects, and makes the functions more modular and easier to test.
2025-07-18 17:25:39 -07:00
rbalsleyMSFT c535126605 Docs: Announce 2507.1 preview release
Updates the README to announce the new preview release, which features a major user interface update.

Removes an obsolete development zip archive.
2025-07-18 15:29:55 -07:00
rbalsleyMSFT 6df7b16cdf Docs: Add PowerShell comment-based help to all script modules
Adds standard PowerShell comment-based help blocks (synopsis and description) to all UI and common library script modules (`.psm1`) and the main UI entry point script (`.ps1`).

This improves maintainability and discoverability by documenting the purpose of each script file. Also removes various redundant or commented-out code blocks.
2025-07-18 14:52:03 -07:00
rbalsleyMSFT 7cc7919da4 Feat: Add configurable thread limit for parallel operations
Adds a "Threads" setting to the UI, allowing users to control the throttle limit for parallel tasks like driver and application processing.

This introduces a new textbox in the build options and updates the parallel processing function to use this configurable value instead of a hardcoded one.

Input validation is also added to ensure the threads value is a valid integer and is at least 1. The new setting is integrated into the configuration save/load functionality.
2025-07-18 13:45:58 -07:00
rbalsleyMSFT e639cee4ee Prevents adding duplicate application names
Adds a validation check to ensure application names are unique.

If a user attempts to add an application with a name that already exists, a warning message is displayed, and the operation is cancelled.
2025-07-17 18:28:09 -07:00
rbalsleyMSFT da299d8a03 Fix race condition in parallel UI status updates
Prevents stale, intermediate status messages from overwriting the final status of a completed task in the UI.

A set of completed task identifiers is now maintained. Any incoming intermediate status updates for tasks that are already marked as complete are ignored.

The job completion logic is also refactored for better robustness and clarity across different job-end states (failed, completed with data, completed without data).
2025-07-17 18:24:44 -07:00
rbalsleyMSFT b04a8460b0 Refactor BYO app copy and add overwrite confirmation
Improves the "Copy BYO Apps" functionality by centralizing the `UserAppList.json` update process. The application list is now saved once from the UI before starting the copy, rather than individually within each background task.

Adds a confirmation prompt that warns the user if any application folders already exist in the destination, allowing them to choose whether to overwrite them. The copy logic is updated to remove existing folders before copying to ensure a clean state.
2025-07-17 17:17:00 -07:00
rbalsleyMSFT 62b4816498 Improves application orchestration and BYO app handling
Enhances the orchestrator script to skip application installation steps if no corresponding app lists or files are present. This prevents unnecessary script executions and cleans up the output log.

Makes the 'Arguments' field optional when adding a 'Bring Your Own' application, simplifying the process for apps without arguments.

Ensures the 'Priority' field for applications is consistently saved as an integer to improve data integrity.
2025-07-17 16:50:38 -07:00
rbalsleyMSFT 56c811ad89 Forces capture media creation when installing apps
Removes the check for a pre-existing capture ISO when applications are being installed.

This change simplifies the logic by unconditionally creating the capture media if `InstallApps` is enabled. This ensures the media is always correctly configured for capturing the virtual machine to an FFU.
2025-07-17 12:22:04 -07:00
rbalsleyMSFT 71b3989083 Adds support for custom Office configuration XML
Introduces the ability to use a custom XML file for Office installation, allowing for more flexible configurations.

The build script now accepts an `OfficeConfigXMLFile` parameter. If provided, its filename will be used for the installation.

The Office Deployment Toolkit is now downloaded to the specified Office path instead of the development path. UI tooltips are updated to clarify this behavior.
2025-07-16 18:04:17 -07:00
rbalsleyMSFT e753344137 Refine Office configuration tooltips
Improves the tooltips for the Office installation path and configuration XML options.

The updated text provides clearer guidance on the purpose and usage of these settings.
2025-07-16 15:06:42 -07:00
rbalsleyMSFT bc32a8a10e Adds browse button for Office configuration file
Implements the click handler for the browse button associated with the Office configuration XML file path.

This change enables users to select the configuration file via a file dialog, automatically populating the path into the text field and improving usability.
2025-07-16 14:09:16 -07:00
rbalsleyMSFT 906f73f303 Refine driver models status column header
Shortens the "Download Status" column header to "Status" for a more concise UI.
2025-07-16 13:39:15 -07:00
rbalsleyMSFT d84c307593 Ensure capture media exists for app installation
Forces the creation of capture media if app installation is enabled and the capture ISO does not already exist.

This change prevents a failure later in the build process by ensuring the necessary media is available.
2025-07-16 13:30:12 -07:00
rbalsleyMSFT de8524b37c Refactor USB creation to use parallel processing
Improves the performance of creating multiple deployment USB drives by refactoring the process to run in parallel using `ForEach-Object -Parallel`.

Key changes:
- Mounts the deployment ISO once before processing begins, rather than for each individual drive.
- Partitions, formats, and copies files to multiple USB drives concurrently, significantly reducing the total time required.
- Simplifies and cleans up the FFU selection logic.
- Standardizes on `robocopy` for all large file transfer operations to improve performance and logging.
2025-07-16 13:05:22 -07:00
rbalsleyMSFT c67d8761e2 Fix ambiguous Windows Release selection on config load
Refines the logic for setting the Windows Release from a saved configuration to handle ambiguous values.

When a release value like '2019' exists for both Server and LTSC editions, the UI could select the wrong item. The logic now inspects the `WindowsSKU` config value to differentiate between them, correctly selecting the LTSC or non-LTSC release.
2025-07-16 11:27:21 -07:00
rbalsleyMSFT 7600ae86d1 Refactor update file discovery and cache comparison
Improves the reliability of finding update files (CU, CUP, .NET) by first using the exact filename from the update metadata. The script now only falls back to searching by KB article ID if the primary method fails.

Additionally, this enhances the VHDX cache lookup logic. The comparison now uses update file names extracted directly from their URLs, ensuring a more accurate match against the cached configuration.
2025-07-15 20:04:12 -07:00
rbalsleyMSFT 97e0998e1d Refactor update handling to improve VHDX caching logic
Separates the process of identifying required Windows updates from the download process itself. This allows for checking against the VHDX cache using a list of required updates before any files are downloaded, making the caching mechanism more efficient and reliable.

This change introduces a new `Get-UpdateFileInfo` function to gather update metadata. The main script logic is updated to first determine all necessary updates, then check for a suitable cached VHDX, and only download the updates if no valid cache item is found.
2025-07-15 17:43:08 -07:00
rbalsleyMSFT 5b85acbd73 Refactor(UI): Reorder tabs for a more logical workflow
Moves the "Build" tab to appear after the "Hyper-V Settings" tab. This change aligns the UI with the typical user process, creating a more intuitive sequence for configuration.
2025-07-14 18:09:59 -07:00
rbalsleyMSFT ca84f4dfea Implement conditional autoscroll in log monitor
Improves the user experience in the monitor tab by making the log output autoscroll conditional.

Autoscrolling is now disabled when the user selects a log entry other than the last one, allowing them to inspect previous output without interruption. Selecting the last item in the list re-enables autoscrolling.
2025-07-14 17:56:51 -07:00
rbalsleyMSFT 315f0f3858 Refactor: Remove unused BITS transfer function
Removes the `Start-BitsTransferWithRetry` function as it is no longer in use. This change cleans up the script by removing dead code.
2025-07-14 16:57:11 -07:00
rbalsleyMSFT 21d5f74dd8 Feat: Auto-save selected drivers and refine script execution
Adds a feature to automatically save the selected drivers to `Drivers.json` upon download, preserving the user's selection for future use.

Improves script execution by:
- Ensuring the disk cleanup process completes before proceeding.
- Suppressing progress bars globally for a cleaner console output.
- Hiding non-critical warnings during driver installation.
- Removing an unused BITS transfer function and trailing whitespace.
2025-07-14 16:55:15 -07:00
rbalsleyMSFT 08c9d5a0e3 feat: Add progress reporting to FFU build process
Introduces a progress reporting system to provide real-time feedback during the FFU build. This includes adding a progress bar and status messages to the UI, which are updated at key stages of the build process.

- Adds a new `Set-Progress` function to log progress updates.
- Integrates `Set-Progress` calls throughout the main build script.
- Updates the UI to parse progress logs and update the progress bar and status text.
- Improves error reporting in the UI to display more detailed failure reasons.
- Corrects a typo in the `LogicalSectorSizeBytes` parameter name in documentation and log messages.
2025-07-11 16:43:42 -07:00
rbalsleyMSFT 4a719b6c9a Improves reliability of build log monitoring
Deletes the old log file before starting a new build job to prevent the UI from displaying stale content from a previous run.

Replaces a fixed delay with a more robust wait loop that polls for the new log file's creation. This avoids a race condition where the monitor could fail to attach if the background job was slow to start. A timeout is included to prevent the UI from hanging.
2025-07-10 21:59:19 -07:00
rbalsleyMSFT 7043af47c3 Feat: Add live log monitoring tab to UI
Introduces a new "Monitor" tab in the `BuildFFUVM` UI to display live log output from the build process.

When a build is started, the UI now automatically switches to the Monitor tab. It tails the main log file in real-time and displays the content in a list view, which auto-scrolls as new entries appear.

This provides immediate visual feedback on the build progress and any errors without needing to manually open the log file.

Additionally, this change adds a Ctrl+C keyboard shortcut to copy selected log lines from the monitor view to the clipboard.
2025-07-10 19:56:59 -07:00
rbalsleyMSFT ebbb3e8ed0 Run build process in background job to keep UI responsive
Refactors the FFU build process to execute asynchronously using a background job. This prevents the UI from freezing during the build.

A timer now polls the job status and updates the UI with the final result (success or failure) upon completion. The build button is also disabled while a build is in progress to prevent multiple executions.
2025-07-10 18:00:03 -07:00
rbalsleyMSFT 863f0bba2c Refactor error handling and improve diagnostics
Introduces a centralized function to handle script termination, ensuring the DISM log is always copied on failure for easier debugging.

Improves user-facing error messages to provide clearer guidance for common issues, such as a missing hard drive, incorrect USB drive configuration, or FFU logical sector size mismatches.

Adds post-deployment verification to ensure the Windows partition is created and assigned a drive letter correctly after applying the FFU.
2025-07-10 16:09:15 -07:00
rbalsleyMSFT 7df81177de Enhance console output for readability and user experience
Refactors the script's console output to be cleaner and more user-friendly.

- Adds formatted section headers to clearly delineate each stage of the deployment process.
- Replaces verbose console logging with more selective and meaningful status messages for the user.
- Prevents external commands from opening new windows, creating a more seamless execution flow in the terminal.
2025-07-10 14:32:57 -07:00
rbalsleyMSFT 9f6ea0fb58 Improve driver injection by mounting WIMs
Replaces the slow WIM extraction process with a faster WIM mount operation. This significantly improves performance when injecting drivers from a WIM file.

The script now properly unmounts the WIM after injection to ensure resources are cleaned up correctly.

Additionally, log messages are now written to the console for better real-time feedback during script execution.
2025-07-10 12:24:49 -07:00
rbalsleyMSFT f64514cbe6 Enhance script output and information gathering
Adds a new function to gather and display detailed system information (model, CPU, memory, disk size) in both the log file and the console. This helps with verification and troubleshooting.

Improves the user experience by adding an ASCII art banner and cleaning up the console output by suppressing progress bars and command echoes.

Modernizes the script by replacing the deprecated `Get-WmiObject` cmdlet with `Get-CimInstance`.
2025-07-09 18:53:50 -07:00
rbalsleyMSFT b651bc6385 Fixes FFU selection table output
Pipes the FFU selection table through `Out-String` to ensure it is correctly rendered in the console. This prevents potential issues where the formatted table object is not displayed as intended.
2025-07-09 15:32:01 -07:00
rbalsleyMSFT 6b7351d1b3 Hides download settings when using a local ISO
Updates the UI to conditionally hide the Windows language and media type controls. These settings are only applicable when downloading a new image and are irrelevant when a local ISO file is provided.

This change simplifies the user interface and prevents confusion by only showing options relevant to the selected action.
2025-07-09 14:49:42 -07:00
rbalsleyMSFT 878b93889f Refactors ISO path handling for clarity and robustness
Inverts the conditional logic for determining available Windows releases and versions based on whether an ISO path is provided.

This change improves code readability by checking for the positive case (an ISO is present) first. It also adds validation to ensure the file path ends with the `.iso` extension.
2025-07-09 13:26:09 -07:00
rbalsleyMSFT 9edbcc6f01 feat: Dynamically update Windows architecture options
Implements logic to filter the available Windows architectures based on the selected OS Release, Version, and SKU. This ensures that only valid architecture options are presented to the user.

- Adds a new function to determine the correct architectures for different Windows versions (e.g., Server, Windows 10/11, LTSC editions).
- Wires up event handlers to the Release, Version, and SKU dropdowns to refresh the architecture list when their selection changes.
- Refactors initialization to use this new dynamic logic.
2025-07-09 12:56:57 -07:00
rbalsleyMSFT 3f6661e2dc Corrects display name for 2024 LTSC release
Updates the display name for the 2024 LTSC release to correctly identify it as Windows 11 instead of Windows 10.
2025-07-09 11:38:54 -07:00
rbalsleyMSFT f9a8e3149f Adds a "Verbose" checkbox to the UI, allowing users to enable write-verbose output from the underlying build script.
This helps in troubleshooting the FFU build process by providing more detailed output to the console. The verbose setting is saved to and loaded from the configuration file.
2025-07-09 09:50:26 -07:00
rbalsleyMSFT 4ef7c2fb0b Propagates verbose preference to the common module
Sets the verbose preference within the `FFU.Common` module to match the preference of the calling script.

This ensures that when the script is run with the `-Verbose` switch, verbose output from the module is also logged to the console.
2025-07-08 18:51:19 -07:00
rbalsleyMSFT d6689888b2 Refactor: Remove redundant Success property assignment
Removes the logic that adds a `Success` property to the returned object when an existing driver package is found.

This property is handled by the calling function, making this assignment unnecessary and simplifying the driver-saving task functions.
2025-06-28 00:57:38 -07:00
rbalsleyMSFT 1523091637 Feat: Add WIM compression for existing driver folders
Implements logic to compress previously downloaded driver folders into WIM files if the compression option is enabled. This ensures consistency in the driver store when re-running the download process.

Refactors the parallel task processing to rely on a standardized boolean 'Success' property from all driver download tasks. This simplifies the result handling logic and makes it more reliable than parsing status strings.
2025-06-28 00:51:22 -07:00
rbalsleyMSFT 0b0046986e Refactor driver existence check into a common function
Adds a new `Test-ExistingDriver` function to the common driver module to centralize the logic for checking if drivers have already been downloaded, either as a folder or a WIM file.

This change removes duplicated code from the Dell, HP, Lenovo, and Microsoft driver download tasks, simplifying maintenance and ensuring consistent behavior across all manufacturers.
2025-06-27 21:14:53 -07:00
rbalsleyMSFT 1b0c0da677 Optimize driver download by checking for existing WIM files
Refactors the driver download logic for all manufacturers to first check for the existence of a final `.wim` archive. If a WIM file is found, the download and processing for that model is skipped, significantly improving performance on subsequent runs.

This change also resolves a potential type conversion error when processing driver mapping JSON files and corrects a minor typo in a log message.
2025-06-27 12:51:51 -07:00
rbalsleyMSFT 660a619944 Improves driver matching to select the most specific rule
Refines the driver selection logic to correctly handle overlapping or wildcard-based rules in the driver mapping file.

Previously, the script would use the first rule that matched the system manufacturer and model. This could lead to a less specific rule being chosen if it appeared before a more specific one.

The logic now finds all matching rules and selects the one with the most specific model name, ensuring a more accurate driver package is applied.
2025-06-27 10:57:33 -07:00
rbalsleyMSFT dcb7957d15 Refactor driver handling and remove preview update option
Removes the parameter for installing preview cumulative updates to simplify the script's interface.

Improves logging during the driver copy process to provide better visibility into which drivers are being used.

The UI no longer loads the selected device 'Make' from the configuration on startup.
2025-06-27 09:34:25 -07:00
rbalsleyMSFT dfe07b16ae Adds support for an initial directory in folder picker
Enhances the modern folder browser to accept and open to a specified initial directory.

This improves the user experience by starting the "Browse for Drivers" dialog in the project's 'Drivers' subfolder, reducing the need for manual navigation. The implementation uses the Win32 API to create a shell item from the initial path and set it as the dialog's starting folder.
2025-06-26 18:37:22 -07:00
rbalsleyMSFT 9bbb40ce8c feat: Clean up driver source and obsolete config
Automatically deletes the source driver folder after successful compression into a WIM file to conserve disk space. A failure to delete will only log a warning and not interrupt the process.

Removes unused 'Make' and 'Model' properties from the UI configuration.
2025-06-26 18:12:35 -07:00
rbalsleyMSFT 98c5946efd Feat: Automate driver selection during FFU deployment
Adds a `DriverMapping.json` file to automate driver injection during image deployment.

Driver download tasks now generate or update this mapping file with the relative path for each successfully downloaded driver package.

The deployment script now uses this file to automatically detect and select the correct drivers for the target hardware, removing the need for manual selection. The manual driver selection prompt is retained as a fallback.
2025-06-26 17:45:31 -07:00
rbalsleyMSFT 40fd739b2c Refactor: Unify driver WIM and folder detection
Streamlines the driver discovery process by searching for both WIM files and driver folders at the same time. This replaces the previous sequential logic that checked for WIMs first, then folders.

If multiple driver sources are found, they are now presented in a single, unified list for user selection, regardless of whether they are a WIM or a folder. This simplifies the script's control flow and improves the user experience.
2025-06-24 17:01:15 -07:00
rbalsleyMSFT d2909ab21d Adds support for driver installation from WIM files
Updates the driver installation logic to prioritize searching for and installing drivers from .wim files located in the `Drivers` directory. If a single WIM is found, it is used automatically. If multiple WIMs are found, the user is prompted for a selection.

The script extracts the WIM to a temporary location before injecting the drivers. The previous method of installing from a folder is retained as a fallback if no WIM files are present. Error handling and cleanup for the WIM installation process are also included.
2025-06-24 16:13:02 -07:00
rbalsleyMSFT 4f5445a833 Refactor MSI extraction for improved reliability
Implements a dedicated mutex to serialize MSI extraction operations. This prevents race conditions when multiple driver packages are processed in parallel by different tasks.

Adds a post-extraction verification step to ensure the target directory is not empty. This guards against silent failures where `msiexec` exits successfully but extracts no files, triggering a retry if necessary.
2025-06-24 13:01:23 -07:00
rbalsleyMSFT 54dad486a4 Corrects list view updates to use ItemsSource
Modifies the item lookup logic to query the `ItemsSource` property instead of the `Items` collection.

This ensures changes are applied directly to the underlying data source, leading to more reliable UI updates.
2025-06-23 13:44:56 -07:00
rbalsleyMSFT 84fca7ba25 Fixes download logic for filtered driver lists
Updates the download function to source selected drivers from the complete data model instead of the UI's filtered view.

This corrects an issue where selected drivers would be excluded from the download operation if they were hidden by a filter.
2025-06-20 18:00:44 -07:00
rbalsleyMSFT 795b4e5095 Optimize Dell catalog parsing to reduce memory usage
Refactors the Dell driver catalog parsing to use a streaming `System.Xml.XmlReader` instead of loading the entire XML file into memory.

This change significantly reduces the memory footprint and improves performance, especially when processing large catalog files. The new approach reads the file node by node, processing each software component individually.
2025-06-20 17:46:02 -07:00
rbalsleyMSFT 2e497ccec8 Refactor: Pre-process Dell catalog before parallel downloads
Moves the Dell catalog download and preparation logic from the individual driver download task to the parent function.

This prevents a race condition where multiple parallel tasks would attempt to download and extract the same catalog file simultaneously. The catalog is now prepared once before any driver downloads begin, improving efficiency and reliability.

Additionally, comments out manual garbage collection calls in the VM build UI.
2025-06-20 16:30:23 -07:00
rbalsleyMSFT d0c5ddc9c7 Refactor UI config logic into a core module
Moves the logic for saving and loading configuration files from the main UI script into the `FFUUI.Core.Config` module. This change improves modularity and separation of concerns, making the code easier to maintain.

The main `BuildFFUVM_UI.ps1` script is simplified, with event handlers now calling the new, dedicated functions in the core module to manage configuration state.

Also corrects the path for the `FFUConfig.json` file.
2025-06-20 14:25:39 -07:00
rbalsleyMSFT 9871d1c23b Refactors driver download logic into a dedicated function
Extracts the driver download implementation from the button click event handler into a new `Invoke-DownloadSelectedDrivers` function.

This change improves code modularity and maintainability by separating the business logic for downloading drivers from the UI event handling code. The event handler is now simplified to a single function call.
2025-06-20 12:09:39 -07:00
rbalsleyMSFT 50eec23c85 Refactor Get Models logic into a dedicated function
Extracts the model fetching and UI update logic from the button's click event handler into a new, reusable `Invoke-GetModels` function.

This refactoring improves code organization by separating UI event handling from the core application logic, leading to better readability and maintainability.
2025-06-20 12:01:02 -07:00
rbalsleyMSFT 1675c48fc3 Refactor and improve ListView item selection
Extracts the logic for toggling an item's selection state with the spacebar into a new, reusable function. This change centralizes previously duplicated code, improving maintainability.

The event handling is moved from `KeyDown` to `PreviewKeyDown` to provide a more reliable user experience. Additionally, focus is now correctly restored to the toggled item, preventing the selection from moving unexpectedly.
2025-06-20 11:50:47 -07:00
rbalsleyMSFT 86ec6de894 Refactor: Centralize file and folder dialog logic
Introduces a new `Invoke-BrowseAction` helper function to consolidate the creation of file and folder selection dialogs.

This change replaces scattered and repetitive `OpenFileDialog`, `SaveFileDialog`, and `ModernFolderPicker` implementations throughout the event handlers with a single, unified function call. This refactoring simplifies the event handler code, reduces duplication, and improves maintainability.
2025-06-20 11:13:58 -07:00
rbalsleyMSFT ca0a51ec15 Refactor VM Switch initialization logic
Move the `Initialize-VMSwitchData` function call from the main UI script into the `Initialize-UIDefaults` function.

This change centralizes the UI default initialization logic within the core module, improving code organization and ensuring VM Switch data is loaded at the correct time.
2025-06-19 19:16:11 -07:00
rbalsleyMSFT 244aba88d3 Refactor UI initialization into core module
Moves UI initialization logic for the "Bring Your Own" applications section from the main UI script into the `FFUUI.Core` module.

This centralization cleans up the main script's `Loaded` event handler and improves code organization and maintainability.
2025-06-19 19:06:56 -07:00
rbalsleyMSFT 4b7e815c68 Refactor Applications tab UI event handling
Consolidates the scattered event handlers for the Applications tab checkboxes into a single, centralized function and a shared event handler. This new approach manages the visibility of all dependent UI panels based on the state of the checkboxes.

This refactoring simplifies the codebase, eliminates redundant logic, and ensures a more consistent and predictable user interface state.

Additionally, this change fixes bugs where clearing list views for Winget results and drivers did not properly update their "select all" header checkboxes.
2025-06-19 18:38:53 -07:00
rbalsleyMSFT c946e56a41 Refactors CU checkbox initialization logic
Moves the initial state logic for the "Latest CU" and "Preview CU" checkboxes from the main UI script to the core initialization module.

This change centralizes UI default settings into the `Initialize-UIDefaults` function, improving code organization and maintainability.
2025-06-19 18:12:06 -07:00
rbalsleyMSFT cbc9ec4634 Refactor: Remove Office UI logic from load event
Removes the logic for managing the visibility of Office installation options from the window's loaded event handler.

This change centralizes UI state management within dedicated event handlers, simplifying the initial UI setup.
2025-06-19 16:40:13 -07:00
rbalsleyMSFT 094e084316 Refactor and centralize UI panel visibility logic
Consolidates the logic for showing and hiding UI elements into dedicated functions, `Update-DriverDownloadPanelVisibility` and `Update-OfficePanelVisibility`.

This change simplifies the event handlers for the "Download Drivers" checkbox by using a single handler for both checked and unchecked states. It also moves the initial UI setup from the main script into the core initialization module, improving code organization and reducing duplication.
2025-06-19 16:37:32 -07:00
rbalsleyMSFT 52085cf521 Refactor Apps Script Variables UI logic
Extracts inline event handler logic for managing Apps Script Variables from the main UI script into new functions within the `FFUUI.Core.Applications` module.

This change improves code organization and maintainability by centralizing the logic for adding and removing variables. The event handlers are updated to call these new, dedicated functions.
2025-06-19 16:11:40 -07:00
rbalsleyMSFT a4e9b70b27 Refactor: Centralize driver checkbox event handling
Moves the interdependent state logic for the driver-related checkboxes from the main UI script into a new, centralized function within the core module.

This change simplifies the event handling by using a single handler for all related checkboxes, which improves code readability and maintainability. The new function is also called during initialization to ensure the UI reflects the correct state on startup.
2025-06-19 15:42:57 -07:00
rbalsleyMSFT 2cfd947429 Refactor UI event handlers into core module
Moves the 'Browse' button click event handlers from the main UI script into the core handlers module.

This change improves code organization and separation of concerns. The handlers are updated to be more self-contained by retrieving state from the event source rather than relying on a global state variable, which improves modularity.
2025-06-19 15:31:00 -07:00
rbalsleyMSFT 24ed20305f Refactor: Decouple BYO Apps UI logic into core modules
Moves the event handling and business logic for the "Bring Your Own Applications" feature from the main UI script into dedicated core modules.

This change improves code organization and separation of concerns by centralizing application-related functions and their corresponding event handlers. The main UI script is now cleaner and primarily responsible for UI initialization, enhancing overall code maintainability.
2025-06-19 15:19:35 -07:00
rbalsleyMSFT 12607cca44 Refactor: Move Winget download logic to core module
Extracts the Winget app download logic from the main UI script into a new `Invoke-WingetDownload` function within the core Winget module.

This change decouples the UI event handling from the business logic, improving modularity and maintainability. It also introduces more robust error handling for the download process by wrapping the logic in a try/catch block.
2025-06-19 12:23:13 -07:00
rbalsleyMSFT 00ee79d33c Style: Remove extraneous blank lines
Improves code readability by removing unnecessary whitespace.
2025-06-19 12:16:55 -07:00
rbalsleyMSFT d7d0cb3a06 Refactor: Centralize UI event handlers and abstract list clearing
Moves UI event handling logic from the main script into a dedicated core module to improve separation of concerns.

Introduces a new shared function, `Clear-ListViewContent`, to consolidate duplicated logic for clearing list views. This generic function handles user confirmation, data source clearing, and UI updates for multiple tabs, significantly reducing code redundancy.
2025-06-19 12:11:57 -07:00
rbalsleyMSFT ec8ce1f2f6 Refactor: Move application tab event handlers to core module
Relocates the event handling logic for the "Applications" tab from the main UI script into the `FFUUI.Core.Handlers` module.

This improves code organization and modularity by centralizing UI logic. The handlers are also updated to use local state instead of a global variable.
2025-06-19 11:00:14 -07:00
rbalsleyMSFT f7f78da1a1 Chore: Remove unnecessary comment
Removes a redundant comment from the UI script to improve code readability.
2025-06-18 17:31:47 -07:00
rbalsleyMSFT 49b0772076 Refactor USB drive UI logic into core modules
Moves event handlers and initial state configuration for the USB drive creation settings from the main UI script into the core handler and initialization modules.

This change centralizes the UI logic, improving code organization and maintainability.
2025-06-18 17:30:23 -07:00
rbalsleyMSFT 9b8a6c36db Improves Winget search performance by streaming results
Refactors the package search logic to process results as a stream instead of collecting them all in memory first.

This change uses the PowerShell pipeline to transform search results on the fly. This significantly reduces memory usage and improves responsiveness, especially for queries that return a large number of packages.
2025-06-18 17:19:57 -07:00
rbalsleyMSFT f44e06c57e Refactor USB drive list with dynamic selection and sorting
Updates the USB drive selection UI to align with other list views in the application. This change replaces the static "Select All" checkbox with a dynamic, selectable column that includes the checkbox in the header.

This refactoring provides a more consistent user experience and adds column sorting functionality to the USB drive list.

Additionally, the underlying shared function for creating selectable columns is improved to use the central UI state object for managing controls, removing the dependency on script-scoped variables for better encapsulation.
2025-06-18 16:10:15 -07:00
rbalsleyMSFT b46b904504 Refactor USB drive event handlers to core module
Moves the event handling logic for the USB drive selection controls from the main UI script to the `FFUUI.Core.Handlers` module.

This change improves code organization by centralizing UI logic. The handlers were also improved to correctly manage the "Select All" checkbox state.
2025-06-18 13:52:25 -07:00
rbalsleyMSFT 004b42436e Refactor USB drive detection event handler
Moves the click event handler for detecting USB drives from the main UI script to the dedicated core handlers module.

This change centralizes UI logic, improving code organization and maintainability.
2025-06-18 13:24:38 -07:00
rbalsleyMSFT 5e5db62d2a Refactors CU checkbox logic to core handlers
Moves the event handling logic for the "Latest CU" and "Preview CU" checkboxes from the main UI script into the core handlers module.

This change centralizes UI event handling, improving code organization and maintainability. The new implementation is also more robust, accessing state via the window's `Tag` property rather than a global script variable.
2025-06-18 13:16:34 -07:00
rbalsleyMSFT fd39b0008e Refactor: Centralize Install Apps checkbox logic
Consolidates the state management for the "Install Apps" checkbox into a single, reusable function.

Previously, the logic to automatically check and disable "Install Apps" when selecting an update or installing Office was duplicated and scattered across multiple event handlers and files.

This change introduces a new core function that centralizes this behavior. A single event handler is now used for all relevant checkboxes (Updates and Office), simplifying the UI code, reducing redundancy, and making the logic more robust and maintainable. The initial state is also set correctly on startup.
2025-06-18 13:07:07 -07:00
rbalsleyMSFT af5d5206f8 Refactors Office UI event handlers into core module
Moves the event handling logic for the M365 Apps/Office options from the main UI script into the `FFUUI.Core.Handlers` module.

This change centralizes UI event handling, improving code organization and maintainability. It also decouples the handlers from the main script by using a local state object instead of a global script variable.
2025-06-17 22:55:42 -07:00
rbalsleyMSFT de0e014e50 Refactor UI event handlers to a new module
Moves all UI event handling logic from the initialization script into a new, dedicated module `FFUUI.Core.Handlers.psm1`.

This change improves code organization and separation of concerns by centralizing event handler registration.
2025-06-17 18:39:10 -07:00
rbalsleyMSFT 6bf38d369b Improves code formatting
Adds a blank line to improve readability by separating UI state initialization logic.
2025-06-17 18:34:00 -07:00
rbalsleyMSFT dbb98ba4fe Refactor: Improve code clarity and remove unused variable
Renames the event handler parameter from `$sender` to `$eventSource` for better readability.

Removes an unused variable in the parallel processing function to clean up the code.
2025-06-17 18:31:56 -07:00
rbalsleyMSFT 87c9bc769e Improve driver list data handling and filtering
Refactors the driver selection UI to enhance stability and performance by changing how the underlying data source is managed. Creating and re-assigning a new list when data changes, rather than modifying the bound collection in-place, prevents UI inconsistency errors.

- Updates the model search to use the native WPF `CollectionView.Filter` for more efficient and reliable filtering.
- Fixes an issue where HTML entities were not decoded in Microsoft driver model names.
- Ensures selected drivers from one manufacturer are preserved when fetching models for another.
- Centralizes driver-related button event handlers into the core initialization module.
2025-06-17 18:25:06 -07:00
rbalsleyMSFT f014d7ffcd Refactor: Move driver download logic to core module
Relocates the click event handler for the driver download button from the main UI script to the `FFUUI.Core` module.

This change centralizes UI logic within the core module, improving code organization and maintainability. The handler is updated to use the local window state object instead of global script variables.
2025-06-17 16:18:38 -07:00
rbalsleyMSFT 07306ff209 Refactors model filter event handling to core module
Moves the `TextChanged` event handler for the driver model filter from the UI script to the core `Register-EventHandlers` function.

This change centralizes the logic and makes the handler more generic by retrieving the UI state from the window's tag instead of relying on a global variable.
2025-06-17 16:11:18 -07:00
rbalsleyMSFT ab0b92ad5c Refactor Dell driver catalog download logic
Moves the logic for downloading and preparing the Dell driver catalog from the main build scripts directly into the specific Dell driver download task.

This change encapsulates all Dell-specific logic within the `Save-DellDriversTask` function, making it self-sufficient. Each parallel task now manages its own dependency on the catalog file, which simplifies the main script's responsibilities.

Also includes minor code style formatting.
2025-06-17 16:02:34 -07:00
rbalsleyMSFT df96d14643 Clean up comments in build UI script
Removes several obsolete or redundant comments to improve code readability.
2025-06-17 15:52:58 -07:00
rbalsleyMSFT 61ec7509ad Refactor: Move model fetching logic to core UI module
Centralizes the `btnGetModels` click event handler from the main UI script into the `FFUUI.Core` module to improve code organization and separation of concerns.

The handler is updated to use the local state object instead of a global script variable. The logic for updating the model list is also improved to preserve the collection's object reference, ensuring data binding stability.
2025-06-17 15:07:18 -07:00
rbalsleyMSFT 0bcedadc5c Refactors UI event handlers into core module
Moves UI event handler registration logic from the main script to the `Register-EventHandlers` function in the `FFUUI.Core` module.

This change centralizes UI logic, improving code organization and modularity. The event handlers are also updated to access state via the window's tag, reducing reliance on script-scoped variables.
2025-06-17 15:03:12 -07:00
rbalsleyMSFT c1c5aa9239 Refactor: Move initial Windows settings population to core module
Moves the logic for the initial population of the Windows settings comboboxes from the UI's loaded event handler into the `Initialize-UIDefaults` function.

This change centralizes UI default value setup into a single location, improving code organization and maintainability.
2025-06-17 14:26:31 -07:00
rbalsleyMSFT ab7c8aa250 Refactor UI initialization and event handling
Moves the VM Switch data population and event handler logic from the main UI script into the `FFUUI.Core.Initialize` module.

This change improves code organization by centralizing UI initialization and event registration into dedicated functions, making the main script cleaner.

It also simplifies module manifests by changing explicit function exports to a wildcard.
2025-06-17 14:18:41 -07:00
rbalsleyMSFT 0fb9878cff Refactor Windows settings function for clarity
Renames the function for populating Windows settings UI controls to better reflect its purpose.

The `Get-` verb more accurately describes the function's action of retrieving information from the ISO file, as opposed to the previous `Initialize-` verb.
2025-06-17 13:21:56 -07:00
rbalsleyMSFT acd0092f26 Refactor UI initialization into core module
Extracts the logic for setting default UI control values from the main script into a new `Initialize-UIDefaults` function within the `FFUUI.Core` module.

This change cleans up the main UI script's window loaded event, improving code organization and separating UI setup from default value initialization.
2025-06-17 13:14:54 -07:00
rbalsleyMSFT 71a4923c56 Refactors UI control access for improved clarity
Replaces `$window.FindName` with  `$script:uiState.Controls` for accessing UI elements.

This change simplifies how UI controls are referenced, making the code easier to read and maintain.
2025-06-17 10:38:14 -07:00
rbalsleyMSFT dea59c5285 Removes unnecessary comment
Improves code readability by removing a redundant comment.
2025-06-17 10:21:40 -07:00
rbalsleyMSFT f1d0ab20cb Refactor function names and improve code formatting
Renames functions for better semantic clarity:
- `Refresh-WindowsSettingsCombos` becomes `Initialize-WindowsSettingsCombos` to better reflect its role in initial UI setup.
- `Filter-DriverModels` becomes `Search-DriverModels` to more accurately describe its use in user-driven model searches.

Enhances code readability in the UI script through consistent formatting of event handlers and multi-line script blocks.
2025-06-16 19:37:58 -07:00
rbalsleyMSFT fb9dc3fbc5 Refactors dynamic UI setup into core module for better organization
Moves initialization logic for several list views (driver models, Winget results, app script variables) and the features grid into a new `Initialize-DynamicUIElements` function.
This centralizes the setup of these UI components within the `FFUUI.Core.Initialize` module, improving code structure.

Additionally:
- Updates sort event handlers in the new module to correctly retrieve UI state from the window's tag.
- Enhances `Invoke-ListViewSort` by initializing sort state flags if they are not already present, improving sorting stability.
2025-06-16 18:28:04 -07:00
rbalsleyMSFT 49acd71ff9 Moves UI initialization logic to a new module
Extracts the UI control initialization functionality from the main core script into a dedicated module.
This improves code organization and modularity.
The module manifest is updated to include this new initialization module.
2025-06-16 17:00:26 -07:00
rbalsleyMSFT 026fdc0d33 Removes redundant comment from appsScriptVariablesDataList clearing logic 2025-06-15 14:22:13 -07:00
rbalsleyMSFT 08a4c1b732 Removes comments from driver UI logic
Cleans up the driver selection UI script by removing comments that were redundant, self-explanatory, or no longer relevant.
This enhances code readability and maintainability without altering functionality.
2025-06-15 13:58:27 -07:00
rbalsleyMSFT 3eaa8b6efd Removes comments from XAML
Removes unnecessary comments from the XAML file to improve readability.
2025-06-15 10:32:17 -07:00
rbalsleyMSFT 6c4e157b5c Updates module import path
Corrects the import path for the common module to `FFU.Common`.
2025-06-13 15:01:15 -07:00
rbalsleyMSFT b7847ebaad Refactors ListView helper functions to a shared module
Moves ListView item manipulation functions (`Update-ListViewPriorities`, `Move-ListViewItemTop`, `Move-ListViewItemUp`, `Move-ListViewItemDown`, `Move-ListViewItemBottom`) from the `FFUUI.Core.Applications` module to the `FFUUI.Core.Shared` module.

This improves code organization and reusability by centralizing common UI helper logic.
2025-06-13 14:40:02 -07:00
rbalsleyMSFT 1fe4e19239 Removes unnecessary module requirements from core and drivers modules 2025-06-13 14:15:26 -07:00
rbalsleyMSFT 4b4f5eba8c Refactors UI configuration and state management
Moves UI configuration loading and retrieval logic into a new `FFUUI.Core.Config` module.
Removes the `Set-UIValue` helper function from `BuildFFUVM_UI.ps1` and the `Get-UIConfig` function from `FFUUI.Core.psm1`.
This centralizes UI-related configuration handling for improved modularity.
2025-06-13 13:43:43 -07:00
rbalsleyMSFT 8c9d40eefa Refactor: Relocate Windows settings logic to new module
Moves functions and static data related to Windows releases, versions, SKUs, and optional features from the main UI script and core module into a new, dedicated `FFUUI.Core.WindowsSettings` module.

This change enhances code organization and modularity by centralizing Windows-specific configuration and UI helper functions. The UI script is updated to reflect these changes, including passing state to the refactored `BuildFeaturesGrid` function.
2025-06-13 13:22:43 -07:00
rbalsleyMSFT 925d2172ff Improves readability of allowed languages list
Formats the `$script:allowedLangs` array by placing each language code on a new line. This change enhances code readability and maintainability.
2025-06-13 12:16:14 -07:00
rbalsleyMSFT 6b0fb0385d Removes obsolete path variable definitions
Cleans up script initialization by removing unused path variables for application, application list, and user application list.
2025-06-13 11:55:32 -07:00
rbalsleyMSFT 2e9a7265e9 Refactors application management to a new module
Moves ListView manipulation, BYO application import/export, and copy task logic from the main UI script and core UI module into a new `FFUUI.Core.Applications` module.

This improves code organization and modularity for application-related features.
2025-06-13 11:53:14 -07:00
rbalsleyMSFT d1835c5c06 Refactor Winget functions into a dedicated module
Extracts Winget-related functionalities, including search, list management, installation, and download tasks, from `BuildFFUVM_UI.ps1` and `FFUUI.Core.psm1`.
Consolidates these features into a new module, `FFUUI.Core.Winget.psm1`, to improve code organization and maintainability.
2025-06-13 10:34:46 -07:00
rbalsleyMSFT 7babad8262 Refactor driver management into dedicated modules
Relocates driver-specific download, parsing, and management logic from the main UI script and the FFUUI.Core module into new, dedicated modules for each manufacturer (Dell, HP, Lenovo, Microsoft). This improves modularity and code organization.

Additionally, centralizes common HTTP headers and user agent strings in the FFUUI.Core module, accessible via a new helper function.
2025-06-12 15:47:46 -07:00
rbalsleyMSFT 9282b4231e Add FFU.Common and FFUUI.Core module manifests and shared UI functions
- Created module manifest for FFU.Common with initial version 0.0.1.
- Created module manifest for FFUUI.Core with initial version 0.0.1.
- Implemented shared UI functions in FFUUI.Shared.psm1, including:
  - Update-ListViewItemStatus: Updates the status of items in a ListView.
  - Update-OverallProgress: Updates a progress bar and status label.
  - Invoke-ProgressUpdate: Enqueues progress updates to the UI thread.
  - Add-SortableColumn: Adds sortable columns to a ListView.
  - Add-SelectableGridViewColumn: Adds a selectable column with a "Select All" checkbox.
  - Update-SelectAllHeaderCheckBoxState: Updates the state of the header checkbox.
  - Invoke-ListViewSort: Sorts ListView items based on specified properties.
  - Show-ModernFolderPicker: Displays a modern folder picker dialog.
2025-06-11 20:50:51 -07:00
rbalsleyMSFT e4201aebff Refactor UI initialization to utilize centralized state object for control management
- Introduced the Initialize-UIControls function to streamline the assignment of UI control references to the centralized state object.
- Removed repetitive control assignments from the window loading event, enhancing maintainability and readability.
- Improved the overall structure of the UI initialization process for better consistency across the application.
2025-06-10 20:09:13 -07:00
rbalsleyMSFT 1fb42bffdc Implement code changes to enhance functionality and improve performance 2025-06-10 19:04:25 -07:00
rbalsleyMSFT 9d7d0a0ec9 Remove debug logging statements from UI initialization for cleaner code 2025-06-09 20:32:54 -07:00
rbalsleyMSFT c5730230f6 Refactor functions to utilize a centralized state object for improved consistency and maintainability
Refactor functions to use a centralized state object

Modifies various functions to accept and utilize a `$State` parameter, replacing direct access to `$script:uiState` and module-scoped variables.

This change centralizes state management, improving code consistency and maintainability by passing necessary UI elements, data, and default configurations explicitly.
2025-06-09 20:30:57 -07:00
rbalsleyMSFT 19867cbcd9 Remove verbose flag from BuildFFUVM script execution for cleaner output 2025-06-07 20:21:44 -07:00
rbalsleyMSFT 131bd63a5b Refactor MainThreadLogPath to use script:uiState.LogFilePath for consistency 2025-06-07 19:07:05 -07:00
rbalsleyMSFT 562529a26b Refactor Get-ModelsForMake and Update-CopyButtonState to use State parameter
- Updated functions to accept a State parameter for improved scope management.
- Replaced direct window control access with State object references for better encapsulation and consistency.
- Ensured proper handling of UI elements within the context of the State object.

Refactors UI functions to use a State parameter

Improves scope management and encapsulation by passing a `State` object to UI-interacting functions.

This change modifies several functions, including `Get-ModelsForMake` and `Update-CopyButtonState`, to accept a `State` parameter.
It replaces direct access to UI controls via `$window.FindName` with references from the `State` object, leading to more consistent and maintainable code.
2025-06-07 18:47:15 -07:00
rbalsleyMSFT 6eae7226be Refactor UpdateInstallApps logic to use state object
- Moved the UpdateInstallAppsBasedOnUpdates function to the Controls section of the uiState object for better encapsulation.
- Updated the function to accept a state parameter, reducing reliance on the script scope.
- Adjusted event handlers to pass the state object, ensuring consistent access to UI state.
- Removed the previous definition of the function from the script scope to streamline the code.
2025-06-07 18:12:12 -07:00
rbalsleyMSFT 0091a4da92 Docs: Correct comment for Windows SKU combo population
The comment explaining how the Windows SKU combo box's ItemsSource is populated was outdated. It incorrectly stated that the `Update-WindowsSkuCombo` function was called via `RefreshWindowsSettingsCombos`.

This change removes the inaccurate reference, clarifying that `Update-WindowsSkuCombo` is now called directly.
2025-06-07 17:57:24 -07:00
rbalsleyMSFT 95ac89c5ec remove file 2025-06-07 17:51:49 -07:00
rbalsleyMSFT 11084f6689 Added new $state object and refactored code to use the $state reference instead.
- Moved Get-UIConfig from BuildFFUVM_UI.ps1 to FFUUI.Core.psm1 to enhance separation of concerns.
- Updated Get-UIConfig to accept a central $State object, removing direct dependencies on global UI variables.
- Modified internal logic to access UI control values from the $State.Controls hashtable.
- Updated calls in BuildFFUVM_UI.ps1 to pass the $script:uiState object to the refactored function.
- Exported Get-UIConfig from FFUUI.Core.psm1 for accessibility in the main UI script.
2025-06-07 17:36:30 -07:00
rbalsleyMSFT 1b5fa5129c Updated formatting 2025-06-05 16:31:44 -07:00
rbalsleyMSFT 00a27fc4a8 Enhance MSRT handling for Windows installations by differentiating between client and LTSB/LTSC types 2025-05-29 20:09:22 -07:00
rbalsleyMSFT e1b1dfafac Reorder script execution list to ensure Install-Office.ps1 runs after Update-Defender.ps1 and remove unnecessary pause 2025-05-29 19:33:11 -07:00
rbalsleyMSFT 4ffdba8e41 Refactor Windows SKU handling to derive values internally and update mappings for LTSB/LTSC releases
- Updated Update-WindowsSkuCombo function to derive selected release value and display name from the ComboBox, removing the need for parameters.
- Enhanced logging to include display names for better traceability.
- Corrected SKU values for Windows 10 LTSB 2016 and LTSC 2019 in the release mappings.
- Adjusted Get-AvailableSkusForRelease function to handle specific cases for LTSB 2016 and LTSC 2019, ensuring accurate SKU retrieval.
2025-05-29 17:19:22 -07:00
rbalsleyMSFT 092ae26257 Add dynamic SKU handling for Windows releases and update UI integration
- Introduced a new function `Get-AvailableSkusForRelease` to retrieve available SKUs based on the selected Windows release value.
- Added a new function `Update-WindowsSkuCombo` to refresh the SKU ComboBox in the UI when the release changes.
- Updated the SKU lists for various Windows versions, including client and server SKUs, as well as LTSC editions.
- Modified the `RefreshWindowsSettingsCombos` function to call the new SKU update function.
- Ensured that the SKU ComboBox is populated dynamically based on the selected release, improving user experience and accuracy.
2025-05-29 16:59:45 -07:00
rbalsleyMSFT 11c3eeb9b8 Add Windows 10 LTSB 2016 and LTSC 2019 to release options and update version mapping 2025-05-29 16:22:17 -07:00
rbalsleyMSFT ae656932ba Refactor SKU list formatting for improved readability and maintainability 2025-05-29 15:45:49 -07:00
rbalsleyMSFT 7d4e8ba27c Add additional Windows SKU and release options to core logic
- Expanded the list of allowed SKUs to include 'Enterprise 2016 LTSB', 'Enterprise N 2016 LTSB', 'Enterprise LTSC', 'Enterprise N LTSC', 'IoT Enterprise LTSC', and 'IoT Enterprise N LTSC'.
- Updated the list of all Windows releases to include 'Windows 10 LTSC 2021' and 'Windows 10 LTSC 2024'.
- Modified the Windows version map to include version mappings for LTSC 2021 and LTSC 2024.
2025-05-29 15:38:27 -07:00
rbalsleyMSFT fd3d45a21a Improve initial directory handling for Drivers.json SaveFileDialog
- Enhanced logic to determine the initial directory for the SaveFileDialog based on the parent directory of the path in the txtDriversJsonPath TextBox.
- Added error handling and logging for cases where the path is invalid or empty, ensuring the dialog defaults to its standard behavior when necessary.
- Improved user feedback by logging actions related to the selection or cancellation of the SaveFileDialog.
2025-05-29 14:02:40 -07:00
rbalsleyMSFT c8042c6a75 Add Drivers.json path configuration to UI and core logic
- Introduced a new TextBox and Button in the UI for specifying the Drivers.json file path.
- Updated the UI layout to accommodate the new Drivers.json path input.
- Enhanced the core logic to set a default path for Drivers.json based on the Drivers folder.
- Implemented functionality to browse and select the Drivers.json file, allowing users to create or select an existing file.
2025-05-29 13:26:03 -07:00
rbalsleyMSFT 421fb76320 Add 'Update Latest Microcode' checkbox to UI and update related logic for handling microcode updates 2025-05-29 12:54:15 -07:00
rbalsleyMSFT 6f336970db Add 'Update ADK' checkbox to UI and update related logic for handling ADK installation 2025-05-29 10:39:19 -07:00
rbalsleyMSFT 985285963c Add 'Remove Downloaded Update Files' checkbox to UI and update related logic for handling updates 2025-05-28 18:01:59 -07:00
rbalsleyMSFT 43223ad1cc Add 'Remove Apps Folder Content' checkbox to UI and update related logic
- Introduced a new checkbox in the UI for removing application content in the Apps folder after FFU capture.
- Updated the Get-UIConfig function to retrieve the state of the new checkbox.
- Added default value for the new 'RemoveApps' parameter in the general defaults.
2025-05-28 17:43:59 -07:00
rbalsleyMSFT f6c8172676 Implement Apps Script Variables data list and enhance ListView sorting functionality
- Introduced a script-scoped list for managing Apps Script Variables data.
- Updated the ListView to bind its ItemsSource to the new data list.
- Enabled sorting for Key and Value columns in the Apps Script Variables ListView.
- Improved item addition and removal logic to interact with the data list directly.
- Ensured header checkbox state updates correctly after modifications to the data list.
2025-05-28 16:50:45 -07:00
rbalsleyMSFT 05466f96c0 Add selectable "Select All" checkbox functionality to ListView columns and update button naming for clarity
- Implemented Add-SelectableGridViewColumn function to allow adding a header checkbox for selecting all items in ListViews.
- Updated the ListView setup in BuildFFUVM_UI.ps1 to utilize the new selectable column feature for driver models and winget results.
- Added IsSelected property to items in Apps Script Variables ListView for better selection management.
- Renamed button from "Remove Apps Script Variable" to "Remove Selected" for improved clarity on functionality.
2025-05-28 15:33:24 -07:00
rbalsleyMSFT 08b1df79f2 Refactor Apps Script Variables UI for improved layout and usability
- Simplified the input layout for defining Apps Script Variables by removing the grid structure.
- Added clear labels for "Key" and "Value" inputs to enhance user understanding.
- Adjusted the "Add Variable" button for better alignment and padding, improving the overall user experience.
2025-05-27 19:30:34 -07:00
rbalsleyMSFT 3ec53548a9 Add AppsScriptVariables functionality to UI for defining key-value pairs
- Introduced a new checkbox to enable defining Apps Script Variables.
- Added a panel for inputting key-value pairs with validation for duplicates.
- Implemented event handlers for adding, removing, and clearing Apps Script Variables.
- Updated LoadConfig logic to handle loading of Apps Script Variables from configuration.
2025-05-27 19:12:48 -07:00
rbalsleyMSFT 93108817de Remove InstallAppsandSysprep.cmd script to streamline application installation process and eliminate deprecated logic. 2025-05-27 18:38:04 -07:00
rbalsleyMSFT 4b19b7199b Update example usage in Invoke-AppsScript.ps1 and modify Orchestrator.ps1 to include AppsScript execution logic
- Changed example variable checks in Invoke-AppsScript.ps1 to reflect accurate usage of the AppsScriptVariables hashtable.
- Removed Invoke-AppsScript.ps1 from the script list in Orchestrator.ps1 and added logic to invoke it conditionally based on the presence of AppsScriptVariables.json.
- Enhanced output messages for clarity during script execution.
- Updated AppsScriptVariables parameter description in BuildFFUVM.ps1 to clarify its purpose and usage.
2025-05-27 18:30:14 -07:00
rbalsleyMSFT e8d1be6aa6 Rename "Build Config File" button to "Save Config File" for improved clarity in the UI, reflecting its functionality more accurately. 2025-05-27 10:00:33 -07:00
rbalsleyMSFT bde3cdd09f Update LoadConfig logic to manage Optional Features checkboxes based on loaded configuration. Added functionality to check/uncheck feature checkboxes according to the loaded features string, enhancing user interface responsiveness and clarity. 2025-05-26 18:12:32 -07:00
rbalsleyMSFT d05f9aa267 Enhance USB drive selection logic in LoadConfig process
- Added functionality to automatically check "Select Specific USB Drives" if USBDriveList is present and not empty in the configuration.
- Implemented checks for both PSCustomObject and hashtable types for backward compatibility.
- Included logging for the auto-check condition to improve traceability during configuration loading.
2025-05-26 17:59:28 -07:00
rbalsleyMSFT d253a88daa Add Set-UIValue function for improved UI property handling and configuration loading
- Introduced a new helper function `Set-UIValue` to streamline the process of setting UI properties from configuration objects, enhancing error handling and logging.
- Updated the `Get-UIConfig` function to utilize `Set-UIValue` for loading various UI elements from the configuration file, improving code readability and maintainability.
- Enhanced logic for handling driver model selection based on user input and added robust checks for configuration keys.
- Improved logging for configuration loading process, including error handling for missing keys and exceptions.
2025-05-26 17:08:08 -07:00
rbalsleyMSFT 6db5a93598 Refactor Get-UIConfig function to enhance parameter handling and improve clarity. Added new parameters from Sample_default.json, renamed existing parameters for consistency, and removed unnecessary comments. Updated logic to derive paths for UserAppList.json and Orchestration based on user input. 2025-05-26 15:35:32 -07:00
rbalsleyMSFT d4a4c6878d Added BuildFFUVM.ps1 parameters to Sample_default.json 2025-05-26 14:52:44 -07:00
rbalsleyMSFT 550e45a5c0 Updated UI Config parameters 2025-05-26 13:04:49 -07:00
rbalsleyMSFT 266fcbf58b Fix version numbering 2025-05-26 12:23:16 -07:00
rbalsleyMSFT f162de89be - Added Apps\Orchestration folder with new orchestration workflow to replace InstallAppsAndSysprep.cmd file.
- Updated BuildFFUUnattend files to point to the new Orchestrator.ps1 file.
- Added new common and FFUUI.Core directories that house common/shared files between the UI and PS1 script. This breaks up each of the PS1 scripts to keep things smaller and more organized. Still a lot of work to do here to pull some stuff out of the PS1 scripts.
- Modified the CaptureFFU.ps1 file to include more info during the capture process to help with troubleshooting
- Too many functional changes to list here.
2025-05-26 11:54:14 -07:00
rbalsleyMSFT 2efb9fb2a1 Added common winget functions 2025-05-26 11:40:06 -07:00
rbalsleyMSFT 1156373f0c Add Save, Load, and Clear functionality for BYO applications with JSON support 2025-05-26 11:40:06 -07:00
rbalsleyMSFT 5f9bf37617 Add functionality to manage applications - implement Add and Remove buttons with priority handling in the UI 2025-05-26 11:40:06 -07:00
rbalsleyMSFT c0fdd102e3 Add application information section for 'Bring Your Own Applications' feature - added code for modern folder browser and added the code behind for the browse folder buttons 2025-05-26 11:40:06 -07:00
rbalsleyMSFT 9f3ee8d963 Add 'Bring Your Own Applications' checkbox and update visibility logic based on installation options 2025-05-26 11:40:06 -07:00
rbalsleyMSFT 802865c1a6 Add Clear List button to Save/Import functionality and fixed sorting issues with winget app list. 2025-05-26 11:40:06 -07:00
rbalsleyMSFT 02835579c1 Add Winget search panel with results list and save/import functionality 2025-05-26 11:40:06 -07:00
rbalsleyMSFT 81b3be894e Refactor margins for application installation options and Winget status panel in UI 2025-05-26 11:40:06 -07:00
rbalsleyMSFT 3875181d89 Enhance UI for application installation options and add Winget status panel 2025-05-26 11:40:06 -07:00
rbalsleyMSFT 0669c64da1 Refactor USB drive configuration options and improve UI handling for related checkboxes 2025-05-26 11:40:06 -07:00
rbalsleyMSFT 931e9d13f7 Fixed USBDriveList json formatting 2025-05-26 11:40:06 -07:00
rbalsleyMSFT cd0598c5d1 Fixed a bug displaying USB drives in the listview 2025-05-26 11:40:06 -07:00
rbalsleyMSFT bc4980c6b8 Add USB drive detection and selection functionality 2025-05-26 11:40:06 -07:00
rbalsleyMSFT f20945be9f Commit
- Added new parameter `$USBDriveList` - A hashtable containing USB drives from win32_diskdrive where:
- Key: USB drive model name (partial match supported)
- Value: USB drive serial number (trailing partial match supported due to some serial numbers ending with blank spaces)

Example: @{ "SanDisk Ultra" = "1234567890"; "Kingston DataTraveler" = "0987654321" }

This is primarily designed for the UI so when a user selects a specific drive, we can make sure we're targeting the correct drive. We don't rely on the physical disk number since a user can save the USB drive information to a config file and re-use that at a later date and the physical disk number could be different, or used on another machine.

- Added support for the $USBDriveList parameter to handle copying to specified USB drives. There is no change to the prior USB drive copy process.
2025-05-26 11:40:06 -07:00
rbalsleyMSFT 099bbb1607 Update log message for .ffu file location and clean up unused image source function 2025-05-26 11:37:43 -07:00
rbalsleyMSFT ebcfa4f9dc Tooltips 2025-05-26 11:37:43 -07:00
rbalsleyMSFT f121505c50 Remove unused image path and image names from BuildFFUVM_UI.ps1, fixed some tooltip issues 2025-05-26 11:37:43 -07:00
rbalsleyMSFT 793830ab21 Fixed some checkbox interplay issues 2025-05-26 11:37:43 -07:00
rbalsleyMSFT 735c1ce688 Lots of changes :) 2025-05-26 11:37:43 -07:00
rbalsleyMSFT 837d4ef7ee - Finished optional features 2025-05-26 11:37:43 -07:00
rbalsleyMSFT adfa88aef8 Minor Optional Features adjustment 2025-05-26 11:37:43 -07:00
rbalsleyMSFT fbd0101b45 More windows settings work 2025-05-26 11:37:43 -07:00
rbalsleyMSFT f1cfeb0acc Added Windows settings 2025-05-26 11:37:43 -07:00
rbalsleyMSFT 45b8563db1 Refactored code and commented to make it more readable 2025-05-26 11:37:43 -07:00
rbalsleyMSFT ec3c89ac1b Added Windows settings tab 2025-05-26 11:32:35 -07:00
rbalsleyMSFT b422a565d5 Added M365 Apps/Office tab 2025-05-26 11:32:35 -07:00
rbalsleyMSFT eb3212ee2d Updates to the UI 2025-05-26 11:32:35 -07:00
rbalsleyMSFT e209251d0b More driver work 2025-05-26 11:32:35 -07:00
rbalsleyMSFT e73fa2d7f9 More driver work 2025-05-26 11:32:35 -07:00
rbalsleyMSFT 8c0ed525b3 Modified the drivers tab 2025-05-26 11:32:35 -07:00
rbalsleyMSFT 31e66c7213 Updated UI 2025-05-26 11:32:34 -07:00
rbalsleyMSFT b3e76f5580 Broke out the xaml to a separate file. Added drivers tab 2025-05-26 11:30:11 -07:00
rbalsleyMSFT f217a819fe First iteration of UI 2025-05-26 11:30:11 -07:00
rbalsleyMSFT 6a784a14fa Merge pull request #167 from rbalsleyMSFT/main
Merge back in changelog.md to be consistent with main
2025-05-21 17:39:09 -07:00
43 changed files with 17597 additions and 2448 deletions
+140
View File
@@ -1,25 +1,155 @@
# Change Log # Change Log
# 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
Waaay too many to list. Just watch the Youtube video in the Readme :)
# 2505.1 # 2505.1
Highly recommended that you upgrade to this release. Fixes the issue with the May 2025 cumulative update and some SKU naming issues for SKUs other than Pro. Highly recommended that you upgrade to this release. Fixes the issue with the May 2025 cumulative update and some SKU naming issues for SKUs other than Pro.
# Support for Windows LTSB/LTSC # Support for Windows LTSB/LTSC
Thanks to @zehadialam for the code to allow support for LTSB and LTSC. This has been a requested feature from a number of customers and some might be opting for LTSC when Windows 10 support ends in October. We support LTSB 2016, LTSC 2019, 2021, 2024 including the N and IoT variants. Extensive testing has gone into validating CU and .net support. File an issue if you see any weird behavior. Thanks to @zehadialam for the code to allow support for LTSB and LTSC. This has been a requested feature from a number of customers and some might be opting for LTSC when Windows 10 support ends in October. We support LTSB 2016, LTSC 2019, 2021, 2024 including the N and IoT variants. Extensive testing has gone into validating CU and .net support. File an issue if you see any weird behavior.
# Support for automating computer naming via CSV # Support for automating computer naming via CSV
Thanks to @JonasKloseBW for PR #150 Thanks to @JonasKloseBW for PR #150
- Allows setting the computer name with a predefined list (SerialComputerNames.csv) of serial numbers and matching computer names - Allows setting the computer name with a predefined list (SerialComputerNames.csv) of serial numbers and matching computer names
- Defaults to FFU-{Random} if no matching serial number is found in list so FFU deployment can continue without user input - Defaults to FFU-{Random} if no matching serial number is found in list so FFU deployment can continue without user input
# Fixes # Fixes
- Thanks to @JonasKloseBW for PR #129 for adding the -AppListPath parameter - Thanks to @JonasKloseBW for PR #129 for adding the -AppListPath parameter
- Fixed an issue where if AppsScriptVariables was configured in a config file, the hashtable wasn't being created by the script when setting the variable. - Fixed an issue where if AppsScriptVariables was configured in a config file, the hashtable wasn't being created by the script when setting the variable.
- Fixed a crash where shortening the Windows SKU was creating duplicate shortened names for certain SKUs (EDU mainly, but others too) - Fixed a crash where shortening the Windows SKU was creating duplicate shortened names for certain SKUs (EDU mainly, but others too)
- Fix an issue with checkpoint CUs and May 2025-05B CU. Should future proof new checkpoint CUs in the future. - Fix an issue with checkpoint CUs and May 2025-05B CU. Should future proof new checkpoint CUs in the future.
# Additional Fixes # Additional Fixes
BuildFFUVM.ps1 BuildFFUVM.ps1
- Added parameter definitions that were missing: - Added parameter definitions that were missing:
- AppListPath - Path to a JSON file containing a list of applications to install using WinGet. Default is $FFUDevelopmentPath\Apps\AppList.json. - AppListPath - Path to a JSON file containing a list of applications to install using WinGet. Default is $FFUDevelopmentPath\Apps\AppList.json.
- PEDriversFolder - Path to the folder containing drivers to be injected into the WinPE deployment media. Default is $FFUDevelopmentPath\PEDrivers. - PEDriversFolder - Path to the folder containing drivers to be injected into the WinPE deployment media. Default is $FFUDevelopmentPath\PEDrivers.
@@ -41,16 +171,19 @@ BuildFFUVM.ps1
- VHDXCaching will now recurse the KBPath folder when finding downloaded KBs to include in its config file - VHDXCaching will now recurse the KBPath folder when finding downloaded KBs to include in its config file
Sample_default.json Sample_default.json
- Added new/missing parameters - Added new/missing parameters
- ApplistPath - ApplistPath
- UpdateADK - UpdateADK
- UpdateLatestMicrocode - UpdateLatestMicrocode
CaptureFFU.ps1 CaptureFFU.ps1
- `$WindowsVersion` 2016 and 2019 for LTSC releases - `$WindowsVersion` 2016 and 2019 for LTSC releases
- Changed some SKU spacing to make things more consistent and included Enterprise N LTSC - Changed some SKU spacing to make things more consistent and included Enterprise N LTSC
ApplyFFU.ps1 ApplyFFU.ps1
- Updated version to 2505.1 - Updated version to 2505.1
# 2412.1 # 2412.1
@@ -58,12 +191,14 @@ ApplyFFU.ps1
This is a major release with a number of quality-of-life improvements that will reduce the time it takes to create FFUs. I highly recommend you update to this release. This is a major release with a number of quality-of-life improvements that will reduce the time it takes to create FFUs. I highly recommend you update to this release.
## Windows Server Support ## Windows Server Support
Thanks to [JonasKloseBW](https://github.com/JonasKloseBW) we have added support for Windows Server! This includes support for Windows Server 2016 through 2025 and supports both core and desktop experience. It will require you to provide your own Server ISO Thanks to [JonasKloseBW](https://github.com/JonasKloseBW) we have added support for Windows Server! This includes support for Windows Server 2016 through 2025 and supports both core and desktop experience. It will require you to provide your own Server ISO
using the `-ISOPath` parameter since we can't automatically download it like we can with client. You also will want to set the `-WindowsSKU` parameter to either `'Standard', 'Datacenter', 'Standard (Desktop Experience)', or 'Datacenter (Desktop Experience)'` depending on your needs. using the `-ISOPath` parameter since we can't automatically download it like we can with client. You also will want to set the `-WindowsSKU` parameter to either `'Standard', 'Datacenter', 'Standard (Desktop Experience)', or 'Datacenter (Desktop Experience)'` depending on your needs.
Cumulative Updates for Windows and .NET should work as expected. Defender updates should work too. If you notice anything that doesn't work, open an issue. Cumulative Updates for Windows and .NET should work as expected. Defender updates should work too. If you notice anything that doesn't work, open an issue.
## VHDX Caching Support ## VHDX Caching Support
Thanks again to Jonas for adding VHDX caching support #89. For those of you that might be making many FFUs for different configurations, instead of building the VHDX every time, you can cache the VHDX and re-use it for your next build. In testing, this seems to save about 10 minutes, depending on how you're installing Windows (via MCT download, or your own ISO and how old your media is). Thanks again to Jonas for adding VHDX caching support #89. For those of you that might be making many FFUs for different configurations, instead of building the VHDX every time, you can cache the VHDX and re-use it for your next build. In testing, this seems to save about 10 minutes, depending on how you're installing Windows (via MCT download, or your own ISO and how old your media is).
The way this works is a VHDXCache folder is created in the FFUDevelopment folder. If `-AllowVHDXCaching $true`, we store the VHDX file and a config file that keeps track of the following info The way this works is a VHDXCache folder is created in the FFUDevelopment folder. If `-AllowVHDXCaching $true`, we store the VHDX file and a config file that keeps track of the following info
@@ -89,9 +224,11 @@ The way this works is a VHDXCache folder is created in the FFUDevelopment folder
] ]
} }
``` ```
The VHDX files are cached before boot, so they've never been sysprepped. On subsequent runs, if `-AllowVHDXCaching $true` is set, we search the VHDXCache folder, loop through any config files, and look to see if we find one that matches the build information you've passed to the script. If a match is found, robocopy copies in the VHDX and uses the cached VHDX to build the FFU VM. The VHDX files are cached before boot, so they've never been sysprepped. On subsequent runs, if `-AllowVHDXCaching $true` is set, we search the VHDXCache folder, loop through any config files, and look to see if we find one that matches the build information you've passed to the script. If a match is found, robocopy copies in the VHDX and uses the cached VHDX to build the FFU VM.
## Configuration File Support ## Configuration File Support
A configuration file can now be used to configure the parameters in lieu of, or in conjunction with, parameters specified on the command line. Configuration files are especially helpful for those making FFUs for different models, Windows releases, application sets, and more. A configuration file can now be used to configure the parameters in lieu of, or in conjunction with, parameters specified on the command line. Configuration files are especially helpful for those making FFUs for different models, Windows releases, application sets, and more.
To use, run: To use, run:
@@ -116,6 +253,7 @@ Doing this will temporarily overwrite whatever is in the json for the `InstallOf
`.\BuildFFUVM.ps1 -ConfigFile 'C:\FFUDevelopment\config\Sample_default.json' -verbose -InstallOffice $False -exportConfigFile 'C:\FFUDevelopment\config\Sample_default.json'` `.\BuildFFUVM.ps1 -ConfigFile 'C:\FFUDevelopment\config\Sample_default.json' -verbose -InstallOffice $False -exportConfigFile 'C:\FFUDevelopment\config\Sample_default.json'`
## Custom FFU Naming Support ## Custom FFU Naming Support
Thanks to Jonas, we now have custom FFU naming support. A new parameter -CustomFFUNameTemplate has been added. Thanks to Jonas, we now have custom FFU naming support. A new parameter -CustomFFUNameTemplate has been added.
This parameter sets a custom FFU output name with placeholders. Allowed placeholders are: This parameter sets a custom FFU output name with placeholders. Allowed placeholders are:
@@ -155,6 +293,7 @@ Would result in:
`Win11_24h2_Pro_Office_2024-12-20_1225.ffu` `Win11_24h2_Pro_Office_2024-12-20_1225.ffu`
## Additional PRs added ## Additional PRs added
#79 Includes the latest Microsoft Software Removal Tool from `@zehadialam` - use `-UpdateLatestMSRT $true` #79 Includes the latest Microsoft Software Removal Tool from `@zehadialam` - use `-UpdateLatestMSRT $true`
#72 Includes some Unattend Sample files from @HedgeComp in the `$FFUDevelopment\Unattend` folder #72 Includes some Unattend Sample files from @HedgeComp in the `$FFUDevelopment\Unattend` folder
@@ -164,6 +303,7 @@ Would result in:
#103 Includes some additional improvements to the USBImagingToolCreator.ps1 file from @MKellyCBSD #103 Includes some additional improvements to the USBImagingToolCreator.ps1 file from @MKellyCBSD
## Misc Fixes ## Misc Fixes
- Added server skus to validateset for $WindowsSKU - Added server skus to validateset for $WindowsSKU
- Added new variable $installationType which uses $WindowsRelease to determine Server or Client. If $installationType is Server, $WindowsRelease version is used to set $WindowsVersion to the appropriate version (1607, 1809, 21H2) - Added new variable $installationType which uses $WindowsRelease to determine Server or Client. If $installationType is Server, $WindowsRelease version is used to set $WindowsVersion to the appropriate version (1607, 1809, 21H2)
- Fixed an issue where the recovery partition wouldn't be created on server OSes due to winre.wim being hidden. Never saw this on client OSes even though it also was hidden IIRC. - Fixed an issue where the recovery partition wouldn't be created on server OSes due to winre.wim being hidden. Never saw this on client OSes even though it also was hidden IIRC.
@@ -1,178 +1,23 @@
{ {
"apps": [ "apps": [
{
"name": "Windows Terminal",
"id": "9N0DX20HK701",
"source": "msstore"
},
{
"name": "Cross Device Experience Host",
"id": "9NTXGKQ8P7N0",
"source": "msstore"
},
{
"name": "Movies & TV",
"id": "9WZDNCRFJ3P2",
"source": "msstore"
},
{
"name": "Microsoft Photos",
"id": "9WZDNCRFJBH4",
"source": "msstore"
},
{
"name": "Mail and Calendar",
"id": "9WZDNCRFHVQM",
"source": "msstore"
},
{
"name": "Microsoft Sticky Notes",
"id": "9NBLGGH4QGHW",
"source": "msstore"
},
{
"name": "Power Automate",
"id": "9NFTCH6J7FHV",
"source": "msstore"
},
{
"name": "Snipping Tool",
"id": "9MZ95KL8MR0L",
"source": "msstore"
},
{
"name": "Phone Link",
"id": "9NMPJ99VJBWV",
"source": "msstore"
},
{
"name": "Microsoft 365 (Office Hub)",
"id": "9WZDNCRFJBH4",
"source": "msstore"
},
{
"name": "App Installer",
"id": "9NBLGGH4NNS1",
"source": "msstore"
},
{
"name": "Microsoft Clipchamp",
"id": "9P1J8S7CCWWT",
"source": "msstore"
},
{
"name": "Webp Image Extensions",
"id": "9PG2DK419DRG",
"source": "msstore"
},
{
"name": "Windows Web Experience Pack",
"id": "9MSSGKG348SP",
"source": "msstore"
},
{
"name": "Xbox",
"id": "9MV0B5HZVK9Z",
"source": "msstore"
},
{
"name": "Paint",
"id": "9PCFS5B6T72H",
"source": "msstore"
},
{
"name": "Windows Camera",
"id": "9WZDNCRFJBBG",
"source": "msstore"
},
{ {
"name": "Windows Notepad", "name": "Windows Notepad",
"id": "9MSMLRH6LZF3", "id": "9MSMLRH6LZF3",
"source": "msstore" "source": "msstore"
}, },
{ {
"name": "Windows Sound Recorder", "name": "Windows Client Web Experience",
"id": "9WZDNCRFHWKN", "id": "9MSSGKG348SP",
"source": "msstore" "source": "msstore"
}, },
{ {
"name": "Windows Calculator", "name": "Screen Sketch",
"id": "9WZDNCRFHVN5", "id": "9MZ95KL8MR0L",
"source": "msstore" "source": "msstore"
}, },
{ {
"name": "Feedback Hub", "name": "Windows Terminal",
"id": "9NBLGGH4R32N", "id": "9N0DX20HK701",
"source": "msstore"
},
{
"name": "Xbox Identity Provider",
"id": "9WZDNCRD1HKW",
"source": "msstore"
},
{
"name": "Windows Media Player",
"id": "9WZDNCRFJ3PT",
"source": "msstore"
},
{
"name": "MSN Weather",
"id": "9WZDNCRFJ3Q2",
"source": "msstore"
},
{
"name": "Game Bar",
"id": "9NZKPSTSNW4P",
"source": "msstore"
},
{
"name": "Web Media Extensions",
"id": "9N5TDP8VCMHS",
"source": "msstore"
},
{
"name": "Get Help",
"id": "9PKDZBMV1H3T",
"source": "msstore"
},
{
"name": "Raw Image Extension",
"id": "9NCTDW2W1BH8",
"source": "msstore"
},
{
"name": "Store Experience Host",
"id": "9NBLGGH4LS1F",
"source": "msstore"
},
{
"name": "Windows Maps",
"id": "9WZDNCRDTBVB",
"source": "msstore"
},
{
"name": "Windows Clock",
"id": "9WZDNCRFJ3PR",
"source": "msstore"
},
{
"name": "Microsoft To Do",
"id": "9NBLGGH5R558",
"source": "msstore"
},
{
"name": "Cortana",
"id": "9NFFX4SZZ23L",
"source": "msstore"
},
{
"name": "Quick Assist",
"id": "9P7BP5VNWKX5",
"source": "msstore"
},
{
"name": "HEIF Image Extensions",
"id": "9PMMSR1CGPWG",
"source": "msstore" "source": "msstore"
}, },
{ {
@@ -181,28 +26,123 @@
"source": "msstore" "source": "msstore"
}, },
{ {
"name": "Xbox Live in-game experience", "name": "MPEG2 Video Extension",
"id": "9N95Q1ZZPMH4",
"source": "msstore"
},
{
"name": "Store Purchase App",
"id": "9NBLGGH4LS1F",
"source": "msstore"
},
{
"name": "Windows Feedback Hub",
"id": "9NBLGGH4R32N",
"source": "msstore"
},
{
"name": "Todos",
"id": "9NBLGGH5R558",
"source": "msstore"
},
{
"name": "Raw Image Extension",
"id": "9NCTDW2W1BH8",
"source": "msstore"
},
{
"name": "Power Automate Desktop",
"id": "9NFTCH6J7FHV",
"source": "msstore"
},
{
"name": "Xbox TCUI",
"id": "9NKNC0LD5NN6", "id": "9NKNC0LD5NN6",
"source": "msstore" "source": "msstore"
}, },
{ {
"name": "Xbox Game Speech Window", "name": "Your Phone",
"id": "9P086NHDNB9W", "id": "9NMPJ99VJBWV",
"source": "msstore" "source": "msstore"
}, },
{ {
"name": "Microsoft News", "name": "Xbox Gaming Overlay",
"id": "9NZKPSTSNW4P",
"source": "msstore"
},
{
"name": "Clipchamp",
"id": "9P1J8S7CCWWT",
"source": "msstore"
},
{
"name": "Paint",
"id": "9PCFS5B6T72H",
"source": "msstore"
},
{
"name": "Webp Image Extension",
"id": "9PG2DK419DRG",
"source": "msstore"
},
{
"name": "Get Help",
"id": "9PKDZBMV1H3T",
"source": "msstore"
},
{
"name": "HEIF Image Extension",
"id": "9PMMSR1CGPWG",
"source": "msstore"
},
{
"name": "Xbox Identity Provider",
"id": "9WZDNCRD1HKW",
"source": "msstore"
},
{
"name": "Microsoft Office Hub",
"id": "9WZDNCRD29V9",
"source": "msstore"
},
{
"name": "Bing News",
"id": "9WZDNCRFHVFW", "id": "9WZDNCRFHVFW",
"source": "msstore" "source": "msstore"
}, },
{ {
"name": "Microsoft Store", "name": "Windows Sound Recorder",
"id": "9WZDNCRFJBMP", "id": "9WZDNCRFHWKN",
"source": "msstore" "source": "msstore"
}, },
{ {
"name": "Microsoft Tips", "name": "Windows Alarms",
"id": "9WZDNCRDTBJJ", "id": "9WZDNCRFJ3PR",
"source": "msstore"
},
{
"name": "Zune Music",
"id": "9WZDNCRFJ3PT",
"source": "msstore"
},
{
"name": "Bing Weather",
"id": "9WZDNCRFJ3Q2",
"source": "msstore"
},
{
"name": "Windows Camera",
"id": "9WZDNCRFJBBG",
"source": "msstore"
},
{
"name": "Windows Photos",
"id": "9WZDNCRFJBH4",
"source": "msstore"
},
{
"name": "Windows Store",
"id": "9WZDNCRFJBMP",
"source": "msstore" "source": "msstore"
} }
] ]
@@ -1,86 +0,0 @@
setlocal enabledelayedexpansion
REM Put each app install on a separate line
REM M365 Apps/Office ProPlus
REM d:\Office\setup.exe /configure d:\office\DeployFFU.xml
REM Install Defender Platform Update
REM Install Defender Definitions
REM Install Windows Security Platform Update
REM Install Windows Malicious Software Removal Tool
REM Install OneDrive Per Machine
REM Install Edge Stable
REM Winget Win32 Apps
REM START Batch variables placeholder
REM END Batch variables placeholder
REM Add additional apps below here
REM Contoso App (Example)
REM msiexec /i d:\Contoso\setup.msi /qn /norestart
set "INSTALL_STOREAPPS=false"
if /i "%INSTALL_STOREAPPS%"=="false" (
echo Skipping MS Store installation due to INSTALL_STOREAPPS flag.
goto :remaining
)
set "basepath=D:\MSStore"
for /d %%D in ("%basepath%\*") do (
set "appfolder=%%D"
set "mainpackage="
set "dependenciesfolder=!appfolder!\Dependencies"
for %%F in ("!appfolder!\*") do (
if not "%%~dpF"=="!dependenciesfolder!\" (
if /i not "%%~xF"==".xml" (
if /i not "%%~xF"==".yaml" (
set "mainpackage=%%F"
)
)
)
)
if defined mainpackage (
set "dism_command=DISM /Online /Add-ProvisionedAppxPackage /PackagePath:"!mainpackage!" /Region:all /StubPackageOption:installfull"
if exist "!dependenciesfolder!" (
for %%G in ("!dependenciesfolder!\*") do (
set "dism_command=!dism_command! /DependencyPackagePath:"%%G""
)
)
for %%F in ("!appfolder!\*.xml") do (
set "licensefile=%%F"
)
if defined licensefile (
set "dism_command=!dism_command! /LicensePath:"!licensefile!""
) else (
set "dism_command=!dism_command! /SkipLicense"
)
echo !dism_command!
!dism_command!
)
)
:remaining
endlocal
for /r "D:\" %%G in (.) do (
if exist "%%G\Notepad++" (
powershell -Command "Remove-AppxPackage -Package NotepadPlusPlus_1.0.0.0_neutral__7njy0v32s6xk6"
)
)
REM The below lines will remove the unattend.xml that gets the machine into audit mode. If not removed, the OS will get stuck booting to audit mode each time.
REM Also kills the sysprep process in order to automate sysprep generalize
del c:\windows\panther\unattend\unattend.xml /F /Q
del c:\windows\panther\unattend.xml /F /Q
taskkill /IM sysprep.exe
timeout /t 10
REM Run disk cleanup (cleanmgr.exe) with all options enabled: https://learn.microsoft.com/en-us/troubleshoot/windows-server/backup-and-storage/automating-disk-cleanup-tool
set rootkey=HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches
REM Per above doc, the Offline Pages Files subkey does not have stateflags value
for /f "tokens=*" %%K in ('reg query "%rootkey%"') do (
echo %%K | findstr /i /c:"Offline Pages Files"
if errorlevel 1 (
reg add "%%K" /v StateFlags0000 /t REG_DWORD /d 2 /f
)
)
cleanmgr.exe /sagerun:0
REM Remove the StateFlags0000 registry value
for /f "tokens=*" %%K in ('reg query "%rootkey%"') do (
echo %%K | findstr /i /c:"Offline Pages Files"
if errorlevel 1 (
reg delete "%%K" /v StateFlags0000 /f
)
)
REM Sysprep/Generalize
c:\windows\system32\sysprep\sysprep.exe /quiet /generalize /oobe
@@ -0,0 +1,4 @@
{
"VMWareTools": true,
"foo": "bar"
}
@@ -0,0 +1,269 @@
#Requires -RunAsAdministrator
# --- CONFIGURATION ---
# Base path where application folders are located. Each subfolder represents one application.
$basePath = "D:\MSStore"
# Path for temporary files (e.g., for extracting archives). This will be created and cleaned up automatically.
$tempBasePath = Join-Path -Path $env:TEMP -ChildPath "StoreAppInstall"
# --- SCRIPT ---
# Helper function to clean up temporary files on exit or error
function Remove-TemporaryFiles {
if (Test-Path -Path $tempBasePath) {
Write-Host "Cleaning up temporary directory: $tempBasePath"
Remove-Item -Path $tempBasePath -Recurse -Force
}
}
# Ensure temp directory is clean before starting
Remove-TemporaryFiles
New-Item -Path $tempBasePath -ItemType Directory -Force | Out-Null
# 1. Determine applicable dependency architectures based on the OS architecture
$osArchitecture = $env:PROCESSOR_ARCHITECTURE
$applicableArchitectures = switch ($osArchitecture) {
"AMD64" { 'x64', 'x86' }
"x86" { 'x86' }
"ARM64" { 'arm64', 'arm' }
default { $osArchitecture.ToLower() }
}
Write-Host "Installing Store Apps: Detected OS Architecture: $osArchitecture."
Write-Host "Applicable dependency architectures: $($applicableArchitectures -join ', ')"
# Check if the base path exists
if (-not (Test-Path -Path $basePath)) {
Write-Host "Installing Store Apps: Base path '$basePath' does not exist. Exiting."
exit
}
Write-Host "Installing Store Apps: Base path '$basePath' exists."
# 2. Process and install each main application
Write-Host "Starting main application installation process..."
foreach ($appFolder in Get-ChildItem -Path $basePath -Directory) {
Write-Host "--- Processing application in folder: $($appFolder.Name) ---"
# Find the main application package (.appx/.msix/.appxbundle) in the app's root folder
$mainPackage = Get-ChildItem -Path $appFolder.FullName -File |
Where-Object { $_.Extension -in '.appx', '.msix', '.appxbundle', '.msixbundle' } |
Select-Object -First 1
if (-not $mainPackage) {
Write-Warning "No main application package found in '$($appFolder.Name)'. Skipping."
Write-Output ""
continue
}
Write-Host "Found main package: $($mainPackage.Name)"
# Extract and parse AppxManifest.xml from the main package
$manifestTempPath = Join-Path -Path $tempBasePath -ChildPath "AppxManifest.xml"
if (Test-Path $manifestTempPath) { Remove-Item $manifestTempPath -Force }
$requiredDependencies = $null
try {
[System.Reflection.Assembly]::LoadWithPartialName("System.IO.Compression.FileSystem") | Out-Null
# Logic for handling bundles vs. single packages
if ($mainPackage.Extension -in '.appxbundle', '.msixbundle') {
Write-Host "Processing bundle. Searching for architecture-specific package..."
$bundleArchive = [System.IO.Compression.ZipFile]::OpenRead($mainPackage.FullName)
try {
# Find the best matching .appx/.msix package inside the bundle
$primaryArch = if ($osArchitecture -eq 'AMD64') { 'x64' } else { $osArchitecture.ToLower() }
$packageEntries = $bundleArchive.Entries | Where-Object { ($_.Name.EndsWith('.appx') -or $_.Name.EndsWith('.msix')) -and $_.Name -notlike "*_language-*" }
# Prioritize the primary architecture, then x86 (on x64), then neutral
$bestPackageEntry = $packageEntries | Where-Object { $_.Name -imatch "[._]${primaryArch}\.(appx|msix)$" } | Select-Object -First 1
if (-not $bestPackageEntry -and $primaryArch -eq 'x64') {
$bestPackageEntry = $packageEntries | Where-Object { $_.Name -imatch "[._]x86\.(appx|msix)$" } | Select-Object -First 1
}
if (-not $bestPackageEntry) {
$bestPackageEntry = $packageEntries | Where-Object { $_.Name -imatch "[._]neutral\.(appx|msix)$" } | Select-Object -First 1
}
if ($bestPackageEntry) {
Write-Host "Found inner package: $($bestPackageEntry.Name). Extracting to read its manifest."
$innerPackageTempPath = Join-Path -Path $tempBasePath -ChildPath $bestPackageEntry.Name
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($bestPackageEntry, $innerPackageTempPath, $true)
$innerPackageArchive = [System.IO.Compression.ZipFile]::OpenRead($innerPackageTempPath)
try {
$manifestEntry = $innerPackageArchive.Entries | Where-Object { $_.Name -eq 'AppxManifest.xml' } | Select-Object -First 1
if ($manifestEntry) {
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($manifestEntry, $manifestTempPath, $true)
}
} finally {
$innerPackageArchive.Dispose()
}
} else {
Write-Error "Could not find a suitable architecture-specific package inside '$($mainPackage.Name)'."
}
} finally {
$bundleArchive.Dispose()
}
} else { # It's a regular .appx or .msix
$zipArchive = [System.IO.Compression.ZipFile]::OpenRead($mainPackage.FullName)
try {
$manifestEntry = $zipArchive.Entries | Where-Object { $_.Name -eq 'AppxManifest.xml' } | Select-Object -First 1
if ($manifestEntry) {
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($manifestEntry, $manifestTempPath, $true)
}
} finally {
$zipArchive.Dispose()
}
}
# Common manifest parsing logic
if (Test-Path $manifestTempPath) {
[xml]$manifest = Get-Content -Path $manifestTempPath
$nsm = [System.Xml.XmlNamespaceManager]::new($manifest.NameTable)
$nsm.AddNamespace("def", "http://schemas.microsoft.com/appx/manifest/foundation/windows10")
$dependenciesNode = $manifest.SelectSingleNode("//def:Dependencies", $nsm)
if ($dependenciesNode) {
$requiredDependencies = $dependenciesNode.SelectNodes("def:PackageDependency", $nsm)
}
} else {
Write-Error "Could not find or extract AppxManifest.xml from '$($mainPackage.FullName)'."
}
} catch {
Write-Error "Failed to read or parse manifest from '$($mainPackage.FullName)'. Error: $($_.Exception.Message)"
}
# Scan for and resolve dependencies only if the manifest lists actual package dependencies.
$resolvedDependencyPaths = [System.Collections.Generic.List[string]]::new()
if ($null -ne $requiredDependencies -and $requiredDependencies.Count -gt 0) {
$appDependenciesPath = Join-Path -Path $appFolder.FullName -ChildPath "Dependencies"
if (Test-Path -Path $appDependenciesPath) {
Write-Host "Scanning for dependencies in '$appDependenciesPath'..."
$appSpecificDependencies = @{}
$dependencyFoldersToScan = [System.Collections.Generic.List[string]]::new()
$dependencyFoldersToScan.Add($appDependenciesPath)
# Handle zipped dependencies by extracting them to a temp location
Get-ChildItem -Path $appDependenciesPath -Filter "*.zip" -File | ForEach-Object {
$zipFile = $_
# Ensure unique extract path per app to avoid conflicts
$extractPath = Join-Path -Path $tempBasePath -ChildPath "$($appFolder.Name)_$($zipFile.BaseName)"
Write-Host "Extracting zipped dependencies from '$($zipFile.FullName)' to '$extractPath'..."
try {
Expand-Archive -Path $zipFile.FullName -DestinationPath $extractPath -Force
$dependencyFoldersToScan.Add($extractPath)
}
catch {
Write-Error "Failed to extract '$($zipFile.FullName)'. Error: $($_.Exception.Message)"
}
}
# Regex to parse package filenames
$packageFileRegex = '^(?<Name>.+?)_(?<Version>(?:\d+\.){2,3}\d+)_(?:[^_]+_)*(?<Arch>x64|x86|arm|arm64|neutral)(?:__.*)?$'
# Catalog all package files found in the dependency folders for this app
foreach ($folder in $dependencyFoldersToScan.ToArray() | Select-Object -Unique) {
Get-ChildItem -Path $folder -Recurse -File | Where-Object { $_.Extension -in '.appx', '.msix', '.appxbundle' } | ForEach-Object {
$file = $_
$match = $file.BaseName -imatch $packageFileRegex
if ($match) {
$dependencyName = $matches.Name
try {
$dependencyVersion = [System.Version]$matches.Version
$dependencyArch = $matches.Arch
if (-not $appSpecificDependencies.ContainsKey($dependencyName)) {
$appSpecificDependencies[$dependencyName] = [System.Collections.Generic.List[object]]::new()
}
$appSpecificDependencies[$dependencyName].Add([pscustomobject]@{
Name = $dependencyName
Version = $dependencyVersion
Arch = $dependencyArch
Path = $file.FullName
})
}
catch {
Write-Warning "Could not parse version for file '$($file.Name)'. Skipping."
}
}
}
}
Write-Host "Dependency scan for '$($appFolder.Name)' complete."
# Resolve all required dependencies using the app-specific catalog
foreach ($req in $requiredDependencies) {
$reqName = $req.Name
$reqMinVersion = [System.Version]$req.MinVersion
Write-Host "Resolving dependency: $reqName (MinVersion: $reqMinVersion)"
if ($appSpecificDependencies.ContainsKey($reqName)) {
# Find all available packages that meet the minimum version and architecture requirements
$candidates = $appSpecificDependencies[$reqName] | Where-Object {
$_.Version -ge $reqMinVersion -and
$_.Arch -in ($applicableArchitectures + 'neutral')
}
if ($candidates) {
# Group by architecture and find the single latest version for each applicable arch
$bestCandidates = $candidates | Group-Object -Property Arch | ForEach-Object {
$_.Group | Sort-Object -Property Version -Descending | Select-Object -First 1
}
foreach($best in $bestCandidates) {
Write-Host " - Found best match: $($best.Path.Replace($basePath, '...'))"
$resolvedDependencyPaths.Add($best.Path)
}
} else {
Write-Warning " - No suitable package found for dependency '$reqName' with MinVersion '$reqMinVersion' for applicable architectures."
}
} else {
Write-Warning " - Dependency '$reqName' not found in this app's dependency folder."
}
}
}
else {
Write-Warning "Dependencies are required by manifest, but no 'Dependencies' folder found for '$($appFolder.Name)'."
}
}
else {
Write-Host "No actual package dependencies listed in manifest for '$($appFolder.Name)'. Proceeding without dependency resolution."
}
# Build the DISM command
$dismParams = @(
"/Online"
"/Add-ProvisionedAppxPackage"
"/PackagePath:`"$($mainPackage.FullName)`""
"/Region:all"
# "/StubPackageOption:installfull"
)
# Add resolved dependencies, ensuring no duplicates
$resolvedDependencyPaths.ToArray() | Select-Object -Unique | ForEach-Object {
$dismParams += "/DependencyPackagePath:`"$_`""
}
# Find and add the license file, or skip if not found
$licenseFile = Get-ChildItem -Path $appFolder.FullName -Filter "*.xml" -File | Select-Object -First 1
if ($licenseFile) {
$dismParams += "/LicensePath:`"$($licenseFile.FullName)`""
} else {
$dismParams += "/SkipLicense"
}
# Execute the DISM command
$dismCommand = "DISM.exe " + ($dismParams -join " ")
Write-Host "Constructed DISM command:"
Write-Output $dismCommand
try {
Invoke-Expression -Command $dismCommand -ErrorAction Stop
Write-Host "Successfully installed $($mainPackage.Name)."
} catch {
Write-Error "DISM command failed for $($mainPackage.Name). Error: $($_.Exception.Message)"
}
Write-Output ""
}
# Final cleanup
Write-Host "Installation process finished."
Remove-TemporaryFiles
@@ -0,0 +1,273 @@
function Invoke-Process {
[CmdletBinding(SupportsShouldProcess)]
param
(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$FilePath,
[Parameter()]
[string[]]$ArgumentList,
[Parameter()]
[ValidateNotNullOrEmpty()]
[bool]$Wait = $true,
[Parameter()]
[string[]]$AdditionalSuccessCodes,
[Parameter()]
[bool]$IgnoreNonZeroExitCodes = $false
)
$ErrorActionPreference = 'Stop'
try {
if ($PSCmdlet.ShouldProcess("Process [$($FilePath)]", "Run with args: [$($ArgumentList)]")) {
# Use .NET Process class for proper stream handling
$pinfo = New-Object System.Diagnostics.ProcessStartInfo
$pinfo.FileName = $FilePath
if ($ArgumentList) {
$pinfo.Arguments = $ArgumentList -join ' '
}
$pinfo.RedirectStandardOutput = $true
$pinfo.RedirectStandardError = $true
$pinfo.UseShellExecute = $false
$pinfo.CreateNoWindow = $true
$p = New-Object System.Diagnostics.Process
$p.StartInfo = $pinfo
# Start the process
$p.Start() | Out-Null
# Read output and error streams
$cmdOutput = $p.StandardOutput.ReadToEnd()
$cmdError = $p.StandardError.ReadToEnd()
if ($Wait) {
$p.WaitForExit()
}
$exitCode = $p.ExitCode
# An exit code of 0 is always a success
if ($exitCode -ne 0) {
# If IgnoreNonZeroExitCodes is true, treat any non-zero exit code as a success
if ($IgnoreNonZeroExitCodes) {
Write-Host "Ignoring non-zero exit code $exitCode because IgnoreNonZeroExitCodes is set to true."
}
# Check if the non-zero exit code is in the list of additional success codes
elseif ($null -eq $AdditionalSuccessCodes -or $exitCode -notin $AdditionalSuccessCodes) {
if ($cmdError) {
throw $cmdError.Trim()
}
if ($cmdOutput) {
throw $cmdOutput.Trim()
}
# If there's no output, throw a generic error with the exit code
if (-not $cmdError -and -not $cmdOutput) {
throw "Process exited with non-zero code: $exitCode"
}
}
}
else {
if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) {
# WriteLog $cmdOutput
}
}
# Create a simple object with exit code for compatibility
$result = [PSCustomObject]@{
ExitCode = $exitCode
}
return $result
}
}
catch {
#$PSCmdlet.ThrowTerminatingError($_)
# WriteLog $_
# Write-Host "Script failed - $Logfile for more info"
throw $_
}
}
function Install-Applications {
param(
[Parameter(Mandatory)]
[array]$apps
)
if ($apps.Count -eq 0) {
Write-Host "No applications to install from this source."
return
}
Write-Host "Total apps to install from this source: $($apps.Count)"
# Sort all apps by priority
$sortedApps = $apps | Sort-Object -Property Priority
# Install each app
foreach ($app in $sortedApps) {
# Check if required properties exist
if (-not $app.PSObject.Properties['Name'] -or -not $app.PSObject.Properties['CommandLine'] -or -not $app.PSObject.Properties['Arguments']) {
Write-Warning "Skipping app due to missing required properties (Name, CommandLine, Arguments): $($app | ConvertTo-Json -Depth 1 -Compress)"
continue
}
Write-Host "Installing $($app.Name)..."
# Wait until no MSIExec installation is running
while ($true) {
try {
# Try to open the MSIExec global mutex
$Mutex = [System.Threading.Mutex]::OpenExisting("Global\_MSIExecute")
# Dispose releases the handle from our script only.
$Mutex.Dispose()
Write-Host "Another MSIExec installer is running. Waiting for 5 seconds before rechecking..."
Start-Sleep -Seconds 5
}
catch [System.Threading.WaitHandleCannotBeOpenedException] {
# If we can't open the mutex, it means no MSIExec installation is running
break
}
catch {
# Handle other potential errors when checking the mutex
Write-Warning "Error checking MSIExec mutex: $_. Proceeding with caution."
break
}
}
# Check for 'PAUSE' command
if ($app.CommandLine -eq 'PAUSE') {
Write-Host "Pausing script as requested by '$($app.Name)'. Press Enter to continue..."
$null = Read-Host
continue
}
try {
# Normalize arguments: treat null/empty/whitespace as no arguments
$argumentsToPass = $null
if ($null -ne $app.Arguments) {
if ($app.Arguments -is [array]) {
$trimmed = $app.Arguments | ForEach-Object { ($_ | ForEach-Object { if ($_ -ne $null) { $_.ToString().Trim() } else { $_ } }) } | Where-Object { $_ -and (-not [string]::IsNullOrWhiteSpace($_)) }
if ($trimmed.Count -gt 0) {
$argumentsToPass = $trimmed
}
}
else {
$single = $app.Arguments.ToString().Trim()
if (-not [string]::IsNullOrWhiteSpace($single)) {
$argumentsToPass = @($single)
}
}
}
# Check for and parse AdditionalExitCodes
$additionalSuccessCodes = @()
if ($app.PSObject.Properties['AdditionalExitCodes'] -and -not [string]::IsNullOrWhiteSpace($app.AdditionalExitCodes)) {
$additionalSuccessCodes = $app.AdditionalExitCodes -split ',' | ForEach-Object { $_.Trim() }
Write-Host "Additional success exit codes for $($app.Name): $($additionalSuccessCodes -join ', ')"
}
# Check for IgnoreNonZeroExitCodes
$ignoreNonZeroExitCodes = $false
if ($app.PSObject.Properties['IgnoreNonZeroExitCodes'] -and $app.IgnoreNonZeroExitCodes -is [bool]) {
$ignoreNonZeroExitCodes = $app.IgnoreNonZeroExitCodes
}
if ($null -eq $argumentsToPass -or $argumentsToPass.Count -eq 0) {
Write-Host "Running command: $($app.CommandLine) (no arguments)"
$result = Invoke-Process -FilePath $app.CommandLine -AdditionalSuccessCodes $additionalSuccessCodes -IgnoreNonZeroExitCodes $ignoreNonZeroExitCodes
}
else {
Write-Host "Running command: $($app.CommandLine) $($argumentsToPass -join ' ')"
$result = Invoke-Process -FilePath $app.CommandLine -ArgumentList $argumentsToPass -AdditionalSuccessCodes $additionalSuccessCodes -IgnoreNonZeroExitCodes $ignoreNonZeroExitCodes
}
Write-Host "$($app.Name) exited with exit code: $($result.ExitCode)`r`n"
}
catch {
Write-Error "Error occurred while installing $($app.Name): $_"
Read-Host "An error occurred, and the script cannot continue. Press Enter to exit."
throw $_
}
}
}
# Define paths for the JSON files
$wingetAppsJsonFile = "$PSScriptRoot\WinGetWin32Apps.json"
# Look for UserAppList.json one directory level up from the script's location. This keeps the user specific json files (AppList.json and UserAppList.json in the Apps dir)
$userAppsJsonFile = Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath "UserAppList.json"
# Initialize empty arrays for apps from each source
$wingetApps = @()
$userApps = @()
# Read the WinGetWin32Apps.json file if it exists
if (Test-Path -Path $wingetAppsJsonFile) {
Write-Host "Processing WinGetWin32Apps.json..."
try {
$wingetContent = Get-Content -Path $wingetAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json
if ($wingetContent -is [array]) {
$wingetApps = $wingetContent
Write-Host "Found $(($wingetApps | Measure-Object).Count) WinGet Win32 apps."
}
elseif ($wingetContent) {
$wingetApps = @($wingetContent) # Ensure it's an array
Write-Host "Found 1 WinGet Win32 app."
}
else {
Write-Host "WinGetWin32Apps.json is empty or invalid."
}
}
catch {
Write-Error "Failed to read or parse WinGetWin32Apps.json file: $_"
}
}
else {
Write-Host "WinGetWin32Apps.json file not found. Skipping."
}
# Install WinGet apps if any were found
if ($wingetApps.Count -gt 0) {
Install-Applications -apps $wingetApps
}
# Read the UserAppList.json file if it exists
if (Test-Path -Path $userAppsJsonFile) {
Write-Host "Processing UserAppList.json..."
try {
$userContent = Get-Content -Path $userAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json
if ($userContent -is [array]) {
$userApps = $userContent
Write-Host "Found $(($userApps | Measure-Object).Count) user-defined apps."
}
elseif ($userContent) {
$userApps = @($userContent) # Ensure it's an array
Write-Host "Found 1 user-defined app."
}
else {
Write-Host "UserAppList.json is empty or invalid."
}
}
catch {
Write-Error "Failed to read or parse UserAppList.json file: $_"
}
}
else {
Write-Host "UserAppList.json file not found. Skipping."
}
# Install User apps if any were found
if ($userApps.Count -gt 0) {
Install-Applications -apps $userApps
}
# Check if any apps were installed at all
if ($wingetApps.Count -eq 0 -and $userApps.Count -eq 0) {
Write-Host "No Win32 apps found in either WinGetWin32Apps.json or UserAppList.json. Exiting."
exit 0
}
Write-Host "All Win32 app installations attempted."
@@ -0,0 +1,60 @@
<#
.SYNOPSIS
This script uses the variables from the AppsScriptVariables hashtable passed to BuildFFUVM.ps1 to run application deployment tasks.
.DESCRIPTION
By defining the variables in the AppsScriptVariables hashtable, you can customize the application deployment tasks that are run by this script.
The BuildFFUVM.ps1 script will export the AppsScriptVariables hashtable to a JSON file in the Orchestration folder.
Include your own custom script here if you want to run it as part of the application deployment tasks.
Alternatively, you can pass the AppsScriptVariables hashtable directly to this script.
#>
param (
[hashtable]$AppsScriptVariables
)
# Try to read from the JSON file if it exists and AppsScriptVariables is not provided
$appsScriptVarsJsonPath = Join-Path -Path $PSScriptRoot -ChildPath "AppsScriptVariables.json"
if ((-not $AppsScriptVariables -or $AppsScriptVariables.Count -eq 0) -and (Test-Path -Path $appsScriptVarsJsonPath)) {
try {
$jsonContent = Get-Content -Path $appsScriptVarsJsonPath -Raw -ErrorAction Stop
$jsonObject = $jsonContent | ConvertFrom-Json -ErrorAction Stop
# Convert PSCustomObject to hashtable
$AppsScriptVariables = @{}
foreach ($prop in $jsonObject.PSObject.Properties) {
$AppsScriptVariables[$prop.Name] = $prop.Value
}
Write-Host "Successfully loaded AppsScriptVariables from $appsScriptVarsJsonPath"
}
catch {
Write-Error "Failed to load AppsScriptVariables from JSON file: $_"
}
}
else {
Write-Host "AppsScriptVariables provided directly, skipping JSON file load."
}
# 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"
# }
# Your code below here
Write-Host 'Invoke-AppsScript.ps1 finished'
@@ -0,0 +1,116 @@
<#
.SYNOPSIS
Orchestration script for FFU VM deployment tasks
.DESCRIPTION
This script orchestrates the following deployment tasks:
- Install-Office.ps1
- Update-Defender.ps1
- Update-MSRT.ps1
- Update-OneDrive.ps1
- Update-Edge.ps1
- Install-Win32Apps.ps1
- Invoke-AppsScript.ps1
- Install-UserApps.ps1
- Install-StoreApps.ps1
- Run-DiskCleanup.ps1
- Run-Sysprep.ps1
The script will check for the presence of each of these files and if they exist, will run the script
#>
# Header
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
Write-Host " FFU Builder Orchestrator " -ForegroundColor Yellow
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
# Define the path to the scripts
$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
$scriptList = @(
"Update-Defender.ps1",
"Install-Office.ps1",
"Update-MSRT.ps1",
"Update-OneDrive.ps1",
"Update-Edge.ps1",
"Install-Win32Apps.ps1",
"Install-StoreApps.ps1",
"Install-UserApps.ps1"
)
# Check if each script exists and has content to process, then run it
foreach ($script in $scriptList) {
$scriptFile = Join-Path -Path $scriptPath -ChildPath $script
if (-not (Test-Path -Path $scriptFile)) {
continue
}
$shouldRun = $true # Default to run if script exists
switch ($script) {
"Install-Win32Apps.ps1" {
$wingetAppsJsonFile = Join-Path -Path $scriptPath -ChildPath "WinGetWin32Apps.json"
$userAppsJsonFile = Join-Path -Path (Split-Path -Parent $scriptPath) -ChildPath "UserAppList.json"
if (-not (Test-Path -Path $wingetAppsJsonFile) -and -not (Test-Path -Path $userAppsJsonFile)) {
$shouldRun = $false
}
}
"Install-StoreApps.ps1" {
$msStorePath = "D:\MSStore"
if (-not (Test-Path -Path $msStorePath) -or -not (Get-ChildItem -Path $msStorePath)) {
$shouldRun = $false
}
}
}
if ($shouldRun) {
Write-Host "`n" # Add a newline for spacing
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
Write-Host " Running script: $script " -ForegroundColor Yellow
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
# Run script and wait for it to finish
& $scriptFile
}
}
# Invoke-AppsScript.ps1 if it exists and AppsScriptVariables.json is present
$appsScriptFile = Join-Path -Path $scriptPath -ChildPath "Invoke-AppsScript.ps1"
$appsScriptVarsJsonPath = Join-Path -Path $PSScriptRoot -ChildPath "AppsScriptVariables.json"
if ((Test-Path -Path $appsScriptFile) -and (Test-Path -Path $appsScriptVarsJsonPath)) {
Write-Host "`n" # Add a newline for spacing
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
Write-Host " Running script: Invoke-AppsScript.ps1 " -ForegroundColor Yellow
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
Write-Host "Using AppsScriptVariables from JSON file: $appsScriptVarsJsonPath"
& $appsScriptFile
}
# Run-DiskCleanup.ps1 must run before Run-Sysprep.ps1
$diskCleanupScript = Join-Path -Path $scriptPath -ChildPath "Run-DiskCleanup.ps1"
if (Test-Path -Path $diskCleanupScript) {
Write-Host "`n" # Add a newline for spacing
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
Write-Host " Running script: Run-DiskCleanup.ps1 " -ForegroundColor Yellow
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
# Run script and wait for it to finish
& $diskCleanupScript
} else {
Write-Host "Run-DiskCleanup.ps1 not found!"
}
# Run-Sysprep.ps1 must run last
$sysprepScript = Join-Path -Path $scriptPath -ChildPath "Run-Sysprep.ps1"
if (Test-Path -Path $sysprepScript) {
Write-Host "`n" # Add a newline for spacing
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
Write-Host " Running script: Run-Sysprep.ps1 " -ForegroundColor Yellow
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
# Run script and wait for it to finish
& $sysprepScript
} else {
Write-Host "Run-Sysprep.ps1 not found!"
}
@@ -0,0 +1,21 @@
# Run disk cleanup (cleanmgr.exe) with all options enabled
# Reference: https://learn.microsoft.com/en-us/troubleshoot/windows-server/backup-and-storage/automating-disk-cleanup-tool
$rootKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VolumeCaches"
# Set StateFlags0000 to 2 for all subkeys except "Offline Pages Files"
Get-ChildItem -Path $rootKey | ForEach-Object {
if ($_.PSChildName -ne "Offline Pages Files") {
Set-ItemProperty -Path $_.PSPath -Name "StateFlags0000" -Type DWord -Value 2 -Force
}
}
# Run the disk cleanup tool with the specified flags
Start-Process -FilePath "cleanmgr.exe" -ArgumentList "/sagerun:0" -Wait
# Remove the StateFlags0000 registry values that were added
Get-ChildItem -Path $rootKey | ForEach-Object {
if ($_.PSChildName -ne "Offline Pages Files") {
Remove-ItemProperty -Path $_.PSPath -Name "StateFlags0000" -Force
}
}
@@ -0,0 +1,90 @@
#The below lines will remove the unattend.xml that gets the machine into audit mode. If not removed, the OS will get stuck booting to audit mode each time.
#Also kills the sysprep process in order to automate sysprep generalize
Write-Host "Removing existing unattend.xml files and stopping sysprep process if running..."
Remove-Item -Path "C:\windows\panther\unattend\unattend.xml" -Force -ErrorAction SilentlyContinue
Remove-Item -Path "C:\windows\panther\unattend.xml" -Force -ErrorAction SilentlyContinue
Stop-Process -Name "sysprep" -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 10
# Detect and remediate per-user, non-provisioned Appx packages that would block Sysprep.
Write-Host "Checking for per-user Appx packages not provisioned for all users (potential Sysprep blockers)..."
# Build hash set of provisioned package families (DisplayName_PublisherId).
$provFamilies = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
Get-AppxProvisionedPackage -Online | ForEach-Object {
$family = '{0}_{1}' -f $_.DisplayName, $_.PublisherId
[void]$provFamilies.Add($family)
}
# Collect current user Appx packages excluding frameworks, resource packs, and non-removable packages.
$userApps = Get-AppxPackage -User $env:USERNAME | Where-Object {
$_.Status -eq 'Ok' -and
-not $_.IsFramework -and
-not $_.IsResourcePackage -and
-not $_.NonRemovable
}
# Identify packages not provisioned (per-user only).
$notProvisioned = foreach ($pkg in $userApps) {
if (-not $provFamilies.Contains($pkg.PackageFamilyName)) {
[PSCustomObject]@{
Name = $pkg.Name
PackageFamilyName = $pkg.PackageFamilyName
Version = $pkg.Version
SignatureKind = $pkg.SignatureKind
PackageFullName = $pkg.PackageFullName
}
}
}
if ($notProvisioned) {
Write-Host "Found $($notProvisioned.Count) per-user Appx package(s) not provisioned for all users:"
$notProvisioned | Sort-Object PackageFamilyName | Format-Table -AutoSize -Property Name,PackageFamilyName,Version
Write-Host "Attempting removal of per-user, non-provisioned Appx packages..."
foreach ($pkg in $notProvisioned) {
try {
Write-Host "Removing $($pkg.PackageFullName)..."
Remove-AppxPackage -Package $pkg.PackageFullName -ErrorAction Stop
}
catch {
Write-Warning "Failed to remove $($pkg.PackageFullName): $($_.Exception.Message)"
}
}
# Re-check after attempted removals.
$remaining = @()
$currentUserApps = Get-AppxPackage -User $env:USERNAME | Where-Object {
$_.Status -eq 'Ok' -and
-not $_.IsFramework -and
-not $_.IsResourcePackage -and
-not $_.NonRemovable
}
foreach ($pkg in $currentUserApps) {
if (-not $provFamilies.Contains($pkg.PackageFamilyName)) {
$remaining += $pkg
}
}
if ($remaining.Count -gt 0) {
Write-Error "Unable to remove all per-user, non-provisioned Appx packages. Sysprep cannot continue."
$remaining | Sort-Object PackageFamilyName | Format-Table -AutoSize -Property Name,PackageFamilyName,Version
throw "Sysprep aborted due to unresolved per-user Appx packages. Resolve manually and re-run."
}
else {
Write-Host "All per-user, non-provisioned Appx packages were successfully removed."
}
}
else {
Write-Host "No per-user, non-provisioned Appx packages detected."
}
# If an Unattend.xml has been provided on the mounted Apps ISO (D:\Unattend\Unattend.xml),
# pass it to sysprep; otherwise, run without /unattend.
$unattendOnAppsIso = "D:\Unattend\Unattend.xml"
if (Test-Path -Path $unattendOnAppsIso) {
Write-Host "Using $unattendOnAppsIso from Apps ISO..."
& "C:\windows\system32\sysprep\sysprep.exe" /quiet /generalize /oobe /unattend:$unattendOnAppsIso
}
else {
& "C:\windows\system32\sysprep\sysprep.exe" /quiet /generalize /oobe
}
@@ -5,7 +5,7 @@
<RunAsynchronous> <RunAsynchronous>
<RunAsynchronousCommand wcm:action="add"> <RunAsynchronousCommand wcm:action="add">
<Order>1</Order> <Order>1</Order>
<Path>d:\InstallAppsandSysprep.cmd</Path> <Path>C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -ExecutionPolicy Bypass -File "d:\orchestration\orchestrator.ps1"</Path>
</RunAsynchronousCommand> </RunAsynchronousCommand>
</RunAsynchronous> </RunAsynchronous>
</component> </component>
@@ -5,7 +5,7 @@
<RunAsynchronous> <RunAsynchronous>
<RunAsynchronousCommand wcm:action="add"> <RunAsynchronousCommand wcm:action="add">
<Order>1</Order> <Order>1</Order>
<Path>d:\InstallAppsandSysprep.cmd</Path> <Path>C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -ExecutionPolicy Bypass -File "d:\orchestration\orchestrator.ps1"</Path>
</RunAsynchronousCommand> </RunAsynchronousCommand>
</RunAsynchronous> </RunAsynchronous>
</component> </component>
+2160 -1471
View File
File diff suppressed because it is too large Load Diff
+696
View File
@@ -0,0 +1,696 @@
<#
.SYNOPSIS
Launches the FFU Development UI, a WPF application for configuring and running the FFU build process.
.DESCRIPTION
The BuildFFUVM_UI.ps1 script is the main entry point for the FFU Development user interface. It initializes and displays a WPF-based graphical interface defined in BuildFFUVM_UI.xaml.
The script is responsible for:
- Initializing a global state object to manage UI controls, data, and application flags.
- Importing the required FFU.Common and FFUUI.Core modules which contain the business logic.
- Ensuring system prerequisites, such as PowerShell 7 and Long Path Support, are met.
- Loading the XAML window, initializing UI controls with default values, and registering all event handlers.
- Launching the core build script (BuildFFUVM.ps1) in a background job when the user initiates a build.
- Providing real-time feedback by monitoring the build log file and updating the UI's progress bar and log viewer.
- Handling cleanup operations, such as reverting system settings, when the application is closed.
This script acts as the primary host for the UI, connecting the user interface with the underlying build and logic modules.
#>
[CmdletBinding()]
[System.STAThread()]
param()
# Check PowerShell Version
if ($PSVersionTable.PSVersion.Major -lt 7) {
Write-Error "PowerShell 7 or later is required to run this script."
exit 1
}
# Creating custom state object to hold UI state and data
$FFUDevelopmentPath = $PSScriptRoot
$script:uiState = [PSCustomObject]@{
FFUDevelopmentPath = $FFUDevelopmentPath;
Window = $null;
Controls = @{
featureCheckBoxes = @{};
UpdateInstallAppsBasedOnUpdates = $null
};
Data = @{
allDriverModels = [System.Collections.Generic.List[PSCustomObject]]::new();
appsScriptVariablesDataList = [System.Collections.Generic.List[PSCustomObject]]::new();
versionData = $null;
vmSwitchMap = @{};
logData = $null;
logStreamReader = $null;
pollTimer = $null;
lastConfigFilePath = $null
};
Flags = @{
installAppsForcedByUpdates = $false;
prevInstallAppsStateBeforeUpdates = $null;
installAppsCheckedByOffice = $false;
lastSortProperty = $null;
lastSortAscending = $true;
isBuilding = $false;
isCleanupRunning = $false
};
Defaults = @{};
LogFilePath = "$FFUDevelopmentPath\FFUDevelopment_UI.log"
}
# Remove any existing modules to avoid conflicts
if (Get-Module -Name 'FFU.Common' -ErrorAction SilentlyContinue) {
Remove-Module -Name 'FFU.Common' -Force
}
if (Get-Module -Name 'FFUUI.Core' -ErrorAction SilentlyContinue) {
Remove-Module -Name 'FFUUI.Core' -Force
}
# Import Modules
Import-Module "$PSScriptRoot\FFU.Common" -Force
Import-Module "$PSScriptRoot\FFUUI.Core" -Force
# Set the log path
Set-CommonCoreLogPath -Path $script:uiState.LogFilePath
# Setting long path support - this prevents issues where some applications have deep directory structures
# and driver extraction fails due to long paths.
$script:uiState.Flags.originalLongPathsValue = $null # Store original value
try {
$script:uiState.Flags.originalLongPathsValue = Get-ItemPropertyValue -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -ErrorAction SilentlyContinue
}
catch {
# Key or value might not exist, which is fine.
WriteLog "Could not read initial LongPathsEnabled value (may not exist)."
}
# Enable long paths if not already enabled
if ($script:uiState.Flags.originalLongPathsValue -ne 1) {
try {
WriteLog 'LongPathsEnabled is not set to 1. Setting it to 1 for the duration of this script.'
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -Value 1 -Force
WriteLog 'LongPathsEnabled set to 1.'
}
catch {
WriteLog "Error setting LongPathsEnabled registry key: $($_.Exception.Message). Long path issues might persist."
}
}
else {
WriteLog "LongPathsEnabled is already set to 1."
}
if (Test-Path -Path $script:uiState.LogFilePath) {
Remove-item -Path $script:uiState.LogFilePath -Force
}
Add-Type -AssemblyName WindowsBase
Add-Type -AssemblyName PresentationCore, PresentationFramework
Add-Type -AssemblyName System.Windows.Forms
# Load XAML
$xamlPath = Join-Path $PSScriptRoot "BuildFFUVM_UI.xaml"
if (-not (Test-Path $xamlPath)) {
Write-Error "XAML file not found: $xamlPath"
return
}
$xamlString = Get-Content $xamlPath -Raw
$reader = New-Object System.IO.StringReader($xamlString)
$xmlReader = [System.Xml.XmlReader]::Create($reader)
$window = [Windows.Markup.XamlReader]::Load($xmlReader)
$window.Add_Loaded({
# Pass the state object to all initialization functions
$script:uiState.Window = $window
$window.Tag = $script:uiState
Initialize-UIControls -State $script:uiState
Initialize-UIDefaults -State $script:uiState
Initialize-DynamicUIElements -State $script:uiState
Register-EventHandlers -State $script:uiState
# Attempt automatic load of previous environment (silent)
try {
Invoke-AutoLoadPreviousEnvironment -State $script:uiState
}
catch {
WriteLog "Auto-load previous environment failed: $($_.Exception.Message)"
}
})
# Button: Build FFU
$script:uiState.Controls.btnRun = $window.FindName('btnRun')
$script:uiState.Controls.btnRun.Add_Click({
# Get a local reference to the button for convenience in this handler
$btnRun = $script:uiState.Controls.btnRun
try {
# If a build is running and cleanup is not already running, treat this click as Cancel
if ($script:uiState.Flags.isBuilding -and -not $script:uiState.Flags.isCleanupRunning) {
$btnRun.IsEnabled = $false
$script:uiState.Controls.txtStatus.Text = "Cancel requested. Stopping build..."
WriteLog "Cancel requested by user. Stopping background build job."
# Stop the timer
if ($null -ne $script:uiState.Data.pollTimer) {
$script:uiState.Data.pollTimer.Stop()
$script:uiState.Data.pollTimer = $null
}
# Close the log stream
if ($null -ne $script:uiState.Data.logStreamReader) {
$script:uiState.Data.logStreamReader.Close()
$script:uiState.Data.logStreamReader.Dispose()
$script:uiState.Data.logStreamReader = $null
}
# Stop and remove the running build job
$jobToStop = $script:uiState.Data.currentBuildJob
$script:uiState.Data.currentBuildJob = $null
if ($null -ne $jobToStop) {
try {
# Attempt graceful stop first
Stop-Job -Job $jobToStop -ErrorAction SilentlyContinue
Wait-Job -Job $jobToStop -Timeout 5 -ErrorAction SilentlyContinue | Out-Null
}
catch {
WriteLog "Stop-Job threw: $($_.Exception.Message)"
}
# If the job's hosting process is still alive, kill its process tree to stop child tools like DISM
try {
$jobProcId = $null
if ($null -ne $jobToStop.ChildJobs -and $jobToStop.ChildJobs.Count -gt 0) {
$jobProcId = $jobToStop.ChildJobs[0].ProcessId
}
if ($jobProcId) {
# Recursively terminate the job process and any children
function Stop-ProcessTree {
param([int]$parentPid)
$children = Get-CimInstance Win32_Process -Filter "ParentProcessId=$parentPid" -ErrorAction SilentlyContinue
foreach ($child in $children) {
Stop-ProcessTree -parentPid $child.ProcessId
}
try { Stop-Process -Id $parentPid -Force -ErrorAction SilentlyContinue } catch {}
}
Stop-ProcessTree -parentPid $jobProcId
}
}
catch {
WriteLog "Error terminating job process tree: $($_.Exception.Message)"
}
# Safety net: kill any active DISM capture still running
try {
$dismCaptures = Get-CimInstance Win32_Process -Filter "Name='DISM.EXE'" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -match '/Capture-FFU' }
foreach ($p in $dismCaptures) {
try { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } catch {}
}
}
catch {
WriteLog "Error stopping DISM capture processes: $($_.Exception.Message)"
}
# Also stop Office ODT setup.exe if running (to avoid recreating files after cleanup)
try {
$officePathForKill = $null
# Prefer explicit UI path
$uiOfficePath = $script:uiState.Controls.txtOfficePath.Text
if (-not [string]::IsNullOrWhiteSpace($uiOfficePath)) {
$officePathForKill = $uiOfficePath
}
else {
# Fall back to the last config path only if known
$lastConfigPathLocal = $script:uiState.Data.lastConfigFilePath
if (-not [string]::IsNullOrWhiteSpace($lastConfigPathLocal)) {
$ffuDevRoot = Split-Path (Split-Path $lastConfigPathLocal -Parent) -Parent
if (-not [string]::IsNullOrWhiteSpace($ffuDevRoot)) {
$officePathForKill = Join-Path $ffuDevRoot 'Apps\Office'
}
}
}
# Only proceed when a valid Office folder exists
if ($officePathForKill -and (Test-Path -LiteralPath $officePathForKill -PathType Container)) {
$setupProcs = Get-CimInstance Win32_Process -Filter "Name='setup.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.ExecutablePath -like "$officePathForKill*" }
foreach ($p in $setupProcs) {
try { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } catch {}
}
}
}
catch {
WriteLog "Error stopping Office setup.exe processes: $($_.Exception.Message)"
}
try {
Remove-Job -Job $jobToStop -Force -ErrorAction SilentlyContinue
WriteLog "Background build job stopped and removed."
}
catch {
WriteLog "Error removing background build job: $($_.Exception.Message)"
}
}
# Start cleanup using the same BuildFFUVM.ps1 via -Cleanup short-circuit
$lastConfigPath = $script:uiState.Data.lastConfigFilePath
if ([string]::IsNullOrWhiteSpace($lastConfigPath)) {
WriteLog "No stored config file path found. Cleanup cannot proceed."
$script:uiState.Controls.txtStatus.Text = "Build canceled. No config found for cleanup."
$script:uiState.Flags.isBuilding = $false
$script:uiState.Flags.isCleanupRunning = $false
$btnRun.Content = "Build FFU"
$btnRun.IsEnabled = $true
return
}
$ffuDevPath = Split-Path (Split-Path $lastConfigPath -Parent) -Parent
$mainLogPath = Join-Path $ffuDevPath "FFUDevelopment.log"
WriteLog "Starting cleanup without deleting FFUDevelopment.log (will append new entries)."
$script:uiState.Controls.txtStatus.Text = "Cancel in progress... Cleaning environment..."
WriteLog "Starting cleanup job (BuildFFUVM.ps1 -Cleanup)."
# Prepare parameters for cleanup
# Inform user: in-progress items will be removed; ask whether to also remove other items downloaded during this run
$removeCurrentRunToo = $false
$promptText = "Cancel requested.`n`nWe'll remove the download currently in progress to avoid partial/corrupt content.`n`nDo you also want to remove other items downloaded during this run? Previously downloaded items will be kept."
$result = [System.Windows.MessageBox]::Show($promptText, "Cancel cleanup options", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question)
if ($result -eq [System.Windows.MessageBoxResult]::Yes) { $removeCurrentRunToo = $true }
$cleanupParams = @{
ConfigFile = $lastConfigPath
Cleanup = $true
# Avoid wiping all user content on cancel
RemoveApps = $false
RemoveUpdates = $false
CleanupDrivers = $false
# Scoped removal to current run only (optional per user choice)
CleanupCurrentRunDownloads = $removeCurrentRunToo
}
$cleanupScriptBlock = {
param($buildParams, $PSScriptRoot)
& "$PSScriptRoot\BuildFFUVM.ps1" @buildParams
}
# Start cleanup job
$script:uiState.Data.currentBuildJob = Start-Job -ScriptBlock $cleanupScriptBlock -ArgumentList @($cleanupParams, $PSScriptRoot)
# Wait for log file to appear (or open immediately if it exists)
$logWaitTimeout = 60
$watch = [System.Diagnostics.Stopwatch]::StartNew()
while (-not (Test-Path $mainLogPath) -and $watch.Elapsed.TotalSeconds -lt $logWaitTimeout) {
Start-Sleep -Milliseconds 250
}
$watch.Stop()
# Open log stream for cleanup (tail to end to avoid re-reading the whole file)
if (Test-Path $mainLogPath) {
$fileStream = [System.IO.File]::Open($mainLogPath, 'Open', 'Read', 'ReadWrite')
[void]$fileStream.Seek(0, [System.IO.SeekOrigin]::End)
$script:uiState.Data.logStreamReader = [System.IO.StreamReader]::new($fileStream)
}
else {
WriteLog "Warning: Main log file not found at $mainLogPath after waiting. Monitor tab will not update during cleanup."
}
# Create a timer to poll the cleanup job
$script:uiState.Data.pollTimer = New-Object System.Windows.Threading.DispatcherTimer
$script:uiState.Data.pollTimer.Interval = [TimeSpan]::FromSeconds(1)
$script:uiState.Flags.isCleanupRunning = $true
$script:uiState.Data.pollTimer.Add_Tick({
param($sender, $e)
$currentJob = $script:uiState.Data.currentBuildJob
# Read new lines from log
if ($null -ne $script:uiState.Data.logStreamReader) {
while ($null -ne ($line = $script:uiState.Data.logStreamReader.ReadLine())) {
$script:uiState.Data.logData.Add($line)
if ($script:uiState.Flags.autoScrollLog) {
$script:uiState.Controls.lstLogOutput.ScrollIntoView($line)
$script:uiState.Controls.lstLogOutput.SelectedIndex = $script:uiState.Controls.lstLogOutput.Items.Count - 1
}
}
}
if ($null -eq $currentJob -or $null -eq $script:uiState.Data.pollTimer) {
if ($null -ne $sender) { $sender.Stop() }
$script:uiState.Data.pollTimer = $null
return
}
if ($currentJob.State -in 'Completed', 'Failed', 'Stopped') {
if ($null -ne $sender) { $sender.Stop() }
$script:uiState.Data.pollTimer = $null
if ($null -ne $script:uiState.Data.logStreamReader) {
$lastLine = $null
while ($null -ne ($line = $script:uiState.Data.logStreamReader.ReadLine())) {
$script:uiState.Data.logData.Add($line)
$lastLine = $line
}
if ($script:uiState.Flags.autoScrollLog -and $null -ne $lastLine) {
$script:uiState.Controls.lstLogOutput.ScrollIntoView($lastLine)
$script:uiState.Controls.lstLogOutput.SelectedIndex = $script:uiState.Controls.lstLogOutput.Items.Count - 1
}
$script:uiState.Data.logStreamReader.Close()
$script:uiState.Data.logStreamReader.Dispose()
$script:uiState.Data.logStreamReader = $null
}
$script:uiState.Controls.txtStatus.Text = "Build canceled. Environment cleaned."
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
$script:uiState.Controls.pbOverallProgress.Value = 0
# Receive and remove cleanup job
$currentJob | Receive-Job -ErrorAction SilentlyContinue | Out-Null
Remove-Job -Job $currentJob -Force
$script:uiState.Data.currentBuildJob = $null
# Reset flags and button
$script:uiState.Flags.isCleanupRunning = $false
$script:uiState.Flags.isBuilding = $false
$btn = $script:uiState.Controls.btnRun
$btn.Content = "Build FFU"
$btn.IsEnabled = $true
}
})
$script:uiState.Data.pollTimer.Start()
return
}
# Not currently building: start a new build
$btnRun.IsEnabled = $false
# Switch to Monitor Tab
$script:uiState.Controls.MainTabControl.SelectedItem = $script:uiState.Controls.MonitorTab
# Clear previous log data and reset autoscroll
if ($null -ne $script:uiState.Data.logData) {
$script:uiState.Data.logData.Clear()
$script:uiState.Flags.autoScrollLog = $true
}
$progressBar = $script:uiState.Controls.pbOverallProgress
$txtStatus = $script:uiState.Controls.txtStatus
$progressBar.Visibility = 'Visible'
$txtStatus.Text = "Starting FFU build..."
# Gather config on the UI thread before starting the job
$config = Get-UIConfig -State $script:uiState
# Validate Additional FFU selection if enabled
if ($config.BuildUSBDrive -and $config.CopyAdditionalFFUFiles -and (($null -eq $config.AdditionalFFUFiles) -or ($config.AdditionalFFUFiles.Count -eq 0))) {
[System.Windows.MessageBox]::Show("Please select at least one additional FFU file to copy, or uncheck 'Copy Additional FFU Files'.", "Selection Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: Additional FFU selection required."
return
}
$configFilePath = Join-Path $config.FFUDevelopmentPath "\config\FFUConfig.json"
# Sort top-level keys alphabetically for consistent output
$sortedConfig = [ordered]@{}
foreach ($k in ($config.Keys | Sort-Object)) { $sortedConfig[$k] = $config[$k] }
$sortedConfig | ConvertTo-Json -Depth 10 | Set-Content -Path $configFilePath -Encoding UTF8
$script:uiState.Data.lastConfigFilePath = $configFilePath
if ($config.InstallOffice -and $config.OfficeConfigXMLFile) {
Copy-Item -Path $config.OfficeConfigXMLFile -Destination $config.OfficePath -Force
WriteLog "Office Configuration XML file copied successfully."
}
$txtStatus.Text = "Executing BuildFFUVM.ps1 in the background..."
WriteLog "Executing BuildFFUVM.ps1 in the background..."
# Prepare parameters for splatting
$buildParams = @{
ConfigFile = $configFilePath
}
if ($config.Verbose) {
$buildParams['Verbose'] = $true
}
# Define the script block to run in the background job
$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"
if (Test-Path $mainLogPath) {
WriteLog "Removing old FFUDevelopment.log file."
Remove-Item -Path $mainLogPath -Force
}
# Start the job and store it in the shared state object
$script:uiState.Data.currentBuildJob = Start-Job -ScriptBlock $scriptBlock -ArgumentList @($buildParams, $PSScriptRoot)
# Wait for the new log file to be created by the background job.
$logWaitTimeout = 15 # seconds
$watch = [System.Diagnostics.Stopwatch]::StartNew()
while (-not (Test-Path $mainLogPath) -and $watch.Elapsed.TotalSeconds -lt $logWaitTimeout) {
Start-Sleep -Milliseconds 250
}
$watch.Stop()
# Open a stream reader to the main log file
if (Test-Path $mainLogPath) {
$fileStream = [System.IO.File]::Open($mainLogPath, 'Open', 'Read', 'ReadWrite')
$script:uiState.Data.logStreamReader = [System.IO.StreamReader]::new($fileStream)
}
else {
WriteLog "Warning: Main log file not found at $mainLogPath after waiting. Monitor tab will not update."
}
# Create a timer to poll the job status from the UI thread
$script:uiState.Data.pollTimer = New-Object System.Windows.Threading.DispatcherTimer
$script:uiState.Data.pollTimer.Interval = [TimeSpan]::FromSeconds(1)
# Add the Tick event handler
$script:uiState.Data.pollTimer.Add_Tick({
param($sender, $e)
# This scriptblock runs on the UI thread, so it can safely access script-scoped variables
$currentJob = $script:uiState.Data.currentBuildJob
# Read from log stream
if ($null -ne $script:uiState.Data.logStreamReader) {
while ($null -ne ($line = $script:uiState.Data.logStreamReader.ReadLine())) {
# Add the full line to the log view first to maintain consistency
$script:uiState.Data.logData.Add($line)
if ($script:uiState.Flags.autoScrollLog) {
$script:uiState.Controls.lstLogOutput.ScrollIntoView($line)
$script:uiState.Controls.lstLogOutput.SelectedIndex = $script:uiState.Controls.lstLogOutput.Items.Count - 1
}
# Now, check if it's a progress line and update the UI accordingly
if ($line -match '\[PROGRESS\] (\d{1,3}) \| (.*)') {
$percentage = [double]$matches[1]
$message = $matches[2]
# Update progress bar and status text
$script:uiState.Controls.pbOverallProgress.Value = $percentage
$script:uiState.Controls.txtStatus.Text = $message
}
}
}
# If job 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 -ne $sender) {
$sender.Stop()
}
$script:uiState.Data.pollTimer = $null
return
}
# Check if the job has reached a terminal state
if ($currentJob.State -in 'Completed', 'Failed', 'Stopped') {
# Stop the timer, we're done polling
if ($null -ne $sender) {
$sender.Stop()
}
$script:uiState.Data.pollTimer = $null
# Final read of the log stream
if ($null -ne $script:uiState.Data.logStreamReader) {
$lastLine = $null
while ($null -ne ($line = $script:uiState.Data.logStreamReader.ReadLine())) {
# Add the full line to the log view first
$script:uiState.Data.logData.Add($line)
$lastLine = $line
# Now, check if it's a progress line and update the UI accordingly
if ($line -match '\[PROGRESS\] (\d{1,3}) \| (.*)') {
$percentage = [double]$matches[1]
$message = $matches[2]
$script:uiState.Controls.pbOverallProgress.Value = $percentage
$script:uiState.Controls.txtStatus.Text = $message
}
}
# After the final read, scroll to the last line if autoscroll is enabled
if ($script:uiState.Flags.autoScrollLog -and $null -ne $lastLine) {
$script:uiState.Controls.lstLogOutput.ScrollIntoView($lastLine)
$script:uiState.Controls.lstLogOutput.SelectedIndex = $script:uiState.Controls.lstLogOutput.Items.Count - 1
}
$script:uiState.Data.logStreamReader.Close()
$script:uiState.Data.logStreamReader.Dispose()
$script:uiState.Data.logStreamReader = $null
}
# Determine final status based on job result and whether cleanup was running (should be false here)
$finalStatusText = "FFU build completed successfully."
if ($currentJob.State -eq 'Failed') {
$reason = $null
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."
WriteLog "BuildFFUVM.ps1 job failed. Reason: $reason"
[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
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
}
else {
WriteLog "BuildFFUVM.ps1 job completed successfully."
$script:uiState.Controls.pbOverallProgress.Value = 100
}
# Update UI elements
$script:uiState.Controls.txtStatus.Text = $finalStatusText
# Receive & remove job and clear state
$currentJob | Receive-Job -ErrorAction SilentlyContinue | Out-Null
Remove-Job -Job $currentJob -Force
$script:uiState.Data.currentBuildJob = $null
# Reset button and flags for next run
$script:uiState.Flags.isBuilding = $false
$script:uiState.Flags.isCleanupRunning = $false
$script:uiState.Controls.btnRun.Content = "Build FFU"
$script:uiState.Controls.btnRun.IsEnabled = $true
}
})
# Start the timer
$script:uiState.Data.pollTimer.Start()
# Mark building and toggle button to Cancel
$script:uiState.Flags.isBuilding = $true
$btnRun.Content = "Cancel"
$btnRun.IsEnabled = $true
}
catch {
# This catch block handles errors during the setup of the job (e.g., Get-UIConfig fails)
$errorMessage = "An error occurred before starting the build job: $_"
WriteLog $errorMessage
[System.Windows.MessageBox]::Show($errorMessage, "Error", "OK", "Error")
# Clean up stream reader if it was opened
if ($null -ne $script:uiState.Data.logStreamReader) {
$script:uiState.Data.logStreamReader.Close()
$script:uiState.Data.logStreamReader.Dispose()
$script:uiState.Data.logStreamReader = $null
}
# Re-enable UI elements
$script:uiState.Controls.txtStatus.Text = "FFU build failed to start."
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
if ($null -ne $script:uiState.Controls.btnRun) {
$script:uiState.Controls.btnRun.IsEnabled = $true
$script:uiState.Controls.btnRun.Content = "Build FFU"
$script:uiState.Flags.isBuilding = $false
$script:uiState.Flags.isCleanupRunning = $false
}
}
})
# Add handler for Remove button clicks
$window.Add_SourceInitialized({
$listView = $window.FindName('lstApplications')
$listView.AddHandler(
[System.Windows.Controls.Button]::ClickEvent,
[System.Windows.RoutedEventHandler] {
param($buttonSender, $clickEventArgs)
if ($clickEventArgs.OriginalSource -is [System.Windows.Controls.Button] -and $clickEventArgs.OriginalSource.Content -eq "Remove") {
Remove-Application -priority $clickEventArgs.OriginalSource.Tag -State $script:uiState
}
}
)
})
# Register cleanup to reclaim memory and revert LongPathsEnabled setting when the UI window closes
$window.Add_Closed({
# Stop any running build job if the window is closed
if ($null -ne $script:uiState.Data.currentBuildJob) {
WriteLog "UI closing, stopping background build job."
# Stop the timer
if ($null -ne $script:uiState.Data.pollTimer) {
$script:uiState.Data.pollTimer.Stop()
$script:uiState.Data.pollTimer = $null
}
# Close the log stream
if ($null -ne $script:uiState.Data.logStreamReader) {
$script:uiState.Data.logStreamReader.Close()
$script:uiState.Data.logStreamReader.Dispose()
$script:uiState.Data.logStreamReader = $null
}
# Stop and remove the job
$jobToStop = $script:uiState.Data.currentBuildJob
$script:uiState.Data.currentBuildJob = $null # Clear it from state first
try {
Stop-Job -Job $jobToStop
Remove-Job -Job $jobToStop
WriteLog "Background job stopped and removed."
}
catch {
WriteLog "Error stopping or removing background job: $($_.Exception.Message)"
}
}
# Revert LongPathsEnabled registry setting if it was changed by this script
if ($script:uiState.Flags.originalLongPathsValue -ne 1) {
# Only revert if we changed it from something other than 1
try {
$currentValue = Get-ItemPropertyValue -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -ErrorAction SilentlyContinue
if ($currentValue -eq 1) {
# Double-check it's still 1 before reverting
$revertValue = if ($null -eq $script:uiState.Flags.originalLongPathsValue) { 0 } else { $script:uiState.Flags.originalLongPathsValue } # Revert to original or 0 if it didn't exist
WriteLog "Reverting LongPathsEnabled registry key back to original value ($revertValue)."
Set-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -Value $revertValue -Force
WriteLog "LongPathsEnabled reverted."
}
}
catch {
WriteLog "Error reverting LongPathsEnabled registry key: $($_.Exception.Message)."
}
}
# # Garbage collection
# [System.GC]::Collect()
# [System.GC]::WaitForPendingFinalizers()
})
[void]$window.ShowDialog()
+849
View File
@@ -0,0 +1,849 @@
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=mscorlib" Title="FFU Builder UI">
<Window.Resources>
<Style x:Key="MinimalExpanderNoHighlightStyle" TargetType="Expander">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Expander">
<StackPanel>
<!-- Header Toggle -->
<ToggleButton x:Name="HeaderToggle" IsChecked="{Binding IsExpanded, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}" Background="Transparent" BorderThickness="0" Padding="0" HorizontalAlignment="Left" HorizontalContentAlignment="Left" VerticalContentAlignment="Center">
<ToggleButton.Style>
<Style TargetType="ToggleButton">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<ContentPresenter/>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ToggleButton.Style>
<!-- Text + Arrow side by side -->
<StackPanel Orientation="Horizontal">
<TextBlock Text="Optional Features" Margin="0,0,6,0" VerticalAlignment="Center"/>
<!-- Default arrow = “▼” -->
<TextBlock x:Name="ArrowText" Text="&#9660;" VerticalAlignment="Center"/>
</StackPanel>
</ToggleButton>
<!-- Expanded content -->
<ContentPresenter x:Name="ExpandSite" Visibility="Collapsed" Margin="0,4,0,0"/>
</StackPanel>
<!-- Trigger: Show content, swap arrow to “▲” when expanded -->
<ControlTemplate.Triggers>
<Trigger Property="IsExpanded" Value="True">
<Setter TargetName="ExpandSite" Property="Visibility" Value="Visible"/>
<Setter TargetName="ArrowText" Property="Text" Value="&#9650;"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Added global tooltip styles for consistent tooltips -->
<Style TargetType="TextBox">
<Setter Property="ToolTip" Value="{Binding Tag, RelativeSource={RelativeSource Self}}"/>
</Style>
<Style TargetType="TextBlock">
<Setter Property="ToolTip" Value="{Binding Tag, RelativeSource={RelativeSource Self}}"/>
</Style>
<Style TargetType="CheckBox">
<Setter Property="ToolTip" Value="{Binding Tag, RelativeSource={RelativeSource Self}}"/>
</Style>
</Window.Resources>
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- TabControl with multiple tabs -->
<TabControl x:Name="MainTabControl" TabStripPlacement="Left" VerticalAlignment="Stretch" HorizontalAlignment="Stretch" FontSize="14" Padding="10" Grid.Row="0">
<!-- TAB: Home -->
<TabItem Header="Home" Padding="20">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<Grid Margin="10">
<TextBlock Text="Welcome to FFU Builder" FontSize="20" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
</ScrollViewer>
</TabItem>
<!-- TAB: Hyper-V Settings -->
<TabItem Header="Hyper-V Settings" Padding="20">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Row 0: VM Switch Name -->
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5">
<TextBlock Text="VM Switch Name" ToolTip="Name of the Hyper-V virtual switch. If $InstallApps is set to $true, this must be set. This is required to capture the FFU from the VM. The default is '*external*', but you will likely need to change this."/>
</StackPanel>
<ComboBox x:Name="cmbVMSwitchName" Grid.Row="0" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" ToolTip="Name of the Hyper-V virtual switch. If $InstallApps is set to $true, this must be set. This is required to capture the FFU from the VM. The default is '*external*', but you will likely need to change this."/>
<!-- Row 1: Custom VM Switch Name -->
<TextBox x:Name="txtCustomVMSwitchName" Grid.Row="1" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" Visibility="Collapsed" ToolTip="Enter your custom VM Switch Name if 'Other' is selected."/>
<!-- Row 2: VM Host IP Address -->
<StackPanel Grid.Row="2" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5">
<TextBlock Text="VM Host IP Address" ToolTip="IP address of the Hyper-V host for FFU capture. If $InstallApps is set to $true, this parameter must be configured. You must manually configure this. The script will not auto-detect your IP (depending on your network adapters, it may not find the correct IP)."/>
</StackPanel>
<TextBox x:Name="txtVMHostIPAddress" Grid.Row="2" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch"/>
<!-- Row 3: Disk Size (GB) -->
<StackPanel Grid.Row="3" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5">
<TextBlock Text="Disk Size (GB)" ToolTip="Size of the virtual hard disk for the virtual machine. Default is a 50GB dynamic disk."/>
</StackPanel>
<TextBox x:Name="txtDiskSize" Grid.Row="3" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" Text="50" ToolTip="Size of the virtual hard disk for the virtual machine. Default is a 50GB dynamic disk."/>
<!-- Row 4: Memory (GB) -->
<StackPanel Grid.Row="4" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5">
<TextBlock Text="Memory (GB)" ToolTip="Amount of memory to allocate for the virtual machine. Recommended to use 8GB if possible, especially for Windows 11. Default is 4GB."/>
</StackPanel>
<TextBox x:Name="txtMemory" Grid.Row="4" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" Text="4" ToolTip="Amount of memory to allocate for the virtual machine. Recommended to use 8GB if possible, especially for Windows 11. Default is 4GB."/>
<!-- Row 5: Processors -->
<StackPanel Grid.Row="5" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5">
<TextBlock Text="Processors" ToolTip="Number of virtual processors for the virtual machine. Recommended to use at least 4."/>
</StackPanel>
<TextBox x:Name="txtProcessors" Grid.Row="5" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" Text="4" ToolTip="Number of virtual processors for the virtual machine. Recommended to use at least 4."/>
<!-- Row 6: VM Location -->
<StackPanel Grid.Row="6" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5">
<TextBlock Text="VM Location" ToolTip="Default is $FFUDevelopmentPath\VM. This is the location of the VHDX that gets created where Windows will be installed to."/>
</StackPanel>
<TextBox x:Name="txtVMLocation" Grid.Row="6" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" Text="{x:Static sys:Environment.CurrentDirectory}" ToolTip="Default is $FFUDevelopmentPath\VM. This is the location of the VHDX that gets created where Windows will be installed to."/>
<!-- Row 7: VM Name Prefix -->
<StackPanel Grid.Row="7" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5">
<TextBlock Text="VM Name Prefix" ToolTip="Prefix for the VM Name. The default is _FFU."/>
</StackPanel>
<TextBox x:Name="txtVMNamePrefix" Grid.Row="7" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" ToolTip="Prefix for the VM Name. The default is _FFU."/>
<!-- Row 8: Logical Sector Size -->
<StackPanel Grid.Row="8" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5">
<TextBlock Text="Logical Sector Size" ToolTip="Unit32 value of 512 or 4096. Useful for 4Kn drives or devices shipping with UFS drives. Default is 512."/>
</StackPanel>
<ComboBox x:Name="cmbLogicalSectorSize" Grid.Row="8" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Left" ToolTip="Unit32 value of 512 or 4096. Useful for 4Kn drives or devices shipping with UFS drives. Default is 512.">
<ComboBoxItem Content="512" IsSelected="True"/>
<ComboBoxItem Content="4096"/>
</ComboBox>
</Grid>
</ScrollViewer>
</TabItem>
<!-- TAB: Windows Settings -->
<TabItem Header="Windows Settings" Padding="20">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- (0) ISO Path -->
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" Margin="0,5">
<TextBlock Text="Windows ISO Path" VerticalAlignment="Center" ToolTip="Path to the Windows 10/11 ISO file."/>
</StackPanel>
<Grid Grid.Row="0" Grid.Column="1" Margin="5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="txtISOPath" Grid.Column="0" VerticalAlignment="Center" HorizontalAlignment="Stretch"/>
<Button x:Name="btnBrowseISO" Grid.Column="1" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
</Grid>
<!-- (1) Windows Release -->
<StackPanel Grid.Row="1" Grid.Column="0" Orientation="Horizontal" Margin="0,5">
<TextBlock Text="Windows Release" VerticalAlignment="Center" ToolTip="Integer value of 10 or 11. This is used to identify which release of Windows to download. Default is 11."/>
</StackPanel>
<ComboBox x:Name="cmbWindowsRelease" Grid.Row="1" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" ToolTip="Integer value of 10 or 11. This is used to identify which release of Windows to download. Default is 11."/>
<!-- (2) Windows Version -->
<StackPanel Grid.Row="2" Grid.Column="0" Orientation="Horizontal" Margin="0,5">
<TextBlock Text="Windows Version" VerticalAlignment="Center" ToolTip="String value of the Windows version to download. This is used to identify which version of Windows to download. Default is '24h2'."/>
</StackPanel>
<ComboBox x:Name="cmbWindowsVersion" Grid.Row="2" Grid.Column="1" Margin="5" Width="120" VerticalAlignment="Center" HorizontalAlignment="Left" IsEnabled="False" ToolTip="String value of the Windows version to download. This is used to identify which version of Windows to download. Default is '24h2'."/>
<!-- (3) Windows Arch -->
<StackPanel Grid.Row="3" Grid.Column="0" Orientation="Horizontal" Margin="0,5">
<TextBlock Text="Windows Architecture" VerticalAlignment="Center" ToolTip="String value of 'x86' or 'x64'. This is used to identify which architecture of Windows to download. Default is 'x64'."/>
</StackPanel>
<ComboBox x:Name="cmbWindowsArch" Grid.Row="3" Grid.Column="1" Margin="5" Width="120" VerticalAlignment="Center" HorizontalAlignment="Left" ToolTip="String value of 'x86' or 'x64'. This is used to identify which architecture of Windows to download. Default is 'x64'."/>
<!-- (4) Windows Lang -->
<StackPanel x:Name="WindowsLangStackPanel" Grid.Row="4" Grid.Column="0" Orientation="Horizontal" Margin="0,5">
<TextBlock Text="Windows Language" VerticalAlignment="Center" ToolTip="String value in language-region format (e.g., 'en-us'). This is used to identify which language of media to download. Default is 'en-us'."/>
</StackPanel>
<ComboBox x:Name="cmbWindowsLang" Grid.Row="4" Grid.Column="1" Margin="5" Width="120" VerticalAlignment="Center" HorizontalAlignment="Left" ToolTip="String value in language-region format (e.g., 'en-us'). This is used to identify which language of media to download. Default is 'en-us'."/>
<!-- (5) Windows SKU -->
<StackPanel Grid.Row="5" Grid.Column="0" Orientation="Horizontal" Margin="0,5">
<TextBlock Text="Windows SKU" VerticalAlignment="Center" ToolTip="Edition of Windows 10/11 to be installed. Accepted values are: 'Home', 'Home N', 'Home Single Language', 'Education', 'Education N', 'Pro', 'Pro N', 'Pro Education', 'Pro Education N', 'Pro for Workstations', 'Pro N for Workstations', 'Enterprise', 'Enterprise N'."/>
</StackPanel>
<ComboBox x:Name="cmbWindowsSKU" Grid.Row="5" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch" ToolTip="Edition of Windows 10/11 to be installed. Accepted values are: 'Home', 'Home N', 'Home Single Language', 'Education', 'Education N', 'Pro', 'Pro N', 'Pro Education', 'Pro Education N', 'Pro for Workstations', 'Pro N for Workstations', 'Enterprise', 'Enterprise N'."/>
<!-- (6) Media Type -->
<StackPanel x:Name="MediaTypeStackPanel" Grid.Row="6" Grid.Column="0" Orientation="Horizontal" Margin="0,5">
<TextBlock Text="Media Type" VerticalAlignment="Center" ToolTip="String value of either 'business' or 'consumer'. This is used to identify which media type to download. Default is 'consumer'."/>
</StackPanel>
<ComboBox x:Name="cmbMediaType" Grid.Row="6" Grid.Column="1" Margin="5" Width="120" VerticalAlignment="Center" HorizontalAlignment="Left" ToolTip="String value of either 'business' or 'consumer'. This is used to identify which media type to download. Default is 'consumer'."/>
<!-- (7) Product Key -->
<StackPanel Grid.Row="7" Grid.Column="0" Orientation="Horizontal" Margin="0,5">
<TextBlock Text="Product Key" VerticalAlignment="Center" ToolTip="Product key for the Windows edition specified in WindowsSKU. This will overwrite whatever SKU is entered for WindowsSKU. Recommended to use if you want to use a MAK or KMS key to activate Enterprise or Education. If using VL media instead of consumer media, you'll want to enter a MAK or KMS key here."/>
</StackPanel>
<TextBox x:Name="txtProductKey" Grid.Row="7" Grid.Column="1" Margin="5" VerticalAlignment="Center" HorizontalAlignment="Stretch"/>
<!-- (8) Expander for Optional Features -->
<Expander x:Name="expOptionalFeatures" Style="{StaticResource MinimalExpanderNoHighlightStyle}" Grid.Row="8" Grid.Column="0" Grid.ColumnSpan="2" IsExpanded="False" Margin="0,5,0,0" ExpandDirection="Down">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto" Margin="0,5,0,0">
<StackPanel x:Name="stackFeaturesContainer" Margin="15,5">
<TextBlock Text="Selected features (semicolon):" Margin="0,10,0,5" FontStyle="Italic"/>
</StackPanel>
</ScrollViewer>
</Expander>
</Grid>
</ScrollViewer>
</TabItem>
<!-- TAB: Updates -->
<TabItem Header="Updates" Padding="20">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="5">
<CheckBox x:Name="chkUpdateLatestCU" Content="Update Latest Cumulative Update" Margin="5" VerticalAlignment="Center" ToolTip="When set to $true, will download and install the latest cumulative update for Windows 10/11. Default is $false."/>
<CheckBox x:Name="chkUpdateLatestNet" Content="Update .NET" Margin="5" VerticalAlignment="Center" ToolTip="When set to $true, will download and install the latest .NET Framework for Windows 10/11. Default is $false."/>
<CheckBox x:Name="chkUpdateLatestDefender" Content="Update Defender" Margin="5" VerticalAlignment="Center" ToolTip="When set to $true, will download and install the latest Windows Defender definitions and Defender platform update. Default is $false."/>
<CheckBox x:Name="chkUpdateEdge" Content="Update Edge" Margin="5" VerticalAlignment="Center" ToolTip="When set to $true, will download and install the latest Microsoft Edge for Windows 10/11. Default is $false."/>
<CheckBox x:Name="chkUpdateOneDrive" Content="Update OneDrive (Per-Machine)" Margin="5" VerticalAlignment="Center" ToolTip="When set to $true, will download and install the latest OneDrive for Windows 10/11 and install it as a per-machine installation instead of per-user. Default is $false."/>
<CheckBox x:Name="chkUpdateLatestMSRT" Content="Update Microsoft Software Removal Tool (MSRT)" Margin="5" VerticalAlignment="Center" ToolTip="When set to $true, will download and install the latest Windows Malicious Software Removal Tool. Default is $false."/>
<CheckBox x:Name="chkUpdateLatestMicrocode" Content="Update Latest Microcode (for LTSC/Server 2016/2019)" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will download and install the latest microcode updates for applicable Windows releases (e.g., Windows Server 2016/2019, Windows 10 LTSC 2016/2019) into the FFU."/>
<CheckBox x:Name="chkUpdatePreviewCU" Content="Update Preview Cumulative Update" Margin="5" VerticalAlignment="Center" ToolTip="When set to $true, will download and install the latest Preview cumulative update for Windows 10/11. Default is $false."/>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- TAB: Applications -->
<TabItem Header="Applications" Padding="20">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Regular Applications Section -->
<StackPanel Grid.Row="0" Margin="0,0,0,10">
<CheckBox x:Name="chkInstallApps" Content="Install Applications" Margin="5" ToolTip="Enable to install regular applications during the build process"/>
<!-- Application Path - Shows only when Install Applications is checked -->
<StackPanel x:Name="applicationPathPanel" Visibility="Collapsed" Margin="25,5,5,10">
<TextBlock Text="Application Path:" Margin="0,0,0,5" ToolTip="Path where applications will be downloaded and stored"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="txtApplicationPath" Grid.Column="0" VerticalAlignment="Center" ToolTip="Path where applications will be downloaded and stored"/>
<Button x:Name="btnBrowseApplicationPath" Grid.Column="1" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
</Grid>
</StackPanel>
<!-- AppList.json Path - Shows only when Install Applications is checked -->
<StackPanel x:Name="appListJsonPathPanel" Visibility="Collapsed" Margin="25,5,5,10">
<TextBlock Text="AppList.json Path:" Margin="0,0,0,5" ToolTip="Path to the AppList.json file"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="txtAppListJsonPath" Grid.Column="0" VerticalAlignment="Center" ToolTip="Path to the AppList.json file"/>
<Button x:Name="btnBrowseAppListJsonPath" Grid.Column="1" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
</Grid>
</StackPanel>
<!-- Winget Applications Section - Indented under Install Applications -->
<CheckBox x:Name="chkInstallWingetApps" Content="Install Winget Applications" Margin="5" ToolTip="Enable to install applications using Windows Package Manager (winget)"/>
<!-- Winget Status Panel -->
<StackPanel x:Name="wingetPanel" Visibility="Collapsed" Margin="25,0,5,5">
<TextBlock Text="Winget Status" FontWeight="Bold" Margin="0,0,0,5"/>
<Grid Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="150"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Winget CLI Version Display -->
<TextBlock Text="Winget Version:" Grid.Row="0" Grid.Column="0" VerticalAlignment="Center" ToolTip="Current version of the Winget CLI installed on the system"/>
<TextBlock x:Name="txtWingetVersion" Text="Not checked" Grid.Row="0" Grid.Column="1" VerticalAlignment="Center"/>
<!-- Winget PowerShell Module Version Display -->
<TextBlock Text="Module Version:" Grid.Row="1" Grid.Column="0" VerticalAlignment="Center" ToolTip="Current version of the Microsoft.WinGet.Client PowerShell module"/>
<TextBlock x:Name="txtWingetModuleVersion" Text="Not checked" Grid.Row="1" Grid.Column="1" VerticalAlignment="Center"/>
<!-- Check/Install Button -->
<Button x:Name="btnCheckWingetModule" Content="Check Winget Status" Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2" Margin="0,10,0,0" Padding="10,5" HorizontalAlignment="Left" ToolTip="Check installation status and version of Winget CLI and PowerShell module. Will install or update if needed."/>
</Grid>
</StackPanel>
<!-- Winget Search Panel -->
<StackPanel x:Name="wingetSearchPanel" Visibility="Collapsed" Margin="25,10,5,20">
<TextBlock Text="Winget Search" FontWeight="Bold" Margin="0,0,0,5"/>
<Grid Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="100"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="txtWingetSearch" Grid.Column="0" Margin="0,0,10,0" Height="24" VerticalContentAlignment="Center" VerticalAlignment="Center" ToolTip="Enter an application name to search for"/>
<Button x:Name="btnWingetSearch" Grid.Column="1" Content="Search" Width="100" Height="24" ToolTip="Search for applications using Windows Package Manager"/>
</Grid>
<!-- Results ListView -->
<ListView x:Name="lstWingetResults" Height="300" Margin="0,0,0,10" ScrollViewer.VerticalScrollBarVisibility="Auto" ScrollViewer.HorizontalScrollBarVisibility="Auto"/>
<!-- Save/Import/Clear Buttons -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
<Button x:Name="btnSaveWingetList" Content="Save AppList.json" Padding="15,5" Margin="0,0,10,0" ToolTip="Save selected applications to a JSON file"/>
<Button x:Name="btnImportWingetList" Content="Import AppList.json" Padding="15,5" Margin="0,0,10,0" ToolTip="Import applications from a JSON file"/>
<Button x:Name="btnDownloadSelected" Content="Download Selected" Padding="15,5" Margin="0,0,10,0" ToolTip="Download all selected applications"/>
<Button x:Name="btnClearWingetList" Content="Clear List" Padding="15,5" ToolTip="Clear all applications from the list"/>
</StackPanel>
</StackPanel>
<CheckBox x:Name="chkBringYourOwnApps" Content="Bring Your Own Applications" Margin="5" ToolTip="Enable to bring your own applications during the build process"/>
<!-- Application Information Section -->
<StackPanel x:Name="byoApplicationPanel" Visibility="Collapsed" Margin="25,0,5,20">
<TextBlock Text="Application Information" FontWeight="Bold" Margin="0,5,0,10"/>
<!-- Name -->
<TextBlock Text="Name:" Margin="0,0,0,5"/>
<TextBox x:Name="txtAppName" Margin="0,0,0,10" ToolTip="Enter the name of the application"/>
<!-- Command Line -->
<TextBlock Text="Command Line:" Margin="0,0,0,5"/>
<TextBox x:Name="txtAppCommandLine" Margin="0,0,0,10" ToolTip="Enter the full path to the command line to install the application. This should start with D:\Win32 for exe, cmd, etc types of deployments (e.g. D:\Win32\Mozilla FireFox\setup.exe). For MSI installs, use msiexec and then fill in the rest of the arguments in the arguments field."/>
<!-- Arguments -->
<TextBlock Text="Arguments:" Margin="0,0,0,5"/>
<TextBox x:Name="txtAppArguments" Margin="0,0,0,10" ToolTip="Enter the arguments for the command line. If the application is an msi, the command line should only contain msiexec and the rest of the command line arguments would go here (e.g. /i D:\Win32\Mozilla firefox\setup.msi /qn /norestart)."/>
<!-- Source -->
<TextBlock Text="Source:" Margin="0,0,0,5"/>
<Grid Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="txtAppSource" Grid.Column="0" VerticalAlignment="Center" ToolTip="Optional: Enter the source folder path of the application installation files. This is used to copy the files to the $AppsPath\Win32 directory by clicking the Copy Apps button"/>
<Button x:Name="btnBrowseAppSource" Grid.Column="1" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
</Grid>
<!-- Additional Exit Codes -->
<TextBlock Text="Additional Exit Codes:" Margin="0,0,0,5"/>
<TextBox x:Name="txtAppAdditionalExitCodes" Margin="0,0,0,10" ToolTip="Enter a comma-separated list of additional success exit codes."/>
<!-- Ignore Non-Zero Exit Codes Checkbox -->
<CheckBox x:Name="chkIgnoreExitCodes" Content="Ignore all non-zero exit codes" Margin="0,0,0,10" ToolTip="If checked, any non-zero exit code will be considered a success."/>
<!-- Add Application Button -->
<Button x:Name="btnAddApplication" Content="Add Application" Width="120" HorizontalAlignment="Left" Margin="0,10,0,10" Padding="10,5" ToolTip="Add the application to the list"/>
<!-- Grid to hold ListView and Reorder Buttons -->
<Grid Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!-- Applications ListView -->
<ListView x:Name="lstApplications" Grid.Column="0" Height="200">
</ListView>
<!-- Reorder Buttons -->
<StackPanel Grid.Column="1" VerticalAlignment="Bottom" Margin="10,0,0,0">
<Button x:Name="btnMoveTop" Content="&#x2912;" Width="40" Height="40" FontSize="28" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Padding="0" Margin="0,0,0,5" ToolTip="Move selected application to the top" />
<Button x:Name="btnMoveUp" Content="&#x2191;" Width="40" Height="40" FontSize="28" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Padding="0" Margin="0,0,0,5" ToolTip="Move selected application up" />
<Button x:Name="btnMoveDown" Content="&#x2193;" Width="40" Height="40" FontSize="28" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Padding="0" Margin="0,0,0,5" ToolTip="Move selected application down" />
<Button x:Name="btnMoveBottom" Content="&#x2913;" Width="40" Height="40" FontSize="28" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Padding="0" ToolTip="Move selected application to the bottom" />
</StackPanel>
</Grid>
<!-- Save/Import/Clear Buttons -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left" Margin="0,0,0,10">
<Button x:Name="btnSaveBYOApplications" Content="Save UserAppList.json" Margin="0,0,10,0" Padding="10,5" ToolTip="Save application list to JSON file"/>
<Button x:Name="btnLoadBYOApplications" Content="Import UserAppList.json" Margin="0,0,10,0" Padding="10,5" ToolTip="Import application list from JSON file"/>
<Button x:Name="btnEditApplication" Content="Edit Application" IsEnabled="False" Margin="0,0,10,0" Padding="10,5" ToolTip="Edit the selected application's details"/>
<Button x:Name="btnCopyBYOApps" Content="Copy Apps" IsEnabled="False" Margin="0,0,10,0" Padding="10,5" ToolTip="Copy applications with a specified source path to the AppsPath\Win32 folder"/>
<Button x:Name="btnRemoveSelectedBYOApps" Content="Remove Selected" IsEnabled="False" Margin="0,0,10,0" Padding="10,5" ToolTip="Remove selected applications from the list"/>
<Button x:Name="btnClearBYOApplications" Content="Clear List" Padding="10,5" ToolTip="Clear all applications from the list"/>
</StackPanel>
</StackPanel>
<!-- AppsScriptVariables Section -->
<CheckBox x:Name="chkDefineAppsScriptVariables" Content="Define Apps Script Variables" Margin="5" ToolTip="Enable to define key-value pairs for Apps Script Variables"/>
<StackPanel x:Name="appsScriptVariablesPanel" Visibility="Collapsed" Margin="25,0,5,20">
<TextBlock Text="Apps Script Variables" FontWeight="Bold" Margin="0,5,0,10"/>
<!-- Key Input -->
<TextBlock Text="Key:" Margin="0,0,0,5"/>
<TextBox x:Name="txtAppsScriptKey" Margin="0,0,0,10" ToolTip="Enter the variable key"/>
<!-- Value Input -->
<TextBlock Text="Value:" Margin="0,0,0,5"/>
<TextBox x:Name="txtAppsScriptValue" Margin="0,0,0,10" ToolTip="Enter the variable value"/>
<!-- Add Variable Button -->
<Button x:Name="btnAddAppsScriptVariable" Content="Add Variable" Width="120" HorizontalAlignment="Left" Margin="0,10,0,10" Padding="10,5" ToolTip="Add the key-value pair to the list"/>
<!-- ListView for AppsScriptVariables -->
<ListView x:Name="lstAppsScriptVariables" Height="150" Margin="0,0,0,10">
<ListView.View>
<GridView>
<GridViewColumn Header="Key" DisplayMemberBinding="{Binding Key}" Width="200"/>
<GridViewColumn Header="Value" DisplayMemberBinding="{Binding Value}" Width="300"/>
</GridView>
</ListView.View>
</ListView>
<!-- Action Buttons for ListView -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Left">
<Button x:Name="btnRemoveSelectedAppsScriptVariables" Content="Remove Selected" Margin="0,0,10,0" Padding="10,5" ToolTip="Remove the selected variable(s) from the list"/>
<Button x:Name="btnClearAppsScriptVariables" Content="Clear All" Padding="10,5" ToolTip="Clear all variables from the list"/>
</StackPanel>
</StackPanel>
<!-- End AppsScriptVariables Section -->
</StackPanel>
</Grid>
</ScrollViewer>
</TabItem>
<!-- TAB: M365 Apps/Office -->
<TabItem Header="M365 Apps/Office" Padding="20">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="250"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Row="0" Grid.Column="0" Orientation="Horizontal" Margin="0,5">
<CheckBox x:Name="chkInstallOffice" Content="Install Office" Margin="0,0,5,0" ToolTip="Install Microsoft Office if set to $true. The script will download the latest ODT and Office files in the $FFUDevelopmentPath\Apps\Office folder and install Office in the FFU via VM."/>
</StackPanel>
<StackPanel x:Name="OfficePathStackPanel" Grid.Row="1" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5">
<TextBlock Text="Office Path" ToolTip="Path to the Office directory that contains the DownloadFFU.xml and DeployFFU.xml files. This is where Office will be downloaded to from the ODT."/>
</StackPanel>
<Grid x:Name="OfficePathGrid" Grid.Row="1" Grid.Column="1" Margin="5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="txtOfficePath" Grid.Column="0" VerticalAlignment="Center" HorizontalAlignment="Stretch" ToolTip="Path to the Office directory that contains the DownloadFFU.xml and DeployFFU.xml files. This is where Office will be downloaded to from the ODT."/>
<Button x:Name="btnBrowseOfficePath" Grid.Column="1" Content="Browse..." Width="80" VerticalAlignment="Center"/>
</Grid>
<StackPanel x:Name="CopyOfficeConfigXMLStackPanel" Grid.Row="2" Grid.Column="0" Orientation="Horizontal" Margin="0,5">
<CheckBox x:Name="chkCopyOfficeConfigXML" Content="Copy Office Configuration XML" Margin="0,0,5,0" ToolTip="Enable to copy an Office configuration XML file to the Office folder."/>
</StackPanel>
<StackPanel x:Name="OfficeConfigurationXMLFileStackPanel" Grid.Row="3" Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,5" Visibility="Collapsed">
<TextBlock Text="Office Configuration XML File" ToolTip="Specify the path to an Office configuration XML file. This file will be copied into the Office folder and used for the deployment of Office."/>
</StackPanel>
<Grid x:Name="OfficeConfigurationXMLFileGrid" Grid.Row="3" Grid.Column="1" Margin="5" Visibility="Collapsed">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="txtOfficeConfigXMLFilePath" Grid.Column="0" VerticalAlignment="Center" HorizontalAlignment="Stretch" ToolTip="Specify the path to the Office configuration XML file."/>
<Button x:Name="btnBrowseOfficeConfigXMLFile" Grid.Column="1" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
</Grid>
</Grid>
</ScrollViewer>
</TabItem>
<!-- TAB: Drivers -->
<TabItem Header="Drivers" Padding="20">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<!-- Drivers Folder -->
<RowDefinition Height="Auto"/>
<!-- PE Drivers Folder -->
<RowDefinition Height="Auto"/>
<!-- Drivers.json Path -->
<RowDefinition Height="Auto"/>
<!-- Download Drivers Checkbox -->
<RowDefinition Height="Auto"/>
<!-- Make Section (Indented) -->
<RowDefinition Height="Auto"/>
<!-- Get Models Button (Indented) -->
<RowDefinition Height="Auto"/>
<!-- Model Filter Section (Indented) -->
<RowDefinition Height="Auto"/>
<!-- Driver Models ListView (Indented) -->
<RowDefinition Height="Auto"/>
<!-- Driver Action Buttons (Indented) -->
<RowDefinition Height="Auto"/>
<!-- Install Drivers to FFU -->
<RowDefinition Height="Auto"/>
<!-- Copy Drivers to USB -->
<RowDefinition Height="Auto"/>
<!-- Compress Driver Model Folder to WIM -->
<RowDefinition Height="*"/>
<!-- Copy PE Drivers Checkbox & Spacer/Remaining -->
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<!-- Span full width for StackPanels -->
</Grid.ColumnDefinitions>
<!-- Row 0: Drivers Folder -->
<StackPanel Grid.Row="0" Margin="5">
<TextBlock Text="Drivers Folder:" VerticalAlignment="Center" ToolTip="Path to the drivers folder. Default is $FFUDevelopmentPath\Drivers." Margin="0,0,0,5"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="txtDriversFolder" Grid.Column="0" VerticalAlignment="Center" HorizontalAlignment="Stretch" ToolTip="Path to the drivers folder. Default is $FFUDevelopmentPath\Drivers."/>
<Button x:Name="btnBrowseDriversFolder" Grid.Column="1" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
</Grid>
</StackPanel>
<!-- Row 1: PE Drivers Folder -->
<StackPanel Grid.Row="1" Margin="5">
<TextBlock Text="PE Drivers Folder:" VerticalAlignment="Center" ToolTip="Path to the PE drivers folder. Default is $FFUDevelopmentPath\PEDrivers." Margin="0,0,0,5"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="txtPEDriversFolder" Grid.Column="0" VerticalAlignment="Center" HorizontalAlignment="Stretch" ToolTip="Path to the PE drivers folder. Default is $FFUDevelopmentPath\PEDrivers."/>
<Button x:Name="btnBrowsePEDriversFolder" Grid.Column="1" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
</Grid>
</StackPanel>
<!-- Row 2: Drivers.json Path -->
<StackPanel Grid.Row="2" Margin="5">
<TextBlock Text="Drivers.json Path:" VerticalAlignment="Center" ToolTip="Path to the Drivers.json file. Default is $FFUDevelopmentPath\Drivers\Drivers.json." Margin="0,0,0,5"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="txtDriversJsonPath" Grid.Column="0" VerticalAlignment="Center" HorizontalAlignment="Stretch" ToolTip="Path to the Drivers.json file. Default is $FFUDevelopmentPath\Drivers\Drivers.json."/>
<Button x:Name="btnBrowseDriversJsonPath" Grid.Column="1" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
</Grid>
</StackPanel>
<!-- Row 3: Download Drivers Checkbox -->
<StackPanel Grid.Row="3" Orientation="Horizontal" Margin="5">
<CheckBox x:Name="chkDownloadDrivers" Content="Download Drivers" Margin="0,0,5,0" ToolTip="Download the drivers and put them in the Drivers folder."/>
</StackPanel>
<!-- Row 4: Make Section (Indented) -->
<StackPanel x:Name="spMakeSection" Grid.Row="4" Visibility="Collapsed" Margin="25,5,5,0">
<TextBlock Text="Make:" Margin="0,0,0,5" ToolTip="Make of the device to download drivers. Accepted values are: 'Microsoft', 'Dell', 'HP', 'Lenovo'."/>
<ComboBox x:Name="cmbMake" Margin="0,0,0,5" HorizontalAlignment="Left" Width="200"/>
<!-- Model TextBox is removed from here, filtering will be done below -->
</StackPanel>
<!-- Row 5: Get Models Button (Indented) -->
<Button x:Name="btnGetModels" Grid.Row="5" Content="Get Models" Width="150" Margin="25,5,5,10" HorizontalAlignment="Left" ToolTip="Retrieve available models for the selected Make." Visibility="Collapsed" Padding="10,5"/>
<!-- Row 6: Model Filter Section (Indented) -->
<StackPanel x:Name="spModelFilterSection" Grid.Row="6" Visibility="Collapsed" Margin="25,5,5,0">
<TextBlock Text="Model Filter" FontWeight="Bold" Margin="0,0,0,5"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<!-- Removed Search button column -->
</Grid.ColumnDefinitions>
<TextBox x:Name="txtModelFilter" Grid.Column="0" Margin="0,0,0,10" Height="24" VerticalContentAlignment="Center" ToolTip="Type to filter models in the list below"/>
<!-- Search button removed, filtering is real-time -->
</Grid>
</StackPanel>
<!-- Row 7: Driver Models ListView (Indented) -->
<ListView x:Name="lstDriverModels" Grid.Row="7" Margin="25,0,5,5" Height="300" Visibility="Collapsed" ScrollViewer.VerticalScrollBarVisibility="Auto" ScrollViewer.HorizontalScrollBarVisibility="Auto">
</ListView>
<!-- Row 8: Driver Action Buttons (Indented) -->
<StackPanel x:Name="spDriverActionButtons" Grid.Row="8" Orientation="Horizontal" HorizontalAlignment="Left" Margin="25,5,5,10" Visibility="Collapsed">
<Button x:Name="btnSaveDriversJson" Content="Save Drivers.json" Padding="15,5" Margin="0,0,10,0" ToolTip="Save selected drivers to a JSON file (Not Implemented)"/>
<Button x:Name="btnImportDriversJson" Content="Import Drivers.json" Padding="15,5" Margin="0,0,10,0" ToolTip="Import drivers from a JSON file (Not Implemented)"/>
<Button x:Name="btnDownloadSelectedDrivers" Content="Download Selected" Padding="15,5" Margin="0,0,10,0" ToolTip="Download all selected drivers"/>
<Button x:Name="btnClearDriverList" Content="Clear List" Padding="15,5" ToolTip="Clear all drivers from the list (Not Implemented)"/>
</StackPanel>
<!-- Row 9: Install Drivers to FFU -->
<StackPanel Grid.Row="9" Orientation="Horizontal" Margin="5">
<CheckBox x:Name="chkInstallDrivers" Content="Install Drivers to FFU" Margin="0,0,5,0" ToolTip="Install device drivers from the specified $FFUDevelopmentPath\Drivers folder if set to $true. Download the drivers and put them in the Drivers folder. The script will recurse the drivers folder and add the drivers to the FFU."/>
</StackPanel>
<!-- Row 10: Copy Drivers to USB -->
<StackPanel Grid.Row="10" Orientation="Horizontal" Margin="5">
<CheckBox x:Name="chkCopyDrivers" Content="Copy Drivers to USB drive" Margin="0,0,5,0" ToolTip="When set to $true, will copy the drivers from the $FFUDevelopmentPath\Drivers folder to the Drivers folder on the deploy partition of the USB drive. Default is $false."/>
</StackPanel>
<!-- Row 11: Compress Driver Model Folder to WIM -->
<StackPanel Grid.Row="11" Orientation="Horizontal" Margin="5">
<CheckBox x:Name="chkCompressDriversToWIM" Content="Compress Driver Model Folder to WIM" Margin="0,0,5,0" ToolTip="When set to $true, will compress each downloaded driver model folder into a separate WIM file within the Drivers folder. This is useful with Copy Drivers to USB drive."/>
</StackPanel>
<!-- Row 12: PE Driver Options (UseDriversAsPEDrivers is a dependent sub-option) -->
<StackPanel Grid.Row="12" Margin="5">
<CheckBox x:Name="chkCopyPEDrivers" Content="Copy PE Drivers" Margin="0,0,0,5" ToolTip="When set to $true, will copy the drivers from the $FFUDevelopmentPath\PEDrivers folder to the WinPE deployment media. Default is $false."/>
<CheckBox x:Name="chkUseDriversAsPEDrivers" Content="Use Drivers Folder as PE Drivers Source" Margin="25,0,0,0" Visibility="Collapsed" ToolTip="When set to $true (and Copy PE Drivers is also checked), bypasses the PE Drivers Folder path and instead scans the Drivers folder to gather only required WinPE drivers. Hidden unless Copy PE Drivers is checked."/>
</StackPanel>
</Grid>
</ScrollViewer>
</TabItem>
<!-- TAB: Build -->
<TabItem Header="Build" Padding="20">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<Grid Margin="10">
<!-- Define 10 rows for the Build tab -->
<Grid.RowDefinitions>
<!-- Row 0: Header -->
<RowDefinition Height="Auto"/>
<!-- Row 1: FFU Development Path -->
<RowDefinition Height="Auto"/>
<!-- Row 2: Custom FFU Name Template -->
<RowDefinition Height="Auto"/>
<!-- Row 3: FFU Capture Location -->
<RowDefinition Height="Auto"/>
<!-- Row 4: Share Name -->
<RowDefinition Height="Auto"/>
<!-- Row 5: Username -->
<RowDefinition Height="Auto"/>
<!-- Row 6: Threads -->
<RowDefinition Height="Auto"/>
<!-- Row 7: General Build Options Header -->
<RowDefinition Height="Auto"/>
<!-- Row 8: General Build Options Checkboxes -->
<RowDefinition Height="Auto"/>
<!-- Row 9: Build USB Drive Section -->
<RowDefinition Height="Auto"/>
<!-- Row 10: Post-Build Cleanup -->
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Row 0: Header -->
<TextBlock Grid.Row="0" Text="FFU Build Settings" FontWeight="Bold" FontSize="16" Margin="0,0,0,10"/>
<!-- Row 1: FFU Development Path -->
<Grid Grid.Row="1" Margin="0,5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="FFU Development Path" VerticalAlignment="Center" ToolTip="Path to the FFU development folder."/>
<TextBox x:Name="txtFFUDevPath" Grid.Column="1" Margin="5" VerticalAlignment="Center" ToolTip="Path to the FFU development folder."/>
<Button x:Name="btnBrowseFFUDevPath" Grid.Column="2" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
</Grid>
<!-- Row 2: Custom FFU Name Template -->
<Grid Grid.Row="2" Margin="0,5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Custom FFU Name Template" VerticalAlignment="Center" ToolTip="Sets a custom FFU output name with placeholders. Allowed placeholders are: {WindowsRelease}, {WindowsVersion}, {SKU}, {BuildDate}, {yyyy}, {MM}, {dd}, {H}, {hh}, {mm}, {tt}."/>
<TextBox x:Name="txtCustomFFUNameTemplate" Grid.Column="1" Margin="5" VerticalAlignment="Center" ToolTip="Sets a custom FFU output name with placeholders. Allowed placeholders are: {WindowsRelease}, {WindowsVersion}, {SKU}, {BuildDate}, {yyyy}, {MM}, {dd}, {H}, {hh}, {mm}, {tt}."/>
</Grid>
<!-- Row 3: FFU Capture Location -->
<Grid Grid.Row="3" Margin="0,5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="FFU Capture Location" VerticalAlignment="Center" ToolTip="Path to the folder where the captured FFU will be stored."/>
<TextBox x:Name="txtFFUCaptureLocation" Grid.Column="1" Margin="5" VerticalAlignment="Center" ToolTip="Path to the folder where the captured FFU will be stored."/>
<Button x:Name="btnBrowseFFUCaptureLocation" Grid.Column="2" Content="Browse..." Width="80" Margin="5,0,0,0" VerticalAlignment="Center"/>
</Grid>
<!-- Row 4: Share Name -->
<Grid Grid.Row="4" Margin="0,5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Share Name" VerticalAlignment="Center" ToolTip="Name of the shared folder for FFU capture. The default is FFUCaptureShare. This share will be created with rights for the user account. When finished, the share will be removed."/>
<TextBox x:Name="txtShareName" Grid.Column="1" Margin="5" VerticalAlignment="Center" ToolTip="Name of the shared folder for FFU capture. The default is FFUCaptureShare. This share will be created with rights for the user account. When finished, the share will be removed."/>
</Grid>
<!-- Row 5: Username -->
<Grid Grid.Row="5" Margin="0,5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Username" VerticalAlignment="Center" ToolTip="Username for accessing the shared folder. The default is ffu_user. The script will auto-create the account and password. When finished, it will remove the account."/>
<TextBox x:Name="txtUsername" Grid.Column="1" Margin="5" VerticalAlignment="Center" ToolTip="Username for accessing the shared folder. The default is ffu_user. The script will auto-create the account and password. When finished, it will remove the account."/>
</Grid>
<!-- Row 6: Threads -->
<Grid Grid.Row="6" Margin="0,5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Threads" VerticalAlignment="Center" ToolTip="Controls the number of parallel threads used by ForEach-Object -Parallel and sets the value of the -ThrottleLimit parameter. Default is 5. Used in Winget, Application Copy, and driver downloads"/>
<TextBox x:Name="txtThreads" Grid.Column="1" Margin="5" VerticalAlignment="Center" Width="50" HorizontalAlignment="Left" Text="5" ToolTip="Controls the number of parallel threads used by ForEach-Object -Parallel and sets the value of the -ThrottleLimit parameter. Default is 5. Used in Winget, Application Copy, and driver downloads"/>
</Grid>
<!-- Row 7: General Build Options Header -->
<TextBlock Grid.Row="7" Text="General Build Options" FontWeight="Bold" FontSize="16" Margin="0,10,0,5"/>
<!-- Row 8: General Build Options Checkboxes -->
<WrapPanel Grid.Row="8" Margin="0,5">
<CheckBox x:Name="chkBuildUSBDriveEnable" Content="Build USB Drive" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will partition and format a USB drive and copy the captured FFU to the drive."/>
<CheckBox x:Name="chkCompactOS" Content="Compact OS" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will compact the OS when building the FFU."/>
<CheckBox x:Name="chkUpdateADK" Content="Update ADK" Margin="5" VerticalAlignment="Center" Tag="When set to $true, the script will check for and install/update to the latest Windows ADK and WinPE add-on."/>
<CheckBox x:Name="chkOptimize" Content="Optimize" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will optimize the OS when building the FFU."/>
<CheckBox x:Name="chkAllowVHDXCaching" Content="Allow VHDX Caching" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will cache the VHDX file to cache folder and create a config json file to track Windows build information."/>
<CheckBox x:Name="chkCreateCaptureMedia" Content="Create Capture Media" Margin="5" VerticalAlignment="Center" Tag="When set to $true, this will create WinPE capture media for use when InstallApps is set to $true."/>
<CheckBox x:Name="chkCreateDeploymentMedia" Content="Create Deployment Media" Margin="5" VerticalAlignment="Center" Tag="When set to $true, this will create WinPE deployment media for use when deploying to a physical device."/>
<CheckBox x:Name="chkInjectUnattend" Content="Inject Unattend.xml" Margin="5" VerticalAlignment="Center" Tag="When set to $true and Install Apps is enabled, copies unattend_[arch].xml from $FFUDevelopmentPath\unattend into Apps\Unattend\Unattend.xml to be used by sysprep."/>
<CheckBox x:Name="chkVerbose" Content="Verbose" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will enable write-verbose output to the console for the build script."/>
</WrapPanel>
<!-- Row 9: Build USB Drive Section -->
<StackPanel Grid.Row="9" Margin="0,10,0,5" x:Name="usbDriveSection" Visibility="Collapsed">
<TextBlock Text="Build USB Drive Settings" FontWeight="Bold" FontSize="16" Margin="0,0,0,10"/>
<StackPanel Margin="5,0,0,10">
<CheckBox x:Name="chkAllowExternalHardDiskMedia" Content="Allow External Hard Disk Media" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will allow the use of external hard disk media."/>
<CheckBox x:Name="chkPromptExternalHardDiskMedia" Content="Prompt for External Hard Disk Media" Margin="5,5,5,5" IsEnabled="False" VerticalAlignment="Center" Tag="When set to $true, will prompt before using external hard disk media."/>
<CheckBox x:Name="chkSelectSpecificUSBDrives" Content="Select Specific USB Drives" Margin="5" VerticalAlignment="Center" Tag="Enable to select specific USB drives for building"/>
<!-- Added Missing Checkboxes -->
<CheckBox x:Name="chkCopyAutopilot" Content="Copy Autopilot Profile" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will copy the Autopilot profile to the USB drive."/>
<CheckBox x:Name="chkCopyUnattend" Content="Copy Unattend.xml" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will copy the Unattend.xml file to the USB drive."/>
<CheckBox x:Name="chkCopyPPKG" Content="Copy Provisioning Package" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will copy the provisioning package to the USB drive."/>
<CheckBox x:Name="chkCopyAdditionalFFUFiles" Content="Copy Additional FFU Files" Margin="5" VerticalAlignment="Center" Tag="When set to $true, allows selecting existing FFU files in the capture folder to also copy to the USB drive."/>
<!-- Additional FFU Selection Section -->
<Grid x:Name="additionalFFUPanel" Margin="5,0,0,10" Visibility="Collapsed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Header row -->
<DockPanel Grid.Row="0" Margin="0,5" LastChildFill="False">
<TextBlock Text="Additional FFU Files" DockPanel.Dock="Left" FontWeight="Bold" VerticalAlignment="Center" Margin="0,0,10,0"/>
<Button x:Name="btnRefreshAdditionalFFUs" Content="Refresh" DockPanel.Dock="Left" Padding="10,5" ToolTip="Refresh the list of FFU files from the capture folder"/>
</DockPanel>
<!-- ListView row -->
<ListView x:Name="lstAdditionalFFUs" Grid.Row="1" Margin="0,5" Height="150">
<ListView.View>
<GridView>
<GridViewColumn Header="FFU Name" DisplayMemberBinding="{Binding Name}" Width="300"/>
<GridViewColumn Header="Last Modified" DisplayMemberBinding="{Binding LastModified}" Width="200"/>
</GridView>
</ListView.View>
</ListView>
</Grid>
<!-- Max USB Drives -->
<StackPanel Orientation="Horizontal" Margin="5">
<TextBlock Text="Max USB Drives" VerticalAlignment="Center" ToolTip="Maximum number of USB drives to build at once. Enter 0 to process all discovered (or all selected) drives."/>
<TextBox x:Name="txtMaxUSBDrives" Width="50" Margin="10,0,0,0" Text="5" VerticalAlignment="Center" ToolTip="Maximum number of USB drives to build at once. Enter 0 to process all discovered (or all selected) drives."/>
</StackPanel>
<!-- USB Drive Selection Section -->
<Grid x:Name="usbDriveSelectionPanel" Margin="5,0,0,0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Button and Select All row -->
<DockPanel Grid.Row="0" Margin="0,5" LastChildFill="False">
<Button x:Name="btnCheckUSBDrives" Content="Check USB drives" DockPanel.Dock="Left" Padding="10,5"/>
</DockPanel>
<!-- ListView row -->
<ListView x:Name="lstUSBDrives" Grid.Row="1" Margin="0,5" Height="150">
<ListView.View>
<GridView>
<GridViewColumn Header="Model" DisplayMemberBinding="{Binding Model}" Width="200"/>
<GridViewColumn Header="Serial Number" DisplayMemberBinding="{Binding SerialNumber}" Width="150"/>
<GridViewColumn Header="Size (GB)" DisplayMemberBinding="{Binding Size}" Width="80"/>
</GridView>
</ListView.View>
</ListView>
</Grid>
</StackPanel>
</StackPanel>
<!-- Row 10: Post-Build Cleanup -->
<StackPanel Grid.Row="10" Margin="0,10,0,5">
<TextBlock Text="Post-Build Cleanup" FontWeight="Bold" FontSize="16" Margin="0,0,0,5"/>
<CheckBox x:Name="chkCleanupAppsISO" Content="Cleanup Apps ISO" Margin="5" VerticalAlignment="Center" Tag="Remove Apps ISO after FFU capture."/>
<CheckBox x:Name="chkCleanupCaptureISO" Content="Cleanup Capture ISO" Margin="5" VerticalAlignment="Center" Tag="Remove WinPE capture ISO after FFU capture."/>
<CheckBox x:Name="chkCleanupDeployISO" Content="Cleanup Deploy ISO" Margin="5" VerticalAlignment="Center" Tag="Remove WinPE deployment ISO after FFU capture."/>
<CheckBox x:Name="chkCleanupDrivers" Content="Cleanup Drivers" Margin="5" VerticalAlignment="Center" Tag="Remove drivers folder after FFU capture."/>
<CheckBox x:Name="chkRemoveFFU" Content="Remove FFU" Margin="5" VerticalAlignment="Center" Tag="Remove FFU after copying to USB drive."/>
<CheckBox x:Name="chkRemoveApps" Content="Remove Apps Folder Content" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will remove the application content in the Apps folder after the FFU has been captured."/>
<CheckBox x:Name="chkRemoveUpdates" Content="Remove Downloaded Update Files" Margin="5" VerticalAlignment="Center" Tag="When set to $true, will remove downloaded CU, .NET, MSRT, Defender, Edge, and OneDrive files after being applied/included."/>
</StackPanel>
</Grid>
</ScrollViewer>
</TabItem>
<!-- TAB: Monitor -->
<TabItem Header="Monitor" x:Name="MonitorTab" Padding="20">
<Grid Margin="10">
<ListBox x:Name="lstLogOutput" SelectionMode="Extended" VirtualizingStackPanel.IsVirtualizing="True" VirtualizingStackPanel.VirtualizationMode="Recycling" ScrollViewer.VerticalScrollBarVisibility="Auto" ScrollViewer.HorizontalScrollBarVisibility="Auto" HorizontalContentAlignment="Stretch"/>
</Grid>
</TabItem>
</TabControl>
<!-- Progress Bar -->
<ProgressBar x:Name="progressBar" Height="20" Margin="0,10,0,0" Grid.Row="1" Visibility="Collapsed"/>
<!-- Status Text -->
<TextBlock x:Name="txtStatus" Grid.Row="2" Margin="0,5,0,0"/>
<!-- Buttons (Build Config File / Load Config File / Build FFU) -->
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,0,20,20">
<Button x:Name="btnRestoreDefaults" Content="Restore Defaults" Width="150" Margin="0,0,10,0" FontSize="14" Padding="10,5"/>
<Button x:Name="btnBuildConfig" Content="Save Config File" Width="150" Margin="0,0,10,0" FontSize="14" Padding="10,5"/>
<Button x:Name="btnLoadConfig" Content="Load Config File" Width="150" Margin="0,0,10,0" FontSize="14" Padding="10,5"/>
<Button x:Name="btnRun" Content="Build FFU" Width="120" FontSize="14" Padding="10,5"/>
</StackPanel>
</Grid>
</Window>
Binary file not shown.
+128
View File
@@ -0,0 +1,128 @@
```mermaid
graph TD
subgraph "Start & Initialization"
A[Start] --> B{Load ConfigFile?};
B --> C[Process Parameters];
C --> D{Validate Parameters};
D --> E{"dirty.txt exists?"};
E -- Yes --> F[Run Cleanup Routine];
F --> G["Create new dirty.txt"];
E -- No --> G;
end
G --> H{"-InstallDrivers or -CopyDrivers?"};
subgraph "Pre-Build Preparations"
H -- Yes --> I{Driver Source?};
I -- "-DriversJsonPath" --> J[Download Drivers via JSON in Parallel];
I -- "-Make and -Model" --> K[Download Drivers for specific Make/Model];
I -- "Local Folder" --> L[Use Existing Drivers in Drivers Folder];
subgraph "ADK & WinPE"
M[Check for ADK & WinPE Add-on];
M --> N{Latest Version Installed?};
N -- No --> O[Uninstall Old & Install Latest ADK/WinPE];
N -- Yes --> P[Get ADK Path];
O --> P;
end
Q{"-InstallApps?"};
subgraph "Application & In-VM Content Preparation"
direction LR
R[Check for existing downloaded apps];
R --> S{Download missing WinGet apps};
S --> T{"-InstallOffice?"};
T -- Yes --> U[Download ODT & Office content];
T -- No --> V[Continue];
U --> V;
V --> W["Download in-VM updates: Defender, MSRT, etc."];
W --> X["Create Apps.iso"];
end
end
J --> M;
K --> M;
L --> M;
H -- No --> M;
P --> Q;
Q -- Yes --> R;
X --> Y;
Q -- No --> Y{"-AllowVHDXCaching?"};
subgraph "VHDX Management"
Y -- Yes --> Z[Check for matching cached VHDX];
Z --> AA{Cache Hit?};
AA -- Yes --> AB[Use Cached VHDX];
AA -- No --> AC[Create New VHDX];
Y -- No --> AC;
subgraph "VHDX Creation Workflow"
AC --> AD{ISOPath provided?};
AD -- No --> AE[Download Windows ESD media];
AD -- Yes --> AF[Use provided ISO];
AE --> AG[Create & Partition VHDX];
AF --> AG;
AG --> AH[Apply Base Windows Image to VHDX];
AH --> AI{"Updates specified? (CU, dotNET, etc.)"};
AI -- Yes --> AJ[Apply Updates to Offline VHDX];
AJ --> AK[Run Component Cleanup];
AI -- No --> AK;
AK --> AL{"Optional Features specified?"};
AL -- Yes --> AM[Enable Optional Features];
AL -- No --> AN[Finalize VHDX Setup];
AM --> AN;
AN --> AO{"-AllowVHDXCaching?"};
AO -- Yes --> AP[Optimize and Copy VHDX to Cache];
AO -- No --> AQ[Continue];
AP --> AQ;
end
end
AB --> BA;
AQ --> BA{"-InstallApps?"};
subgraph "FFU Creation"
subgraph "VM-Based Capture (-InstallApps)"
direction LR
BB[Create Hyper-V VM from VHDX];
BB --> BC["Create WinPE Capture Media iso"];
BC --> BD[Configure network share for capture];
BD --> BE["Start VM: Boots to Audit Mode"];
BE --> BF[Orchestrator runs: Installs apps, syspreps, shuts down];
BF --> BG[VM reboots from Capture Media];
BG --> BH["CaptureFFU.ps1 runs, saves FFU to share, shuts down"];
end
subgraph "Direct VHDX Capture"
BI[Capture FFU directly from VHDX using DISM];
end
end
BA -- Yes --> BB;
BA -- No --> BI;
subgraph "Post-Processing & Media Creation"
BK{"-InstallDrivers?"};
BK -- Yes --> BL[Mount FFU & Inject Drivers];
BK -- No --> BM[Continue];
BL --> BM;
BM --> BN{"-Optimize?"};
BN -- Yes --> BO[Optimize FFU using DISM];
BN -- No --> BP[Continue];
BO --> BP;
BP --> BQ{"-BuildUSBDrive?"};
BQ -- Yes --> BR[Create WinPE Deployment Media];
BR --> BS["Partition USB Drive(s)"];
BS --> BT[Copy FFU, Deploy scripts & other assets to USB];
BQ -- No --> BU[Continue];
BT --> BU;
end
BH --> BK;
BI --> BK;
subgraph "Final Cleanup"
BU --> BV[Cleanup VM, VHDX, temp files];
BV --> BW["Remove dirty.txt"];
BW --> BX[End];
end
@@ -0,0 +1,109 @@
# Provides shared cleanup functionality for both UI and build script.
function Invoke-FFUPostBuildCleanup {
param(
[string]$RootPath,
[string]$AppsPath,
[string]$DriversPath,
[string]$FFUCapturePath,
[string]$CaptureISOPath,
[string]$DeployISOPath,
[string]$AppsISOPath,
[bool]$RemoveCaptureISO = $false,
[bool]$RemoveDeployISO = $false,
[bool]$RemoveAppsISO = $false,
[bool]$RemoveDrivers = $false,
[bool]$RemoveFFU = $false,
[bool]$RemoveApps = $false,
[bool]$RemoveUpdates = $false
)
$originalProgressPreference = $ProgressPreference
$ProgressPreference = 'SilentlyContinue'
try {
WriteLog "CommonCleanup: Starting cleanup (CaptureISO=$RemoveCaptureISO DeployISO=$RemoveDeployISO AppsISO=$RemoveAppsISO Drivers=$RemoveDrivers FFU=$RemoveFFU Apps=$RemoveApps Updates=$RemoveUpdates)."
# Primary ISO paths (new naming/location)
if ($RemoveCaptureISO -and -not [string]::IsNullOrWhiteSpace($CaptureISOPath) -and (Test-Path -LiteralPath $CaptureISOPath)) {
WriteLog "CommonCleanup: Removing $CaptureISOPath"
try { Remove-Item -LiteralPath $CaptureISOPath -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $CaptureISOPath : $($_.Exception.Message)" }
}
if ($RemoveDeployISO -and -not [string]::IsNullOrWhiteSpace($DeployISOPath) -and (Test-Path -LiteralPath $DeployISOPath)) {
WriteLog "CommonCleanup: Removing $DeployISOPath"
try { Remove-Item -LiteralPath $DeployISOPath -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $DeployISOPath : $($_.Exception.Message)" }
}
if ($RemoveAppsISO -and -not [string]::IsNullOrWhiteSpace($AppsISOPath) -and (Test-Path -LiteralPath $AppsISOPath)) {
WriteLog "CommonCleanup: Removing $AppsISOPath"
try { Remove-Item -LiteralPath $AppsISOPath -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $AppsISOPath : $($_.Exception.Message)" }
}
# Legacy / root-level WinPE ISOs (pattern-based)
if ($RemoveCaptureISO) {
Get-ChildItem -LiteralPath $RootPath -Filter 'WinPE_FFU_Capture*.iso' -ErrorAction SilentlyContinue | ForEach-Object {
try { WriteLog "CommonCleanup: Removing legacy capture ISO $($_.FullName)"; Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing legacy capture ISO $($_.FullName) : $($_.Exception.Message)" }
}
}
if ($RemoveDeployISO) {
Get-ChildItem -LiteralPath $RootPath -Filter 'WinPE_FFU_Deploy*.iso' -ErrorAction SilentlyContinue | ForEach-Object {
try { WriteLog "CommonCleanup: Removing legacy deploy ISO $($_.FullName)"; Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing legacy deploy ISO $($_.FullName) : $($_.Exception.Message)" }
}
}
if ($RemoveDrivers -and -not [string]::IsNullOrWhiteSpace($DriversPath) -and (Test-Path -LiteralPath $DriversPath -PathType Container)) {
WriteLog "CommonCleanup: Removing contents of $DriversPath"
try { Get-ChildItem -LiteralPath $DriversPath -Force -ErrorAction SilentlyContinue | 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) {
WriteLog "CommonCleanup: Cleaning Office artifacts"
$officeSub = Join-Path $office 'Office'
if (Test-Path -LiteralPath $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) {
try { Remove-Item -LiteralPath $setupExe -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $setupExe : $($_.Exception.Message)" }
}
}
}
if ($RemoveUpdates -and -not [string]::IsNullOrWhiteSpace($AppsPath) -and (Test-Path -LiteralPath $AppsPath)) {
$updateDirs = @('Defender', 'Edge', 'MSRT', 'OneDrive', '.NET', 'CU', 'Microcode')
foreach ($d in $updateDirs) {
$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)" }
}
}
}
WriteLog "CommonCleanup: Completed."
}
catch {
WriteLog "CommonCleanup: Fatal cleanup error $($_.Exception.Message)"
}
finally {
$ProgressPreference = $originalProgressPreference
}
}
Export-ModuleMember -Function Invoke-FFUPostBuildCleanup
@@ -0,0 +1,215 @@
<#
.SYNOPSIS
Provides core, shared functions for logging, process execution, and resilient file transfers used across the FFU project.
.DESCRIPTION
This module is a central component of the FFU project, offering a set of robust, reusable functions.
It includes a centralized logging mechanism (WriteLog), a wrapper for running external processes with error handling (Invoke-Process),
a retry-aware BITS transfer function for reliable downloads (Start-BitsTransferWithRetry), and a progress reporting helper.
This module is designed to be imported by other scripts and modules within the project to ensure consistent behavior for common tasks.
#>
# Script-scoped variable for the log file path
$script:CommonCoreLogFilePath = $null
# Mutex for log file access
$script:commonCoreLogMutexName = "Global\FFUCommonCoreLogMutex" # Unique name
$script:commonCoreLogMutex = New-Object System.Threading.Mutex($false, $script:commonCoreLogMutexName)
# Function to set the log file path for this module
function Set-CommonCoreLogPath {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$script:CommonCoreLogFilePath = $Path
if (-not [string]::IsNullOrWhiteSpace($script:CommonCoreLogFilePath)) {
# This initial WriteLog confirms the path is set and the logger is working.
WriteLog "CommonCoreLogPath set to: $script:CommonCoreLogFilePath"
}
else {
# This Write-Warning will appear on console if path is bad, but won't go to log file yet.
Write-Warning "Set-CommonCoreLogPath called with an empty or null path."
}
}
# Centralized WriteLog function
function WriteLog {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$LogText
)
# Check if the log file path has been set
if ([string]::IsNullOrWhiteSpace($script:CommonCoreLogFilePath)) {
Write-Warning "CommonCoreLogFilePath not set. Message: $LogText"
return
}
$logEntry = "$((Get-Date).ToString()) $LogText"
$streamWriter = $null
try {
$script:commonCoreLogMutex.WaitOne() | Out-Null
# Ensure directory exists before writing
$logDir = Split-Path -Path $script:CommonCoreLogFilePath -Parent
if (-not (Test-Path -Path $logDir -PathType Container)) {
New-Item -Path $logDir -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
}
$streamWriter = New-Object System.IO.StreamWriter($script:CommonCoreLogFilePath, $true, [System.Text.Encoding]::UTF8)
$streamWriter.WriteLine($logEntry)
Write-Verbose $LogText
}
catch {
# Use Write-Host for console visibility as Write-Warning might also try to log
Write-Host "WARNING: Error writing to log file '$($script:CommonCoreLogFilePath)': $($_.Exception.Message)" -ForegroundColor Yellow
}
finally {
if ($null -ne $streamWriter) {
$streamWriter.Dispose()
}
$script:commonCoreLogMutex.ReleaseMutex()
}
}
function Invoke-Process {
[CmdletBinding(SupportsShouldProcess)]
param
(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$FilePath,
[Parameter()]
[ValidateNotNullOrEmpty()]
[string[]]$ArgumentList,
[Parameter()]
[ValidateNotNullOrEmpty()]
[bool]$Wait = $true
)
$ErrorActionPreference = 'Stop'
try {
$stdOutTempFile = "$env:TEMP\$((New-Guid).Guid)"
$stdErrTempFile = "$env:TEMP\$((New-Guid).Guid)"
$startProcessParams = @{
FilePath = $FilePath
ArgumentList = $ArgumentList
RedirectStandardError = $stdErrTempFile
RedirectStandardOutput = $stdOutTempFile
Wait = $($Wait);
PassThru = $true;
NoNewWindow = $true;
}
if ($PSCmdlet.ShouldProcess("Process [$($FilePath)]", "Run with args: [$($ArgumentList)]")) {
$cmd = Start-Process @startProcessParams
$cmdOutput = Get-Content -Path $stdOutTempFile -Raw
$cmdError = Get-Content -Path $stdErrTempFile -Raw
if ($cmd.ExitCode -ne 0 -and $wait -eq $true) {
if ($cmdError) {
throw $cmdError.Trim()
}
if ($cmdOutput) {
throw $cmdOutput.Trim()
}
}
else {
if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) {
WriteLog $cmdOutput
}
}
}
}
catch {
#$PSCmdlet.ThrowTerminatingError($_)
WriteLog $_
# Write-Host "Script failed - $Logfile for more info"
throw $_
}
finally {
Remove-Item -Path $stdOutTempFile, $stdErrTempFile -Force -ErrorAction Ignore
}
return $cmd
}
# Function to download a file using BITS with retry and error handling
function Start-BitsTransferWithRetry {
param (
[Parameter(Mandatory = $true)]
[string]$Source,
[Parameter(Mandatory = $true)]
[string]$Destination,
[int]$Retries = 3
)
$attempt = 0
$lastError = $null
while ($attempt -lt $Retries) {
$OriginalVerbosePreference = $VerbosePreference
$OriginalProgressPreference = $ProgressPreference
try {
$VerbosePreference = 'SilentlyContinue'
$ProgressPreference = 'SilentlyContinue'
Start-BitsTransfer -Source $Source -Destination $Destination -ErrorAction Stop
$ProgressPreference = $OriginalProgressPreference
$VerbosePreference = $OriginalVerbosePreference
WriteLog "Successfully transferred $Source to $Destination."
return
}
catch {
$lastError = $_
$attempt++
WriteLog "Attempt $attempt of $Retries failed to download $Source. Error: $($lastError.Exception.Message)."
Start-Sleep -Seconds (1 * $attempt)
}
finally {
if (Get-Variable -Name 'OriginalProgressPreference' -ErrorAction SilentlyContinue) {
$ProgressPreference = $OriginalProgressPreference
}
if (Get-Variable -Name 'OriginalVerbosePreference' -ErrorAction SilentlyContinue) {
$VerbosePreference = $OriginalVerbosePreference
}
}
}
WriteLog "Failed to download $Source after $Retries attempts. Last Error: $($lastError.Exception.Message)"
throw $lastError
}
function Set-Progress {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[int]$Percentage,
[Parameter(Mandatory)]
[string]$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 *
@@ -0,0 +1,410 @@
<#
.SYNOPSIS
Provides common functions for driver management, including compression, mapping, and existence checks.
.DESCRIPTION
The FFU.Common.Drivers module contains a set of shared functions used across the FFU project for handling driver packages.
This includes compressing driver folders into WIM files for efficient storage and deployment, maintaining a JSON-based mapping
of downloaded drivers to their respective makes and models, and checking for the pre-existence of driver packages to avoid
redundant downloads.
#>
function Compress-DriverFolderToWim {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory = $true)]
[ValidateScript({ Test-Path -Path $_ -PathType Container })]
[string]$SourceFolderPath,
[Parameter(Mandatory = $true)]
[string]$DestinationWimPath,
[Parameter()]
[string]$WimName, # Optional, defaults to folder name
[Parameter()]
[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'."
# Default WIM Name and Description to the source folder name if not provided
$sourceFolderName = Split-Path -Path $SourceFolderPath -Leaf
if ([string]::IsNullOrWhiteSpace($WimName)) {
$WimName = $sourceFolderName
WriteLog "WIM Name not provided, defaulting to source folder name: '$WimName'."
}
if ([string]::IsNullOrWhiteSpace($WimDescription)) {
$WimDescription = $sourceFolderName
WriteLog "WIM Description not provided, defaulting to source folder name: '$WimDescription'."
}
# Ensure destination directory exists
$destinationDir = Split-Path -Path $DestinationWimPath -Parent
if (-not (Test-Path -Path $destinationDir -PathType Container)) {
WriteLog "Creating destination directory: $destinationDir"
try {
New-Item -Path $destinationDir -ItemType Directory -Force -ErrorAction Stop | Out-Null
}
catch {
WriteLog "Failed to create destination directory '$destinationDir': $($_.Exception.Message)"
return $false # Indicate failure
}
}
if ($PSCmdlet.ShouldProcess("Folder '$SourceFolderPath'", "Compress to WIM '$DestinationWimPath'")) {
try {
# Construct arguments for dism.exe
$dismArgs = "/Capture-Image /ImageFile:`"$DestinationWimPath`" /CaptureDir:`"$SourceFolderPath`" /Name:`"$WimName`" /Description:`"$WimDescription`" /Compress:Max /CheckIntegrity /Quiet"
WriteLog "Executing dism.exe via Invoke-Process with arguments:"
WriteLog "dism.exe $dismArgs"
# Call Invoke-Process (assumed to be available from FFUUI.Core.psm1 or another imported module)
# Invoke-Process is expected to throw an exception for non-zero exit codes.
Invoke-Process -FilePath "dism.exe" -ArgumentList $dismArgs -Wait $true
WriteLog "Successfully compressed '$SourceFolderPath' to '$DestinationWimPath' using dism.exe."
# Remove the source folder after successful compression
if ($PreserveSource) {
WriteLog "Preserving source driver folder for deferred WinPE driver harvesting: $SourceFolderPath"
try {
$markerFile = Join-Path -Path $SourceFolderPath -ChildPath '__PreservedForPEDrivers.txt'
if (-not (Test-Path -Path $markerFile -PathType Leaf)) {
New-Item -Path $markerFile -ItemType File -Force | Out-Null
WriteLog "Created preservation marker file: $markerFile"
}
}
catch {
WriteLog "Warning: Failed to create preservation marker in $SourceFolderPath. Error: $($_.Exception.Message)"
}
}
else {
WriteLog "Removing source driver folder: $SourceFolderPath"
try {
Remove-Item -Path $SourceFolderPath -Recurse -Force -ErrorAction Stop
WriteLog "Successfully removed source folder '$SourceFolderPath'."
}
catch {
WriteLog "Warning: Failed to remove source folder '$SourceFolderPath'. Error: $($_.Exception.Message)"
# Do not fail the whole operation, just log a warning.
}
}
return $true # Indicate success
}
catch {
WriteLog "Failed to compress folder '$SourceFolderPath' to WIM '$DestinationWimPath' using dism.exe."
WriteLog "Error details: $($_.Exception.Message)"
# Check if the error message contains details about the DISM log (dism.exe output might be in the exception)
if ($_.Exception.Message -match 'DISM log file can be found at (.*)') {
$dismLogPath = $matches[1].Trim()
WriteLog "Check the DISM log for more details: $dismLogPath"
}
return $false # Indicate failure
}
}
else {
WriteLog "Compression operation skipped due to -WhatIf."
return $false # Indicate skipped operation
}
}
# --------------------------------------------------------------------------
# SECTION: Driver Mapping Function
# --------------------------------------------------------------------------
function Update-DriverMappingJson {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[array]$DownloadedDrivers, # Array of PSCustomObjects with Make, Model, DriverPath
[Parameter(Mandatory = $true)]
[string]$DriversFolder # Base drivers folder (e.g., C:\FFUDevelopment\Drivers)
)
$mappingFilePath = Join-Path -Path $DriversFolder -ChildPath "DriverMapping.json"
WriteLog "Updating driver mapping file at: $mappingFilePath"
# Load existing mapping file or create a new list
$mappingList = [System.Collections.Generic.List[PSCustomObject]]::new()
if (Test-Path -Path $mappingFilePath -PathType Leaf) {
try {
$existingJson = Get-Content -Path $mappingFilePath -Raw | ConvertFrom-Json
# Ensure it's a collection before adding to the list
if ($existingJson -is [array]) {
# Iterate through the array to avoid type conversion issues with AddRange
foreach ($item in $existingJson) {
$mappingList.Add($item)
}
}
else {
$mappingList.Add($existingJson)
}
WriteLog "Loaded $($mappingList.Count) existing entries from $mappingFilePath"
}
catch {
WriteLog "Warning: Could not read or parse existing DriverMapping.json. A new file will be created. Error: $($_.Exception.Message)"
}
}
$updatedCount = 0
$addedCount = 0
foreach ($driver in $DownloadedDrivers) {
# Skip if any required property is missing or null
if (-not $driver.PSObject.Properties['Make'] -or -not $driver.PSObject.Properties['Model'] -or -not $driver.PSObject.Properties['DriverPath'] -or [string]::IsNullOrWhiteSpace($driver.DriverPath)) {
WriteLog "Skipping driver entry due to missing or empty Make, Model, or DriverPath. Details: $(($driver | ConvertTo-Json -Compress -Depth 3))"
continue
}
# Find existing entry
$existingEntry = $mappingList | Where-Object { $_.Manufacturer -eq $driver.Make -and $_.Model -eq $driver.Model } | Select-Object -First 1
if ($null -ne $existingEntry) {
# Update existing entry if the path is different
if ($existingEntry.DriverPath -ne $driver.DriverPath) {
WriteLog "Updating driver path for '$($driver.Make) - $($driver.Model)' from '$($existingEntry.DriverPath)' to '$($driver.DriverPath)'."
$existingEntry.DriverPath = $driver.DriverPath
$updatedCount++
}
}
else {
# Add new entry
$newEntry = [PSCustomObject]@{
Manufacturer = $driver.Make
Model = $driver.Model
DriverPath = $driver.DriverPath
}
$mappingList.Add($newEntry)
WriteLog "Adding new mapping for '$($driver.Make) - $($driver.Model)' with path '$($driver.DriverPath)'."
$addedCount++
}
}
if ($updatedCount -gt 0 -or $addedCount -gt 0) {
try {
# Sort the list for consistency before saving
$sortedList = $mappingList | Sort-Object -Property Manufacturer, Model
$sortedList | ConvertTo-Json -Depth 5 | Set-Content -Path $mappingFilePath -Encoding UTF8
WriteLog "Successfully saved DriverMapping.json with $addedCount new entries and $updatedCount updated entries."
}
catch {
WriteLog "Error saving updated DriverMapping.json: $($_.Exception.Message)"
throw "Failed to save driver mapping file."
}
}
else {
WriteLog "No changes needed for DriverMapping.json."
}
}
# --------------------------------------------------------------------------
# SECTION: Driver Existence Check Function
# --------------------------------------------------------------------------
function Test-ExistingDriver {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Make,
[Parameter(Mandatory = $true)]
[string]$Model,
[Parameter(Mandatory = $true)]
[string]$DriversFolder,
[Parameter(Mandatory = $true)]
[string]$Identifier,
[Parameter()]
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null
)
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $Make
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $Model
$driverRelativePath = Join-Path -Path $Make -ChildPath $Model
# Check for WIM file first
$wimFilePath = Join-Path -Path $makeDriversPath -ChildPath "$($Model).wim"
if (Test-Path -Path $wimFilePath -PathType Leaf) {
$status = "Already downloaded (WIM)"
WriteLog "Driver WIM for '$Identifier' already exists at '$wimFilePath'."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $Identifier -Status $status }
$wimRelativePath = Join-Path -Path $Make -ChildPath "$($Model).wim"
return [PSCustomObject]@{
Model = $Identifier # Return original identifier
Status = $status
Success = $true
DriverPath = $wimRelativePath
}
}
# Check for existing driver folder
if (Test-Path -Path $modelPath -PathType Container) {
$folderSize = (Get-ChildItem -Path $modelPath -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($folderSize -gt 1MB) {
$status = "Already downloaded"
WriteLog "Drivers for '$Identifier' already exist in '$modelPath'."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $Identifier -Status $status }
return [PSCustomObject]@{
Model = $Identifier # Return original identifier
Status = $status
Success = $true
DriverPath = $driverRelativePath
}
}
else {
WriteLog "Driver folder '$modelPath' for '$Identifier' exists but is empty or very small. Re-downloading."
}
}
# If neither WIM nor a valid folder exists, return null
return $null
}
function Get-LenovoPSREFToken {
<#
.DESCRIPTION
Retrieves the Lenovo PSREF token from the Edge browser's local storage.
.NOTES
Lenovo's PSREF site creates a cookie/token via javascript when navigating to the PSREF site. This cookie only needs
to be retrieved once on a single machine, and every machine within the same network will be able to access the PSREF API.
Using Invoke-Webrequest with sessionvariable or websession doesn't work because the token is created by javascript.
Using edge in headless mode with remote debugging enabled allows for the retrieval of the token via the DevTools protocol.
You couldn't be more unhappy about this solution than I am, but it works.
Why use PSREF and not catalogv2.xml? Catalogv2.xml doesn't include all models. PSREF provides an API that can be used to retrieve
the friendly model and machine type information for both business and consumer models. Many EDU devices are deemed consumer.
System Update and other tools rely on the user to input machine type and model information, but finding the machine type is difficult for some.
Our solution makes it easier to simply type the model name and you can match the machine type to the model name.
If you have a better solution, please submit a PR or open a discussion on Github. Happy to consider alternatives. An easy way to test
if your alternative works is to see if you can retrieve 100e, 300w, 500w, etc. These don't show up in catalogv2.xml, but they do in PSREF.
#>
# Path to Edge
$edgeExe = "$Env:ProgramFiles (x86)\Microsoft\Edge\Application\msedge.exe"
# Any free port works. 9222 is common.
$port = 9222
$uri = 'https://psref.lenovo.com'
# Headless run with remote debugging.
$flags = "--headless=new --disable-gpu --remote-debugging-port=$port $uri"
$edge = Start-Process -FilePath $edgeExe -ArgumentList $flags -PassThru
Writelog "Edge process started with PID: $($edge.Id)."
# Wait a short moment so the target appears.
Start-Sleep -Seconds 3
# Find the first page target.
$targets = Invoke-RestMethod "http://localhost:$port/json"
$wsUrl = ($targets | Where-Object type -eq 'page')[0].webSocketDebuggerUrl
# Connect to that WebSocket.
$socket = [System.Net.WebSockets.ClientWebSocket]::new()
$socket.ConnectAsync($wsUrl, [Threading.CancellationToken]::None).Wait()
# Helper to send a DevTools command.
function Send-DevToolsCommand {
param([int]$id, [string]$method, [hashtable]$params = @{})
$cmd = @{ id = $id; method = $method; params = $params } |
ConvertTo-Json -Compress
$data = [Text.Encoding]::UTF8.GetBytes($cmd)
$socket.SendAsync([ArraySegment[byte]]$data, 'Text', $true,
[Threading.CancellationToken]::None).Wait()
}
# Ask the page to return localStorage['asut'].
Send-DevToolsCommand -id 1 -method 'Runtime.evaluate' -params @{
expression = "localStorage.getItem('asut')"
}
# Receive frames until the whole message arrives.
$ms = New-Object System.IO.MemoryStream
$buf = New-Object byte[] 8192
do {
$seg = [ArraySegment[byte]]::new($buf)
$res = $socket.ReceiveAsync($seg,
[Threading.CancellationToken]::None).Result
$ms.Write($buf, 0, $res.Count)
} until ($res.EndOfMessage)
$ms.Position = 0
$json = ([System.IO.StreamReader]::new($ms, [Text.Encoding]::UTF8)).ReadToEnd() |
ConvertFrom-Json
$token = $json.result.result.value
# Concatenate the token value with X-PSREF-USER-TOKEN=
$token = "X-PSREF-USER-TOKEN=$token"
WriteLog "Retrieved Lenovo PSREF token: $token"
# Clean up.
$socket.Dispose()
if ($null -ne $socket) {
$socket.Dispose()
}
# Find the PID listening on the debugging port for reliable termination.
$listeningPid = $null
try {
# Find the process listening on the specific port. The regex now looks for the local address and port, followed by anything, then LISTENING.
# Dots are escaped for literal matching.
$netstatOutput = netstat -ano -p TCP | Where-Object { $_ -match "127\.0\.0\.1:$port.*LISTENING" }
if ($netstatOutput) {
# The last number in the line is the PID
$listeningPid = ($netstatOutput -split '\s+')[-1]
WriteLog "Found Edge process PID $listeningPid listening on port $port. This is the process we will terminate."
}
else {
WriteLog "Could not find any process listening on port $port."
}
}
catch {
WriteLog "Could not run netstat to find listening PID. Error: $($_.Exception.Message)"
}
# Determine the correct PID to kill. Prioritize the one found via netstat.
$pidToKill = $null
if ($listeningPid) {
$pidToKill = $listeningPid
}
elseif ($null -ne $edgeProcess -and -not $edgeProcess.HasExited) {
$pidToKill = $edgeProcess.Id
WriteLog "Could not find listening process via netstat. Falling back to initial Edge process PID $($pidToKill) for termination."
}
if ($pidToKill) {
WriteLog "Attempting to terminate Edge process tree with PID: $pidToKill"
try {
taskkill /PID $pidToKill /T /F | Out-Null
WriteLog "Successfully issued termination command for Edge process tree with PID: $pidToKill."
}
catch {
WriteLog "Failed to terminate Edge process tree with PID: $pidToKill. It may have already closed. Error: $($_.Exception.Message)"
}
}
else {
WriteLog "No active Edge process found to terminate."
}
return $token
}
# --------------------------------------------------------------------------
# SECTION: Module Export
# --------------------------------------------------------------------------
Export-ModuleMember -Function Compress-DriverFolderToWim, Update-DriverMappingJson, Test-ExistingDriver, Get-LenovoPSREFToken
@@ -0,0 +1,520 @@
<#
.SYNOPSIS
Manages and executes multiple background tasks in parallel, with support for updating a WPF UI with progress.
.DESCRIPTION
This function provides a generic framework for running tasks in parallel using PowerShell's ForEach-Object -Parallel.
It is designed to process an array of items, executing a specific task for each one. It can operate in two modes: UI mode and non-UI mode.
In UI mode, it updates a specified ListView control in a WPF window with the status of each item as it's being processed
(e.g., Queued, Downloading, Completed, Error). It uses a dispatcher to ensure UI updates are thread-safe.
In non-UI mode, it runs the tasks and logs the status to the FFUDevelopment.log file.
The function determines the task to run via the -TaskType parameter and passes necessary arguments using -TaskArguments.
It handles module imports and log file setup within each parallel runspace to ensure tasks have the necessary dependencies and logging capabilities.
.PARAMETER ItemsToProcess
An array of objects, where each object represents an item to be processed by a parallel task. This is a mandatory parameter.
.PARAMETER ListViewControl
(UI Mode) The WPF ListView control that the function will update with the status of each item. Defaults to $null.
.PARAMETER IdentifierProperty
The name of the property on the item objects that serves as a unique identifier (e.g., 'Name', 'Id').
This is used to find and update the correct row in the ListView.
.PARAMETER StatusProperty
The name of the property on the item objects that holds the status string. This property will be updated with progress messages.
.PARAMETER TaskType
A string specifying which task to execute for each item. This is mandatory.
Valid values are:
- 'WingetDownload': Downloads a Winget application.
- 'CopyBYO': Copies a user-provided application.
- 'DownloadDriverByMake': Downloads drivers for a specific manufacturer.
.PARAMETER TaskArguments
A hashtable containing arguments required by the specific task being run (e.g., paths, API keys, configuration settings).
.PARAMETER CompletedStatusText
The status text to display when an item is processed successfully.
.PARAMETER ErrorStatusPrefix
A prefix for status messages when an error occurs.
.PARAMETER WindowObject
(UI Mode) The main WPF Window object, used to access the UI dispatcher for safe UI updates from background threads.
.PARAMETER MainThreadLogPath
The file path for the log file that should be used by all parallel threads. This ensures consistent logging.
.PARAMETER ThrottleLimit
The maximum number of parallel jobs to run concurrently. The default is 5.
.NOTES
This function relies on ForEach-Object -Parallel, which was introduced in PowerShell 7.
When running in UI mode, both -WindowObject and -ListViewControl must be provided.
The function dynamically imports required modules ('FFU.Common' and 'FFUUI.Core') into each parallel runspace.
It uses a concurrent queue to manage intermediate progress updates from threads to the main UI thread, preventing UI blocking and providing more granular feedback.
#>
function Invoke-ParallelProcessing {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[array]$ItemsToProcess,
[Parameter(Mandatory = $false)]
[object]$ListViewControl = $null,
[Parameter(Mandatory = $false)]
[string]$IdentifierProperty = 'Identifier',
[Parameter(Mandatory = $false)]
[string]$StatusProperty = 'Status',
[Parameter(Mandatory)]
[ValidateSet('WingetDownload', 'CopyBYO', 'DownloadDriverByMake')]
[string]$TaskType,
[Parameter()]
[hashtable]$TaskArguments = @{},
[Parameter(Mandatory = $false)]
[string]$CompletedStatusText = "Completed",
[Parameter(Mandatory = $false)]
[string]$ErrorStatusPrefix = "Error: ",
[Parameter(Mandatory = $false)]
[object]$WindowObject = $null,
[Parameter(Mandatory = $false)]
[string]$MainThreadLogPath = $null,
[Parameter(Mandatory = $false)]
[int]$ThrottleLimit = 5
)
# Check if running in UI mode by verifying the types of the passed objects
$isUiMode = ($null -ne $WindowObject -and $WindowObject -is [System.Windows.Window] -and $null -ne $ListViewControl -and $ListViewControl -is [System.Windows.Controls.ListView])
if ($isUiMode) {
WriteLog "Invoke-ParallelProcessing started for $($ItemsToProcess.Count) items in ListView '$($ListViewControl.Name)'."
}
else {
WriteLog "Invoke-ParallelProcessing started for $($ItemsToProcess.Count) items (non-UI mode)."
}
$resultsCollection = [System.Collections.Generic.List[object]]::new()
$jobs = @()
$totalItems = $ItemsToProcess.Count
$processedCount = 0
$completedIdentifiers = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
# Create a thread-safe queue for intermediate progress updates
$progressQueue = New-Object System.Collections.Concurrent.ConcurrentQueue[hashtable]
# Define common paths locally within this function's scope
$coreModulePath = $MyInvocation.MyCommand.Module.Path
$coreModuleDirectory = Split-Path -Path $coreModulePath -Parent
$ffuDevelopmentRoot = Split-Path -Path $coreModuleDirectory -Parent
# Paths to the module DIRECTORIES needed by the parallel threads
$commonModulePathForJob = Join-Path -Path $ffuDevelopmentRoot -ChildPath "FFU.Common"
$uiCoreModulePathForJob = Join-Path -Path $ffuDevelopmentRoot -ChildPath "FFUUI.Core"
# Use the explicitly passed MainThreadLogPath for the parallel jobs.
# If not provided (e.g., older calls or direct module use without this param), it might be null.
# The parallel job's Set-CommonCoreLogPath will handle null/empty paths by warning.
$currentLogFilePathForJob = $MainThreadLogPath
$jobScopeVariables = $TaskArguments.Clone()
$jobScopeVariables['_commonModulePath'] = $commonModulePathForJob
$jobScopeVariables['_uiCoreModulePath'] = $uiCoreModulePathForJob
$jobScopeVariables['_currentLogFilePathForJob'] = $currentLogFilePathForJob
$jobScopeVariables['_progressQueue'] = $progressQueue
# Initial UI update needs to happen *before* starting the jobs
# Update all items to a static "Processing..." status
if ($isUiMode) {
# Use the new $isUiMode flag
foreach ($item in $ItemsToProcess) {
$identifierValue = $item.$IdentifierProperty
$initialStaticStatus = "Queued..."
try {
# Update the UI on the main thread to show the item is being queued for processing
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] {
Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $identifierValue -StatusProperty $StatusProperty -StatusValue $initialStaticStatus
})
}
catch {
WriteLog "Error setting initial status for item '$identifierValue': $($_.Exception.Message)"
}
}
}
# Queue items and start jobs using the pipeline and $using:
try {
# $jobScopeVariables and $TaskType are local here
# Inside the -Parallel scriptblock, we access them with $using:
$jobs = $ItemsToProcess | ForEach-Object -Parallel {
$currentItem = $_
$localJobArgs = $using:jobScopeVariables
$localTaskType = $using:TaskType
$localProgressQueue = $localJobArgs['_progressQueue']
# Initialize result hashtable
$taskResult = $null
$resultIdentifier = $null
$resultStatus = "Error: Task type '$localTaskType' not recognized"
$resultCode = 1 # Default to error
try {
# Import modules needed for the task
Import-Module $localJobArgs['_commonModulePath'] -Force
Import-Module $localJobArgs['_uiCoreModulePath'] -Force
# Set the log path for this parallel thread
Set-CommonCoreLogPath -Path $localJobArgs['_currentLogFilePathForJob']
# Execute the appropriate background task based on $localTaskType
switch ($localTaskType) {
'WingetDownload' {
# Pass the progress queue to the task function
$wingetTaskArgs = @{
ApplicationItemData = $currentItem
AppListJsonPath = $localJobArgs['AppListJsonPath']
AppsPath = $localJobArgs['AppsPath']
OrchestrationPath = $localJobArgs['OrchestrationPath']
ProgressQueue = $localProgressQueue
WindowsArch = $localJobArgs['WindowsArch']
}
$taskResult = Start-WingetAppDownloadTask @wingetTaskArgs
if ($null -ne $taskResult) {
$resultIdentifier = $taskResult.Id
$resultStatus = $taskResult.Status
$resultCode = $taskResult.ResultCode
}
else {
$resultIdentifier = $currentItem.Id # Fallback
$resultStatus = "Error: WingetDownload task returned null"
$resultCode = 1
WriteLog $resultStatus
}
}
'CopyBYO' {
# Pass the progress queue to the task function
$taskResult = Start-CopyBYOApplicationTask -ApplicationItemData $currentItem `
-AppsPath $localJobArgs['AppsPath'] `
-ProgressQueue $localProgressQueue
if ($null -ne $taskResult) {
$resultIdentifier = $taskResult.Name
$resultStatus = $taskResult.Status
$resultCode = if ($taskResult.Success) { 0 } else { 1 }
}
else {
$resultIdentifier = $currentItem.Name # Fallback
$resultStatus = "Error: CopyBYO task returned null"
$resultCode = 1
WriteLog $resultStatus
}
}
'DownloadDriverByMake' {
$make = $currentItem.Make
# Ensure $resultIdentifier is set before the switch, using the main IdentifierProperty
# This is crucial if a Make is unsupported or a task fails to return a result.
$resultIdentifier = $currentItem.$($using:IdentifierProperty)
switch ($make) {
'Microsoft' {
$taskResult = Save-MicrosoftDriversTask -DriverItemData $currentItem `
-DriversFolder $localJobArgs['DriversFolder'] `
-WindowsRelease $localJobArgs['WindowsRelease'] `
-Headers $localJobArgs['Headers'] `
-UserAgent $localJobArgs['UserAgent'] `
-ProgressQueue $localProgressQueue `
-CompressToWim $localJobArgs['CompressToWim'] `
-PreserveSourceOnCompress $localJobArgs['PreserveSourceOnCompress']
}
'Dell' {
$taskResult = Save-DellDriversTask -DriverItemData $currentItem `
-DriversFolder $localJobArgs['DriversFolder'] `
-WindowsArch $localJobArgs['WindowsArch'] `
-WindowsRelease $localJobArgs['WindowsRelease'] `
-ProgressQueue $localProgressQueue `
-CompressToWim $localJobArgs['CompressToWim'] `
-PreserveSourceOnCompress $localJobArgs['PreserveSourceOnCompress']
}
'HP' {
$taskResult = Save-HPDriversTask -DriverItemData $currentItem `
-DriversFolder $localJobArgs['DriversFolder'] `
-WindowsArch $localJobArgs['WindowsArch'] `
-WindowsRelease $localJobArgs['WindowsRelease'] `
-WindowsVersion $localJobArgs['WindowsVersion'] `
-ProgressQueue $localProgressQueue `
-CompressToWim $localJobArgs['CompressToWim'] `
-PreserveSourceOnCompress $localJobArgs['PreserveSourceOnCompress']
}
'Lenovo' {
$taskResult = Save-LenovoDriversTask -DriverItemData $currentItem `
-DriversFolder $localJobArgs['DriversFolder'] `
-WindowsRelease $localJobArgs['WindowsRelease'] `
-Headers $localJobArgs['Headers'] `
-UserAgent $localJobArgs['UserAgent'] `
-ProgressQueue $localProgressQueue `
-CompressToWim $localJobArgs['CompressToWim'] `
-PreserveSourceOnCompress $localJobArgs['PreserveSourceOnCompress']
}
default {
$unsupportedMakeMessage = "Error: Unsupported Make '$make' for driver download."
WriteLog $unsupportedMakeMessage
$resultStatus = $unsupportedMakeMessage
$resultCode = 1
# $resultIdentifier is already set from $currentItem.$($using:IdentifierProperty)
$localProgressQueue.Enqueue(@{ Identifier = $resultIdentifier; Status = $resultStatus })
# $taskResult remains null, handled below
}
}
# Consolidate result handling for 'DownloadDriverByMake'
if ($null -ne $taskResult) {
# $resultIdentifier is already $currentItem.$($using:IdentifierProperty)
# We use the task's returned Model/Identifier for logging/status if needed,
# but the primary identifier for UI updates should be consistent.
$taskSpecificIdentifier = $null
if ($taskResult.PSObject.Properties.Name -contains 'Model') { $taskSpecificIdentifier = $taskResult.Model }
elseif ($taskResult.PSObject.Properties.Name -contains 'Identifier') { $taskSpecificIdentifier = $taskResult.Identifier }
$resultStatus = $taskResult.Status
# Simplified success check. All driver tasks should now return a 'Success' property.
if ($taskResult.PSObject.Properties.Name -contains 'Success') {
$resultCode = if ($taskResult.Success) { 0 } else { 1 }
}
else {
# Fallback for any task that *still* doesn't return 'Success'. This is now the exceptional case.
WriteLog "Warning: Task for '$taskSpecificIdentifier' did not return a 'Success' property. Inferring from status: '$($taskResult.Status)'"
if ($taskResult.Status -like 'Completed*' -or $taskResult.Status -like 'Already downloaded*') {
$resultCode = 0 # Treat as success
}
else {
$resultCode = 1 # Treat as error
}
}
}
elseif ($make -in ('Microsoft', 'Dell', 'HP', 'Lenovo')) {
# This means a specific Make case was hit, but $taskResult was unexpectedly null
$nullTaskResultMessage = "Error: Task for Make '$make' returned null."
WriteLog $nullTaskResultMessage
$resultStatus = $nullTaskResultMessage
$resultCode = 1
}
}
Default {
# This handles unknown $localTaskType values
$resultStatus = "Error: Task type '$localTaskType' not recognized"
$resultCode = 1
if ($currentItem -is [pscustomobject] -and $currentItem.PSObject.Properties.Name -match $using:IdentifierProperty) {
$resultIdentifier = $currentItem.$($using:IdentifierProperty)
}
else {
$resultIdentifier = "UnknownItem"
}
WriteLog "Error in parallel job: Unknown TaskType '$localTaskType' provided for item '$resultIdentifier'."
}
}
}
catch {
$resultStatus = "Error: $($_.Exception.Message)"
$resultCode = 1
# Try to get an identifier
if ($currentItem -is [pscustomobject] -and $currentItem.PSObject.Properties.Name -match $using:IdentifierProperty) {
$resultIdentifier = $currentItem.$($using:IdentifierProperty)
}
else {
$resultIdentifier = "UnknownItemOnError"
}
WriteLog "Exception during parallel task '$localTaskType' for item '$resultIdentifier': $($_.Exception.ToString())"
$localProgressQueue.Enqueue(@{ Identifier = $resultIdentifier; Status = $resultStatus })
}
$driverPathValue = $null
if ($null -ne $taskResult -and $taskResult.PSObject.Properties.Name -contains 'DriverPath') {
$driverPathValue = $taskResult.DriverPath
}
# Return a consistent hashtable structure (final result)
return @{
Identifier = $resultIdentifier
Status = $resultStatus
ResultCode = $resultCode
DriverPath = $driverPathValue
}
} -ThrottleLimit $ThrottleLimit -AsJob
}
catch {
WriteLog "Error initiating ForEach-Object -Parallel: $($_.Exception.Message)"
# Update all items to show a general startup error
$errorStatus = "$ErrorStatusPrefix Failed to start processing"
foreach ($item in $ItemsToProcess) {
$identifier = $item.$IdentifierProperty
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { # Use $WindowObject
Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $identifier -StatusProperty $StatusProperty -StatusValue $errorStatus # Pass $WindowObject
})
}
return
}
# Check if any jobs failed to start immediately (e.g., module loading issues within the job)
$failedJobs = $jobs | Where-Object { $_.State -eq 'Failed' -and $_.JobStateInfo.Reason }
foreach ($failedJob in $failedJobs) {
WriteLog "Job $($failedJob.Id) failed to start or failed early: $($failedJob.JobStateInfo.Reason)"
# We don't easily know which item failed here without more complex mapping
# Update overall status maybe?
$processedCount++
}
# Filter out jobs that failed immediately
$jobs = $jobs | Where-Object { $_.State -ne 'Failed' }
# Process job results and intermediate status updates without blocking the UI thread
while ($jobs.Count -gt 0 -or -not $progressQueue.IsEmpty) {
# Continue while jobs are running OR queue has messages
# 1. Process intermediate status updates from the queue
$statusUpdate = $null
while ($progressQueue.TryDequeue([ref]$statusUpdate)) {
if ($null -ne $statusUpdate) {
WriteLog "Dequeued progress update: $($statusUpdate | ConvertTo-Json -Compress)"
$intermediateIdentifier = $statusUpdate.Identifier
# If this item has already been marked as complete, skip this stale intermediate update
if ($completedIdentifiers.Contains($intermediateIdentifier)) {
WriteLog "Skipping stale intermediate status for already completed item: $intermediateIdentifier"
continue
}
$intermediateStatus = $statusUpdate.Status
if ($isUiMode) {
# Update the UI with the intermediate status
try {
WriteLog "Dispatching INTERMEDIATE status for '$intermediateIdentifier': '$intermediateStatus'"
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] {
Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $intermediateIdentifier -StatusProperty $StatusProperty -StatusValue $intermediateStatus
})
}
catch {
WriteLog "Error setting intermediate status for item '$intermediateIdentifier': $($_.Exception.Message)"
}
}
else {
# Log intermediate status if not in UI mode
WriteLog "Intermediate Status for '$intermediateIdentifier': $intermediateStatus"
}
}
}
# 2. Check for completed jobs
$completedJobs = $jobs | Where-Object { $_.State -in 'Completed', 'Failed', 'Stopped' }
if ($completedJobs) {
foreach ($completedJob in $completedJobs) {
$jobHandled = $false
if ($completedJob.State -eq 'Failed') {
$jobHandled = $true
$finalIdentifier = "UnknownJob"
WriteLog "Job $($completedJob.Id) failed: $($completedJob.Error)"
$finalStatus = "$ErrorStatusPrefix Job Failed"
$finalResultCode = 1
$processedCount++
# --- DISPATCH FOR FAILED JOB ---
$completedIdentifiers.Add($finalIdentifier) | Out-Null
if ($isUiMode) {
try {
WriteLog "Dispatching FINAL status for '$finalIdentifier': '$finalStatus'"
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $finalIdentifier -StatusProperty $StatusProperty -StatusValue $finalStatus })
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-OverallProgress -WindowObject $WindowObject -CompletedCount $processedCount -TotalCount $totalItems -StatusText "Processed $processedCount of $totalItems..." -ProgressBarName "progressBar" -StatusLabelName "txtStatus" })
}
catch { WriteLog "Error setting FINAL status for item '$finalIdentifier': $($_.Exception.Message)" }
}
else { WriteLog "Final Status for '$finalIdentifier': $finalStatus (ResultCode: $finalResultCode)" }
}
elseif ($completedJob.HasMoreData) {
$jobHandled = $true
$jobResults = $completedJob | Receive-Job
foreach ($result in $jobResults) {
WriteLog "Received FINAL job result: $($result | ConvertTo-Json -Compress -Depth 3)"
if ($null -ne $result -and $result -is [hashtable] -and $result.ContainsKey('Identifier')) {
$finalIdentifier = $result.Identifier
$status = $result.Status
$finalResultCode = $result.ResultCode
$finalStatus = if ($finalResultCode -eq 0) { $status } else { "$($ErrorStatusPrefix)$($status)" }
$processedCount++
}
else {
$finalIdentifier = "UnknownResult"
WriteLog "Warning: Received unexpected final job result format: $($result | Out-String)"
$finalStatus = "$ErrorStatusPrefix Invalid Result Format"
$finalResultCode = 1
$processedCount++
}
if ($null -ne $result) { $resultsCollection.Add($result) }
# --- DISPATCH PER RESULT ---
$completedIdentifiers.Add($finalIdentifier) | Out-Null
if ($isUiMode) {
try {
WriteLog "Dispatching FINAL status for '$finalIdentifier': '$finalStatus'"
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $finalIdentifier -StatusProperty $StatusProperty -StatusValue $finalStatus })
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-OverallProgress -WindowObject $WindowObject -CompletedCount $processedCount -TotalCount $totalItems -StatusText "Processed $processedCount of $totalItems..." -ProgressBarName "progressBar" -StatusLabelName "txtStatus" })
}
catch { WriteLog "Error setting FINAL status for item '$finalIdentifier': $($_.Exception.Message)" }
}
else { WriteLog "Final Status for '$finalIdentifier': $finalStatus (ResultCode: $finalResultCode)" }
}
}
if (-not $jobHandled) {
# Catches 'Completed' with no data
$finalIdentifier = "UnknownJob"
WriteLog "Job $($completedJob.Id) completed with state '$($completedJob.State)' but had no data."
$finalStatus = "$ErrorStatusPrefix No Result Data"
$finalResultCode = 1
$processedCount++
# --- DISPATCH FOR NO-DATA JOB ---
$completedIdentifiers.Add($finalIdentifier) | Out-Null
if ($isUiMode) {
try {
WriteLog "Dispatching FINAL status for '$finalIdentifier': '$finalStatus'"
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-ListViewItemStatus -WindowObject $WindowObject -ListView $ListViewControl -IdentifierProperty $IdentifierProperty -IdentifierValue $finalIdentifier -StatusProperty $StatusProperty -StatusValue $finalStatus })
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] { Update-OverallProgress -WindowObject $WindowObject -CompletedCount $processedCount -TotalCount $totalItems -StatusText "Processed $processedCount of $totalItems..." -ProgressBarName "progressBar" -StatusLabelName "txtStatus" })
}
catch { WriteLog "Error setting FINAL status for item '$finalIdentifier': $($_.Exception.Message)" }
}
else { WriteLog "Final Status for '$finalIdentifier': $finalStatus (ResultCode: $finalResultCode)" }
}
# Remove the completed/failed job from the list and clean it up
$jobs = $jobs | Where-Object { $_.Id -ne $completedJob.Id }
Remove-Job -Job $completedJob -Force -ErrorAction SilentlyContinue
}
}
# 3. Allow UI events to process and sleep briefly
if ($isUiMode) {
# Only sleep if jobs are still running AND the queue is empty (to avoid delaying UI updates)
if ($jobs.Count -gt 0 -and $progressQueue.IsEmpty) {
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action] { }) | Out-Null
Start-Sleep -Milliseconds 100
}
elseif (-not $progressQueue.IsEmpty) {
# If queue has messages, process them immediately without sleeping
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [action] { }) | Out-Null
}
}
else {
# Non-UI mode, just sleep if jobs are running
if ($jobs.Count -gt 0) {
Start-Sleep -Milliseconds 100
}
}
}
# Final cleanup of any remaining jobs (shouldn't be necessary with this loop logic, but good practice)
if ($jobs.Count -gt 0) {
WriteLog "Cleaning up $($jobs.Count) remaining jobs after loop exit."
Remove-Job -Job $jobs -Force -ErrorAction SilentlyContinue
}
if ($isUiMode) {
WriteLog "Invoke-ParallelProcessing finished for ListView '$($ListViewControl.Name)'."
# Final overall progress update
$WindowObject.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Background, [Action] {
Update-OverallProgress -WindowObject $WindowObject -CompletedCount $processedCount -TotalCount $totalItems -StatusText "Processing complete. Processed $processedCount of $totalItems." -ProgressBarName "progressBar" -StatusLabelName "txtStatus"
})
}
else {
WriteLog "Invoke-ParallelProcessing finished (non-UI mode). Processed $processedCount of $totalItems."
}
# Return all collected final results from jobs
return $resultsCollection
}
Export-ModuleMember -Function Invoke-ParallelProcessing
@@ -0,0 +1,752 @@
<#
.SYNOPSIS
Provides functions for interacting with WinGet and the Microsoft Store to find, download, and configure applications.
.DESCRIPTION
This module contains a set of functions designed to automate application management using the WinGet package manager and the Microsoft Store.
It supports checking for and installing WinGet, downloading applications, handling different application types (Win32 and UWP), and generating silent installation commands for Win32 applications.
This module is used by both the build script (BuildFFUVM.ps1) and the UI (BuildFFUVM_UI.ps1) to manage application downloads and configuration.
#>
function Get-Application {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[string]$AppName,
[Parameter(Mandatory = $true)]
[string]$AppId,
[Parameter(Mandatory = $true)]
[ValidateSet('winget', 'msstore')]
[string]$Source,
[Parameter(Mandatory = $true)]
[string]$AppsPath,
[Parameter(Mandatory = $true)]
[string]$ApplicationArch,
[string]$WindowsArch,
[Parameter(Mandatory = $true)]
[string]$OrchestrationPath,
[switch]$SkipWin32Json
)
# Block Company Portal from winget source
# I refuse to code around the poor packaging of this app
if ($AppId -eq 'Microsoft.CompanyPortal' -and $Source -eq 'winget') {
WriteLog "Skipping download of Company Portal from the 'winget' source. This version has packaging inconsistencies. Please use the 'msstore' source instead."
return 4 # Return specific error code for this case
}
# Determine base folder path for checking existence
$appIsWin32ForCheck = ($Source -eq 'msstore' -and $AppId.StartsWith("XP"))
$appBaseFolderPathForCheck = ""
if ($Source -eq 'winget' -or $appIsWin32ForCheck) {
$appBaseFolderPathForCheck = Join-Path -Path "$AppsPath\Win32" -ChildPath $AppName
}
else {
$appBaseFolderPathForCheck = Join-Path -Path "$AppsPath\MSStore" -ChildPath $AppName
}
# Check if the app (any architecture) has already been downloaded by checking for its content folder.
# This prevents re-downloading if BuildFFUVM.ps1 is run after downloading via the UI.
if (Test-Path -Path $appBaseFolderPathForCheck -PathType Container) {
# Check if the folder is not empty.
if (Get-ChildItem -Path $appBaseFolderPathForCheck -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1) {
WriteLog "Application '$AppName' appears to be already downloaded as content exists in '$appBaseFolderPathForCheck'. Skipping download."
# Add silent install command(s) only if not skipping JSON generation (build-time scenario)
$appIsWin32Existing = ($Source -eq 'winget' -or ($Source -eq 'msstore' -and $AppId.StartsWith('XP')))
if ($appIsWin32Existing -and -not $SkipWin32Json) {
$win32BasePath = Join-Path -Path "$AppsPath\Win32" -ChildPath $AppName
if (Test-Path -Path $win32BasePath -PathType Container) {
$archFolders = Get-ChildItem -Path $win32BasePath -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -in @('x86', 'x64', 'arm64') }
if ($archFolders) {
foreach ($archFolder in $archFolders) {
WriteLog "Adding silent install command for pre-downloaded $AppName ($($archFolder.Name)) to $OrchestrationPath\WinGetWin32Apps.json"
Add-Win32SilentInstallCommand -AppFolder $AppName -AppFolderPath $archFolder.FullName -OrchestrationPath $OrchestrationPath -SubFolder $archFolder.Name | Out-Null
}
}
else {
WriteLog "Adding silent install command for pre-downloaded $AppName to $OrchestrationPath\WinGetWin32Apps.json"
Add-Win32SilentInstallCommand -AppFolder $AppName -AppFolderPath $win32BasePath -OrchestrationPath $OrchestrationPath | Out-Null
}
}
}
elseif ($appIsWin32Existing -and $SkipWin32Json) {
WriteLog "Skipping WinGetWin32Apps.json regeneration for pre-downloaded $AppName (UI mode)."
}
return 0 # Success, already present
}
}
# Validate app exists in repository
$wingetSearchResult = Find-WinGetPackage -id $AppId -MatchOption Equals -Source $Source
if (-not $wingetSearchResult) {
if ($VerbosePreference -ne 'Continue') {
Write-Error "$AppName not found in $Source repository."
Write-Error "Check the AppList.json file and make sure the AppID is correct."
}
WriteLog "$AppName not found in $Source repository."
WriteLog "Check the AppList.json file and make sure the AppID is correct."
return 1 # Return error code
}
# Determine architectures to download (ApplicationArch controls download set; WindowsArch (optional) used later for pruning store installers)
$architecturesToDownload = if ($ApplicationArch -eq 'x86 x64') { @('x86', 'x64') } else { @($ApplicationArch) }
$overallResult = 0
# For msstore, we don't specify architecture, so we only need to loop once.
if ($Source -eq 'msstore') {
$architecturesToDownload = @('neutral') # Use a placeholder to loop once
}
foreach ($arch in $architecturesToDownload) {
if ($Source -eq 'msstore') {
WriteLog "Processing '$AppName' for all architectures."
}
else {
WriteLog "Processing '$AppName' for architecture '$arch'."
}
# Determine app type and folder path
$appIsWin32 = ($Source -eq 'msstore' -and $AppId.StartsWith("XP"))
$sanitizedAppName = ConvertTo-SafeName -Name $AppName
if ($sanitizedAppName -ne $AppName) { WriteLog "Sanitized app name: '$AppName' -> '$sanitizedAppName'" }
if ($Source -eq 'winget' -or $appIsWin32) {
$appBaseFolderPath = Join-Path -Path "$AppsPath\Win32" -ChildPath $sanitizedAppName
}
else {
$appBaseFolderPath = Join-Path -Path "$AppsPath\MSStore" -ChildPath $sanitizedAppName
}
# If downloading multiple archs for a Win32 app, create a subfolder
$appFolderPath = $appBaseFolderPath
$subFolderForCommand = $null
if ($architecturesToDownload.Count -gt 1 -and ($Source -eq 'winget' -or $appIsWin32)) {
$appFolderPath = Join-Path -Path $appBaseFolderPath -ChildPath $arch
$subFolderForCommand = $arch
}
# Create app folder
New-Item -Path $appFolderPath -ItemType Directory -Force | Out-Null
# Build download parameters and log information
$downloadParams = @{
id = $AppId
DownloadDirectory = $appFolderPath
Source = $Source
}
if ($Source -ne 'msstore') {
$downloadParams.Architecture = $arch
WriteLog "Downloading $AppName for $arch architecture..."
WriteLog "WinGet command: Export-WinGetPackage -id $AppId -DownloadDirectory `"$appFolderPath`" -Architecture $arch -Source $Source"
}
else {
WriteLog "Downloading $AppName for all architectures..."
WriteLog 'MSStore app downloads require authentication with an Entra ID account. You may be prompted twice for credentials, once for the app and another for the license file.'
WriteLog "WinGet command: Export-WinGetPackage -id $AppId -DownloadDirectory `"$appFolderPath`" -Source $Source"
}
# Download the app
$wingetDownloadResult = Export-WinGetPackage @downloadParams
# Handle download status
if ($wingetDownloadResult.status -ne 'Ok') {
# For winget source, try downloading without architecture if the specified one fails
if (($Source -eq 'winget') -and ($wingetDownloadResult.status -eq 'NoApplicableInstallers' -or $wingetDownloadResult.status -eq 'NoApplicableInstallerFound')) {
WriteLog "No installer found for $arch architecture. Attempting to download without specifying architecture..."
# Remove the architecture parameter and try again
$downloadParams.Remove('Architecture')
$wingetDownloadResult = Export-WinGetPackage @downloadParams
}
# Re-evaluate status after potential second attempt
if ($wingetDownloadResult.status -ne 'Ok') {
# Handle Store-specific publisher restriction error
if ($Source -eq 'msstore' -and $wingetDownloadResult.ExtendedErrorCode -match '0x8A150084') {
$errorMessage = "The Microsoft Store app $AppName does not support downloads by the publisher. Please remove it from the AppList.json. If there's a winget source version of the application, try using that instead. Exiting."
WriteLog $errorMessage
Remove-Item -Path $appFolderPath -Recurse -Force
Write-Error $errorMessage
return 3 # Return specific error code for publisher restriction
}
# Handle other download failures
else {
$errormsg = "Download failed for $AppName with status: $($wingetDownloadResult.status) $($wingetDownloadResult.ExtendedErrorCode)"
WriteLog $errormsg
Remove-Item -Path $appFolderPath -Recurse -Force
Write-Error $errormsg
return 1 # Return generic error code
}
}
else {
WriteLog "Downloaded $AppName without specifying architecture."
}
}
WriteLog "$AppName ($arch) downloaded to $appFolderPath"
# Handle zip files
$zipFile = Get-ChildItem -Path $appFolderPath -Filter "*.zip" -File -ErrorAction SilentlyContinue
if ($zipFile) {
WriteLog "Found zip file: $($zipFile.FullName). Extracting..."
Expand-Archive -Path $zipFile.FullName -DestinationPath $appFolderPath -Force
WriteLog "Extraction complete. Removing zip file."
Remove-Item -Path $zipFile.FullName -Force
WriteLog "Zip file removed."
}
# Handle winget source apps that have appx, appxbundle, msix, or msixbundle extensions but were downloaded to the Win32 folder
$installerFiles = Get-ChildItem -Path "$appFolderPath\*" -Exclude "*.yaml", "*.xml" -File -ErrorAction SilentlyContinue
$uwpExtensions = @(".appx", ".appxbundle", ".msix", ".msixbundle")
$isUwpApp = $false
if ($installerFiles) {
foreach ($file in $installerFiles) {
if ($uwpExtensions -contains $file.Extension) {
$isUwpApp = $true
break
}
}
}
if ($isUwpApp -and $appFolderPath -match 'Win32') {
# Handle UWP apps
$NewAppPath = "$AppsPath\MSStore\$AppName"
WriteLog "$AppName is a UWP app. Moving to $NewAppPath"
WriteLog "Creating $NewAppPath"
New-Item -Path "$AppsPath\MSStore\$AppName" -ItemType Directory -Force | Out-Null
WriteLog "Moving $AppName to $NewAppPath"
Move-Item -Path "$appFolderPath\*" -Destination "$AppsPath\MSStore\$AppName" -Force
WriteLog "Removing $appFolderPath"
Remove-Item -Path $appFolderPath -Force -Recurse
WriteLog "$AppName moved to $NewAppPath"
$result = 0 # Success for UWP app
}
# If app is in Win32 folder, add the silent install command to the WinGetWin32Apps.json file
elseif ($appFolderPath -match 'Win32') {
if (-not $SkipWin32Json) {
WriteLog "$AppName is a Win32 app. Adding silent install command to $OrchestrationPath\WinGetWin32Apps.json"
$result = Add-Win32SilentInstallCommand -AppFolder $AppName -AppFolderPath $appFolderPath -OrchestrationPath $OrchestrationPath -SubFolder $subFolderForCommand
}
else {
WriteLog "$AppName is a Win32 app. Skipping WinGetWin32Apps.json generation (UI mode)."
$result = 0
}
}
else {
# For any other case, set result to 0 (success)
$result = 0
}
if ($result -ne 0) { $overallResult = $result }
# Handle MSStore specific post-processing
if ($Source -eq 'msstore' -and $appFolderPath -match 'MSStore') {
# Handle ARM64-specific dependencies
if ($arch -eq 'ARM64') {
WriteLog 'Windows architecture is ARM64. Removing dependencies that are not ARM64.'
$dependencies = Get-ChildItem -Path "$appFolderPath\Dependencies" -ErrorAction SilentlyContinue
if ($dependencies) {
foreach ($dependency in $dependencies) {
if ($dependency.Name -notmatch 'ARM64') {
WriteLog "Removing dependency file $($dependency.FullName)"
Remove-Item -Path $dependency.FullName -Recurse -Force
}
}
}
}
# Clean up multiple versions honoring WindowsArch (pruning target; keep only one installer)
WriteLog "$AppName has completed downloading. Evaluating installer set for pruning."
$packages = Get-ChildItem -Path "$appFolderPath\*" -Exclude "Dependencies\*", "*.xml", "*.yaml" -File -ErrorAction Stop
if ($packages.Count -gt 1 -and $WindowsArch) {
WriteLog "WindowsArch pruning target provided: $WindowsArch"
# Detect universal bundles (contain x86,x64,arm64 in name)
$universalCandidates = $packages | Where-Object {
$base = $_.BaseName
# Split base name into tokens to avoid partial matches (e.g. arm inside arm64)
$tokens = ($base -split '[\.\-_]') | ForEach-Object { $_.ToLower() }
# Architecture tokens we recognize
$archTokens = @('x86', 'x64', 'arm', 'arm64')
# Distinct matched architecture tokens
$matched = $tokens | Where-Object { $_ -in $archTokens } | Select-Object -Unique
if ($matched.Count -ge 2) {
WriteLog "Multi-architecture bundle detected: $base (tokens: $($matched -join ', '))"
$true
}
else {
$false
}
}
if ($universalCandidates) {
WriteLog "Universal bundle candidate(s) detected: $($universalCandidates.Name -join ', ')"
$candidateSet = $universalCandidates
}
else {
$archToken = switch -Regex ($WindowsArch.ToLower()) {
'^x64$' { 'x64' ; break }
'^x86$' { 'x86' ; break }
'^arm64$' { 'arm64' ; break }
default { $WindowsArch.ToLower() }
}
$archMatches = $packages | Where-Object { $_.BaseName -match "(?i)$archToken" }
if ($archMatches) {
WriteLog "Architecture-specific candidates matching '$archToken': $($archMatches.Name -join ', ')"
$candidateSet = $archMatches
}
else {
WriteLog "No installer filename matched '$archToken'. Falling back to all installers."
$candidateSet = $packages
}
}
# From candidate set, choose latest by signature date
$latestPackage = $candidateSet | Sort-Object { (Get-AuthenticodeSignature $_.FullName).SignerCertificate.NotBefore } -Descending | Select-Object -First 1
WriteLog "Retaining installer: $($latestPackage.Name)"
foreach ($package in $packages) {
if ($package.FullName -ne $latestPackage.FullName) {
try {
WriteLog "Removing $($package.FullName)"
Remove-Item -Path $package.FullName -Force
}
catch {
WriteLog "Failed to delete: $($package.FullName) - $_"
throw $_
}
}
}
}
elseif ($packages.Count -gt 1) {
WriteLog "Multiple installers present but no WindowsArch pruning target supplied. Using original latest-version logic."
$latestPackage = $packages | Sort-Object { (Get-AuthenticodeSignature $_.FullName).SignerCertificate.NotBefore } -Descending | Select-Object -First 1
WriteLog "Retaining latest by signature date: $($latestPackage.Name)"
foreach ($package in $packages) {
if ($package.FullName -ne $latestPackage.FullName) {
try {
WriteLog "Removing $($package.FullName)"
Remove-Item -Path $package.FullName -Force
}
catch {
WriteLog "Failed to delete: $($package.FullName) - $_"
throw $_
}
}
}
}
else {
WriteLog "Single installer present; no pruning required."
}
}
} # End foreach ($arch in $architecturesToDownload)
return $overallResult
}
function Get-Apps {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[string]$AppList,
[Parameter(Mandatory = $true)]
[string]$AppsPath,
[Parameter(Mandatory = $true)]
[string]$WindowsArch,
[Parameter(Mandatory = $true)]
[string]$OrchestrationPath
)
# Load and validate app list
$apps = Get-Content -Path $AppList -Raw | ConvertFrom-Json
if (-not $apps) {
WriteLog "No apps were specified in AppList.json file."
return
}
# Process WinGet apps
$wingetApps = $apps.apps | Where-Object { $_.source -eq "winget" }
if ($wingetApps) {
WriteLog 'Winget apps to be installed:'
$wingetApps | ForEach-Object { WriteLog $_.Name }
}
# Process Store apps
$StoreApps = $apps.apps | Where-Object { $_.source -eq "msstore" }
if ($StoreApps) {
WriteLog 'Store apps to be installed:'
$StoreApps | ForEach-Object { WriteLog $_.Name }
}
# Ensure WinGet is available
Confirm-WinGetInstallation -WindowsArch $WindowsArch
# Create necessary folders
$win32Folder = Join-Path -Path $AppsPath -ChildPath "Win32"
$storeAppsFolder = Join-Path -Path $AppsPath -ChildPath "MSStore"
# Process WinGet apps
if ($wingetApps) {
if (-not (Test-Path -Path $win32Folder -PathType Container)) {
WriteLog "Creating folder for Winget Win32 apps: $win32Folder"
New-Item -Path $win32Folder -ItemType Directory -Force | Out-Null
WriteLog "Folder created successfully."
}
foreach ($wingetApp in $wingetApps) {
try {
$appArch = if ($wingetApp.PSObject.Properties['architecture']) { $wingetApp.architecture } else { $WindowsArch }
Get-Application -AppName $wingetApp.Name -AppId $wingetApp.Id -Source 'winget' -AppsPath $AppsPath -ApplicationArch $appArch -OrchestrationPath $OrchestrationPath
}
catch {
WriteLog "Error occurred while processing $($wingetApp.Name): $_"
throw $_
}
}
}
# Process Store apps
if ($StoreApps) {
if (-not (Test-Path -Path $storeAppsFolder -PathType Container)) {
New-Item -Path $storeAppsFolder -ItemType Directory -Force | Out-Null
}
foreach ($storeApp in $StoreApps) {
try {
$appArch = if ($storeApp.PSObject.Properties['architecture']) { $storeApp.architecture } else { $WindowsArch }
Get-Application -AppName $storeApp.Name -AppId $storeApp.Id -Source 'msstore' -AppsPath $AppsPath -ApplicationArch $appArch -WindowsArch $WindowsArch -OrchestrationPath $OrchestrationPath
}
catch {
WriteLog "Error occurred while processing $($storeApp.Name): $_"
throw $_
}
}
}
# Post-processing: Override CommandLine / Arguments from AppList.json if provided
# Users may supply custom silent install commands or arguments. These optional
# properties (CommandLine, Arguments) in AppList.json replace the auto-generated
# values in WinGetWin32Apps.json. Keyed by Name.
try {
$overrideMap = @{}
foreach ($app in $apps.apps) {
if ($app.source -in @('winget', 'msstore')) {
$hasCmd = ($app.PSObject.Properties['CommandLine'] -and -not [string]::IsNullOrWhiteSpace($app.CommandLine))
$hasArgs = ($app.PSObject.Properties['Arguments'] -and -not [string]::IsNullOrWhiteSpace($app.Arguments))
$hasAdd = ($app.PSObject.Properties['AdditionalExitCodes'] -and -not [string]::IsNullOrWhiteSpace($app.AdditionalExitCodes))
$hasIgnore = ($app.PSObject.Properties['IgnoreNonZeroExitCodes'])
if ($hasCmd -or $hasArgs -or $hasAdd -or $hasIgnore) {
$overrideMap[$app.name] = @{
CommandLine = if ($hasCmd) { $app.CommandLine } else { $null }
Arguments = if ($hasArgs) { $app.Arguments } else { $null }
AdditionalExitCodes = if ($hasAdd) { $app.AdditionalExitCodes } else { $null }
IgnoreNonZeroExitCodes = if ($hasIgnore) { [bool]$app.IgnoreNonZeroExitCodes } else { $null }
}
}
}
}
if ($overrideMap.Count -gt 0) {
$winGetWin32Path = Join-Path -Path $OrchestrationPath -ChildPath 'WinGetWin32Apps.json'
if (Test-Path -Path $winGetWin32Path) {
[array]$appsDataUpdated = Get-Content -Path $winGetWin32Path -Raw | ConvertFrom-Json
$changed = $false
foreach ($entry in $appsDataUpdated) {
if ($overrideMap.ContainsKey($entry.Name)) {
$ov = $overrideMap[$entry.Name]
if ($ov.CommandLine) {
WriteLog "Override (AppList.json) CommandLine for $($entry.Name)"
$entry.CommandLine = $ov.CommandLine
$changed = $true
}
if ($ov.Arguments) {
WriteLog "Override (AppList.json) Arguments for $($entry.Name)"
$entry.Arguments = $ov.Arguments
$changed = $true
}
if ($ov.ContainsKey('AdditionalExitCodes') -and $null -ne $ov.AdditionalExitCodes) {
WriteLog "Override (AppList.json) AdditionalExitCodes for $($entry.Name)"
$entry | Add-Member -NotePropertyName AdditionalExitCodes -NotePropertyValue $ov.AdditionalExitCodes -Force
$changed = $true
}
if ($ov.ContainsKey('IgnoreNonZeroExitCodes') -and $null -ne $ov.IgnoreNonZeroExitCodes) {
WriteLog "Override (AppList.json) IgnoreNonZeroExitCodes for $($entry.Name)"
$entry | Add-Member -NotePropertyName IgnoreNonZeroExitCodes -NotePropertyValue ([bool]$ov.IgnoreNonZeroExitCodes) -Force
$changed = $true
}
}
}
if ($changed) {
$appsDataUpdated | ConvertTo-Json -Depth 10 | Set-Content -Path $winGetWin32Path
WriteLog "Applied AppList.json command overrides to WinGetWin32Apps.json"
}
else {
WriteLog "No matching apps required command overrides."
}
}
else {
WriteLog "WinGetWin32Apps.json not found; no overrides applied."
}
}
}
catch {
WriteLog "Failed to apply AppList.json command overrides: $($_.Exception.Message)"
}
}
function Install-WinGet {
param (
[string]$Architecture
)
$packages = @(
@{Name = "VCLibs"; Url = "https://aka.ms/Microsoft.VCLibs.$Architecture.14.00.Desktop.appx"; File = "Microsoft.VCLibs.$Architecture.14.00.Desktop.appx" },
@{Name = "UIXaml"; Url = "https://github.com/microsoft/microsoft-ui-xaml/releases/download/v2.8.6/Microsoft.UI.Xaml.2.8.$Architecture.appx"; File = "Microsoft.UI.Xaml.2.8.$Architecture.appx" },
@{Name = "WinGet"; Url = "https://aka.ms/getwinget"; File = "Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle" }
)
foreach ($package in $packages) {
$destination = Join-Path -Path $env:TEMP -ChildPath $package.File
WriteLog "Downloading $($package.Name) from $($package.Url) to $destination"
Start-BitsTransferWithRetry -Source $package.Url -Destination $destination
WriteLog "Installing $($package.Name)..."
# Don't show progress bar for Add-AppxPackage - there's a weird issue where the progress stays on the screen after the apps are installed
$ProgressPreference = 'SilentlyContinue'
Add-AppxPackage -Path $destination -ErrorAction SilentlyContinue
# Set progress preference back to default
$ProgressPreference = 'Continue'
WriteLog "Removing $($package.Name)..."
Remove-Item -Path $destination -Force -ErrorAction SilentlyContinue
}
WriteLog "WinGet installation complete."
}
function Confirm-WinGetInstallation {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$WindowsArch
)
WriteLog 'Checking if WinGet is installed...'
$minVersion = [version]"1.8.1911"
# Check WinGet PowerShell module
$wingetModule = Get-InstalledModule -Name Microsoft.Winget.Client -ErrorAction SilentlyContinue
$wingetModuleVersion = [version]$wingetModule.Version
if ($wingetModuleVersion -lt $minVersion -or -not $wingetModule) {
WriteLog 'Microsoft.Winget.Client module is not installed or is an older version. Installing the latest version...'
# Handle PSGallery trust settings
$PSGalleryTrust = (Get-PSRepository -Name 'PSGallery').InstallationPolicy
if ($PSGalleryTrust -eq 'Untrusted') {
WriteLog 'Temporarily setting PSGallery as a trusted repository...'
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted
}
Install-Module -Name Microsoft.Winget.Client -Force -Repository 'PSGallery'
if ($PSGalleryTrust -eq 'Untrusted') {
WriteLog 'Setting PSGallery back to untrusted repository...'
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Untrusted
WriteLog 'Done'
}
}
else {
WriteLog "Installed Microsoft.Winget.Client module version: $($wingetModule.Version)"
}
# Check WinGet CLI
$wingetVersion = Get-WinGetVersion
if (-not $wingetVersion) {
WriteLog "WinGet is not installed. Installing WinGet..."
Install-WinGet -Architecture $WindowsArch
}
elseif ($wingetVersion -match 'v?(\d+\.\d+\.\d+)' -and [version]$matches[1] -lt $minVersion) {
WriteLog "The installed version of WinGet $($matches[1]) does not support downloading MSStore apps. Installing the latest version of WinGet..."
Install-WinGet -Architecture $WindowsArch
}
else {
WriteLog "Installed WinGet version: $wingetVersion"
}
}
function Add-Win32SilentInstallCommand {
param (
[string]$AppFolder,
[string]$AppFolderPath,
[Parameter(Mandatory = $true)]
[string]$OrchestrationPath,
[string]$SubFolder
)
$appName = $AppFolder
# Discover installer candidates (top-level files as before)
$installerCandidates = Get-ChildItem -Path "$appFolderPath\*" -Include "*.exe", "*.msi" -File -ErrorAction SilentlyContinue
if (-not $installerCandidates) {
WriteLog "No win32 app installers were found. Skipping the inclusion of $AppFolder"
Remove-Item -Path $AppFolderPath -Recurse -Force
return 1
}
# Read the exported WinGet YAML
$yamlFile = Get-ChildItem -Path "$appFolderPath\*" -Include "*.yaml" -File -ErrorAction Stop
$yamlText = Get-Content -Path $yamlFile -Raw
# Attempt to resolve the correct installer from YAML NestedInstallerFiles within the matching Architecture block
$desiredArch = if (-not [string]::IsNullOrEmpty($SubFolder)) { $SubFolder } else { $null }
$relativeFromYaml = $null
$blockSilent = $null
$regexOptions = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
$pattern = '-\s+Architecture:\s*(?<arch>\S+)[\s\S]*?NestedInstallerFiles:\s*-\s*RelativeFilePath:\s*(?<path>.+?)\r?\n'
$yamlMatches = [regex]::Matches($yamlText, $pattern, $regexOptions)
$selectedMatch = $null
if ($yamlMatches.Count -gt 0) {
if ($desiredArch) {
foreach ($m in $yamlMatches) {
if ($m.Groups['arch'].Value -ieq $desiredArch) {
$selectedMatch = $m
break
}
}
}
if (-not $selectedMatch) {
$selectedMatch = $yamlMatches[0]
}
$pathValue = $selectedMatch.Groups['path'].Value.Trim()
$pathValue = $pathValue.Trim("'").Trim('"')
$relativeFromYaml = $pathValue
# Extract a Silent switch from within the same installer block if present
$startIndex = $selectedMatch.Index
$nextIndex = -1
for ($i = 0; $i -lt $yamlMatches.Count; $i++) {
if ($yamlMatches[$i].Index -gt $startIndex) {
$nextIndex = $yamlMatches[$i].Index
break
}
}
if ($nextIndex -gt -1) {
$blockText = $yamlText.Substring($startIndex, $nextIndex - $startIndex)
}
else {
$blockText = $yamlText.Substring($startIndex)
}
$blockSilentMatch = [regex]::Match($blockText, 'InstallerSwitches:[\s\S]*?Silent:\s*(.+?)\r?\n', $regexOptions)
if ($blockSilentMatch.Success) {
$blockSilent = $blockSilentMatch.Groups[1].Value.Trim().Trim("'").Trim('"')
}
}
# Resolve Silent switch (prefer block-level, fallback to first Silent in file)
$silentInstallSwitch = $blockSilent
if ([string]::IsNullOrEmpty($silentInstallSwitch)) {
$globalSilentMatch = [regex]::Match($yamlText, 'Silent:\s*(.+)', $regexOptions)
$silentInstallSwitch = $globalSilentMatch.Groups[1].Value.Trim().Trim("'").Trim('"')
}
if (-not $silentInstallSwitch) {
WriteLog "Silent install switch for $appName could not be found. Skipping the inclusion of $appName."
Remove-Item -Path $appFolderPath -Recurse -Force
return 2
}
# Choose final installer path and extension
$resolvedRelativePath = $null
$installerExt = $null
if ($installerCandidates.Count -eq 1 -and -not $relativeFromYaml) {
# Single installer keep current behavior
$resolvedRelativePath = $installerCandidates[0].Name
$installerExt = $installerCandidates[0].Extension
WriteLog "Single installer detected ($resolvedRelativePath). Using current behavior."
}
else {
if ($relativeFromYaml) {
$normalizedPath = ($relativeFromYaml -replace '/', '\')
$resolvedRelativePath = $normalizedPath
$installerExt = [System.IO.Path]::GetExtension($normalizedPath)
if ([string]::IsNullOrEmpty($installerExt)) {
$leafName = [System.IO.Path]::GetFileName($normalizedPath)
$matchedCandidate = $installerCandidates | Where-Object { $_.Name -ieq $leafName } | Select-Object -First 1
if ($matchedCandidate) {
$installerExt = $matchedCandidate.Extension
}
}
WriteLog "Multiple installers found. Selected by YAML NestedInstallerFiles: $resolvedRelativePath"
}
if (-not $resolvedRelativePath) {
# Fallbacks when YAML lacks NestedInstallerFiles or couldn't be matched
$msis = $installerCandidates | Where-Object { $_.Extension -ieq ".msi" }
if ($msis.Count -eq 1) {
$resolvedRelativePath = $msis[0].Name
$installerExt = ".msi"
WriteLog "Multiple installers found. YAML not used. Falling back to single MSI: $resolvedRelativePath"
}
else {
$exes = $installerCandidates | Where-Object { $_.Extension -ieq ".exe" }
if ($exes.Count -eq 1) {
$resolvedRelativePath = $exes[0].Name
$installerExt = ".exe"
WriteLog "Multiple installers found. YAML not used. Falling back to single EXE: $resolvedRelativePath"
}
else {
$first = $installerCandidates | Select-Object -First 1
$resolvedRelativePath = $first.Name
$installerExt = $first.Extension
WriteLog "Multiple installers found and ambiguous. Selecting the first candidate: $resolvedRelativePath"
}
}
}
}
$basePath = "D:\win32\$AppFolder"
if (-not [string]::IsNullOrEmpty($SubFolder)) {
$basePath = "$basePath\$SubFolder"
}
# Build final command/arguments
if ($installerExt -ieq ".exe") {
$silentInstallCommand = "$basePath\$resolvedRelativePath"
}
elseif ($installerExt -ieq ".msi") {
$silentInstallCommand = "msiexec"
$silentInstallSwitch = "/i `"$basePath\$resolvedRelativePath`" $silentInstallSwitch"
}
else {
# Default path usage if extension could not be inferred
$silentInstallCommand = "$basePath\$resolvedRelativePath"
}
# Path to the JSON file
$wingetWin32AppsJson = "$OrchestrationPath\WinGetWin32Apps.json"
# Initialize or load existing JSON data
if (Test-Path -Path $wingetWin32AppsJson) {
[array]$appsData = Get-Content -Path $wingetWin32AppsJson -Raw | ConvertFrom-Json
# Get highest priority value
if ($appsData.Count -gt 0) {
$highestPriority = $appsData.Count + 1
}
}
else {
$appsData = @()
$highestPriority = 1
}
# Create new app entry
$newApp = [PSCustomObject]@{
Priority = $highestPriority
Name = if (-not [string]::IsNullOrEmpty($SubFolder)) { "$appName ($SubFolder)" } else { $appName }
CommandLine = $silentInstallCommand
Arguments = $silentInstallSwitch
}
$appsData += $newApp
$appsData | ConvertTo-Json -Depth 10 | Set-Content -Path $wingetWin32AppsJson
WriteLog "Added $($newApp.Name) to WinGetWin32Apps.json with priority $highestPriority"
# Return 0 for success
return 0
}
# --------------------------------------------------------------------------
# SECTION: Module Export
# --------------------------------------------------------------------------
# Export functions needed by both BuildFFUVM and the UI Core module
Export-ModuleMember -Function Get-Application, Get-Apps, Confirm-WinGetInstallation, Add-Win32SilentInstallCommand, Install-Winget
+135
View File
@@ -0,0 +1,135 @@
#
# Module manifest for module 'FFU.Common'
#
# Generated by: Richard Balsley
#
# Generated on: 6/11/2025
#
@{
# Script module or binary module file associated with this manifest.
RootModule = 'FFU.Common.Core.psm1'
# Version number of this module.
ModuleVersion = '0.0.1'
# Supported PSEditions
# CompatiblePSEditions = @()
# ID used to uniquely identify this module
GUID = '7dac2b8f-e65a-4997-961e-7a5ef5161901'
# Author of this module
Author = 'Richard Balsley'
# Company or vendor of this module
CompanyName = 'Unknown'
# Copyright statement for this module
Copyright = '(c) Richard Balsley. All rights reserved.'
# Description of the functionality provided by this module
Description = 'Common functions shared between FFU Builder UI and the BuildFFUVM.ps1 build script.'
# Minimum version of the PowerShell engine required by this module
# PowerShellVersion = ''
# Name of the PowerShell host required by this module
# PowerShellHostName = ''
# Minimum version of the PowerShell host required by this module
# PowerShellHostVersion = ''
# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
# DotNetFrameworkVersion = ''
# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
# ClrVersion = ''
# Processor architecture (None, X86, Amd64) required by this module
# ProcessorArchitecture = ''
# Modules that must be imported into the global environment prior to importing this module
# RequiredModules = @()
# Assemblies that must be loaded prior to importing this module
# RequiredAssemblies = @()
# Script files (.ps1) that are run in the caller's environment prior to importing this module.
# ScriptsToProcess = @()
# Type files (.ps1xml) to be loaded when importing this module
# TypesToProcess = @()
# Format files (.ps1xml) to be loaded when importing this module
# FormatsToProcess = @()
# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess
NestedModules = @('FFU.Common.Drivers.psm1',
'FFU.Common.Winget.psm1',
'FFU.Common.Parallel.psm1',
'FFU.Common.Cleanup.psm1')
# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
FunctionsToExport = '*'
# Cmdlets 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 cmdlets to export.
CmdletsToExport = '*'
# Variables to export from this module
VariablesToExport = '*'
# Aliases 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 aliases to export.
AliasesToExport = '*'
# DSC resources to export from this module
# DscResourcesToExport = @()
# List of all modules packaged with this module
# ModuleList = @()
# List of all files packaged with this module
# FileList = @()
# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell.
PrivateData = @{
PSData = @{
# Tags applied to this module. These help with module discovery in online galleries.
# Tags = @()
# A URL to the license for this module.
# LicenseUri = ''
# A URL to the main website for this project.
# ProjectUri = ''
# A URL to an icon representing this module.
# IconUri = ''
# ReleaseNotes of this module
# ReleaseNotes = ''
# Prerelease string of this module
# Prerelease = ''
# Flag to indicate whether the module requires explicit user acceptance for install/update/save
# RequireLicenseAcceptance = $false
# External dependent modules of this module
# ExternalModuleDependencies = @()
} # End of PSData hashtable
} # End of PrivateData hashtable
# HelpInfo URI of this module
# HelpInfoURI = ''
# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix.
# DefaultCommandPrefix = ''
}
@@ -0,0 +1,566 @@
<#
.SYNOPSIS
Manages the UI business logic for the "Applications" tab, including "Bring Your Own (BYO) Applications" and "Apps Script Variables".
.DESCRIPTION
This module contains all the functions that power the "Applications" tab in the BuildFFUVM_UI. It handles user interactions for managing custom application lists (BYO Apps), such as adding, removing, reordering, and saving/loading the list from a JSON file (UserAppList.json). It also includes the logic for copying the application source files to the designated staging directory in parallel. Additionally, it manages the UI for creating and removing key-value pairs for the AppsScriptVariables.json file, which allows for custom parameterization of user-provided scripts.
#>
# Function to update the enabled state of BYO Apps action buttons based on selection
function Update-BYOAppsActionButtonsState {
param(
[psobject]$State
)
$listView = $State.Controls.lstApplications
$removeButton = $State.Controls.btnRemoveSelectedBYOApps
$editButton = $State.Controls.btnEditApplication
if ($listView -and $removeButton -and $editButton) {
# Count selected items
$selectedItems = @($listView.Items | Where-Object { $_.IsSelected })
$selectedCount = $selectedItems.Count
# Enable the remove button if any item is selected
$removeButton.IsEnabled = ($selectedCount -gt 0)
# Enable the edit button only if exactly one item is selected
$editButton.IsEnabled = ($selectedCount -eq 1)
}
}
# Function to remove all selected BYO applications
function Remove-SelectedBYOApplications {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[psobject]$State
)
$listView = $State.Controls.lstApplications
$itemsToRemove = @($listView.Items | Where-Object { $_.IsSelected })
if ($itemsToRemove.Count -eq 0) {
# This should not happen if the button is correctly disabled, but as a safeguard:
[System.Windows.MessageBox]::Show("No applications are selected for removal.", "Remove Applications", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
return
}
# Check if the item being edited is among those being removed
if ($null -ne $State.Data.editingBYOApplication -and $itemsToRemove.Contains($State.Data.editingBYOApplication)) {
# Reset the edit state
$State.Data.editingBYOApplication = $null
$State.Controls.btnAddApplication.Content = "Add Application"
# Clear the form fields
$State.Controls.txtAppName.Clear()
$State.Controls.txtAppCommandLine.Clear()
$State.Controls.txtAppArguments.Clear()
$State.Controls.txtAppSource.Clear()
$State.Controls.txtAppAdditionalExitCodes.Clear()
$State.Controls.chkIgnoreExitCodes.IsChecked = $false
}
foreach ($item in $itemsToRemove) {
$listView.Items.Remove($item)
}
# Re-calculate priorities for the remaining items
Update-ListViewPriorities -ListView $listView
# Update button states (Copy and Remove)
Update-CopyButtonState -State $State
Update-BYOAppsActionButtonsState -State $State
# Update the header checkbox state
$headerChk = $State.Controls.chkSelectAllBYOApps
if ($null -ne $headerChk) {
Update-SelectAllHeaderCheckBoxState -ListView $listView -HeaderCheckBox $headerChk
}
# Ask user if they want to save the changes
$result = [System.Windows.MessageBox]::Show("The selected applications have been removed from the list. Do you want to save these changes to UserAppList.json now?", "Save Changes", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question)
if ($result -eq 'Yes') {
$userAppListPath = Join-Path -Path $State.Controls.txtApplicationPath.Text -ChildPath 'UserAppList.json'
Save-BYOApplicationList -Path $userAppListPath -State $State
}
}
# Function to update the enabled state of the Copy Apps button
function Update-CopyButtonState {
param(
[psobject]$State
)
$listView = $State.Controls.lstApplications
$copyButton = $State.Controls.btnCopyBYOApps
if ($listView -and $copyButton) {
$hasSource = $false
foreach ($item in $listView.Items) {
if ($null -ne $item -and $item.PSObject.Properties['Source'] -and -not [string]::IsNullOrWhiteSpace($item.Source)) {
$hasSource = $true
break
}
}
$copyButton.IsEnabled = $hasSource
}
}
# Function to remove application and reorder priorities
function Remove-Application {
param(
$priority,
[psobject]$State
)
$listView = $State.Controls.lstApplications
# Remove the item with the specified priority
$itemToRemove = $listView.Items | Where-Object { $_.Priority -eq $priority } | Select-Object -First 1
if ($itemToRemove) {
$listView.Items.Remove($itemToRemove)
# Reorder priorities for remaining items
Update-ListViewPriorities -ListView $listView
# Update the Copy Apps button state
Update-CopyButtonState -State $State
Update-BYOAppsActionButtonsState -State $State
}
}
# Function to add a new BYO application from the UI
function Add-BYOApplication {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[psobject]$State
)
$name = $State.Controls.txtAppName.Text
$commandLine = $State.Controls.txtAppCommandLine.Text
$arguments = $State.Controls.txtAppArguments.Text
$source = $State.Controls.txtAppSource.Text
$additionalExitCodes = $State.Controls.txtAppAdditionalExitCodes.Text
$ignoreNonZeroExitCodes = $State.Controls.chkIgnoreExitCodes.IsChecked
if ([string]::IsNullOrWhiteSpace($name) -or [string]::IsNullOrWhiteSpace($commandLine)) {
[System.Windows.MessageBox]::Show("Please fill in all fields (Name and Command Line)", "Missing Information", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
return
}
$listView = $State.Controls.lstApplications
# Check if we are in edit mode
if ($null -ne $State.Data.editingBYOApplication) {
$itemToUpdate = $State.Data.editingBYOApplication
# Check for duplicate names, excluding the item being edited
$existingApp = $listView.Items | Where-Object { $_.Name -eq $name -and $_ -ne $itemToUpdate }
if ($existingApp) {
[System.Windows.MessageBox]::Show("An application with the name '$name' already exists.", "Duplicate Name", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
return
}
# Update the properties of the existing object
$itemToUpdate.Name = $name
$itemToUpdate.CommandLine = $commandLine
$itemToUpdate.Arguments = $arguments
$itemToUpdate.Source = $source
$itemToUpdate.AdditionalExitCodes = $additionalExitCodes
$itemToUpdate.IgnoreNonZeroExitCodes = $ignoreNonZeroExitCodes
$itemToUpdate.IgnoreExitCodes = if ($ignoreNonZeroExitCodes) { "Yes" } else { "No" }
# Refresh the ListView to show the changes
$listView.Items.Refresh()
# Reset state
$State.Data.editingBYOApplication = $null
$State.Controls.btnAddApplication.Content = "Add Application"
}
else {
# This is a new application
# Check for duplicate names
$existingApp = $listView.Items | Where-Object { $_.Name -eq $name }
if ($existingApp) {
[System.Windows.MessageBox]::Show("An application with the name '$name' already exists.", "Duplicate Name", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
return
}
$priority = 1
if ($listView.Items.Count -gt 0) {
$priority = ($listView.Items | Measure-Object -Property Priority -Maximum).Maximum + 1
}
$application = [PSCustomObject]@{
IsSelected = $false
Priority = $priority
Name = $name
CommandLine = $commandLine
Arguments = $arguments
Source = $source
AdditionalExitCodes = $additionalExitCodes
IgnoreNonZeroExitCodes = $ignoreNonZeroExitCodes
IgnoreExitCodes = if ($ignoreNonZeroExitCodes) { "Yes" } else { "No" }
CopyStatus = ""
}
$listView.Items.Add($application)
}
# Clear form and update button states for both add and update operations
$State.Controls.txtAppName.Text = ""
$State.Controls.txtAppCommandLine.Text = ""
$State.Controls.txtAppArguments.Text = ""
$State.Controls.txtAppSource.Text = ""
$State.Controls.txtAppAdditionalExitCodes.Text = ""
$State.Controls.chkIgnoreExitCodes.IsChecked = $false
Update-CopyButtonState -State $State
Update-BYOAppsActionButtonsState -State $State
}
# Function to populate the form for editing a BYO application
function Start-EditBYOApplication {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[psobject]$State
)
$listView = $State.Controls.lstApplications
$itemToEdit = @($listView.Items | Where-Object { $_.IsSelected }) | Select-Object -First 1
if ($null -eq $itemToEdit) {
[System.Windows.MessageBox]::Show("No application selected or multiple applications selected.", "Edit Error", "OK", "Warning")
return
}
# Store the item being edited in the state
$State.Data.editingBYOApplication = $itemToEdit
# Populate the form fields
$State.Controls.txtAppName.Text = $itemToEdit.Name
$State.Controls.txtAppCommandLine.Text = $itemToEdit.CommandLine
$State.Controls.txtAppArguments.Text = $itemToEdit.Arguments
$State.Controls.txtAppSource.Text = $itemToEdit.Source
$State.Controls.txtAppAdditionalExitCodes.Text = $itemToEdit.AdditionalExitCodes
$State.Controls.chkIgnoreExitCodes.IsChecked = $itemToEdit.IgnoreNonZeroExitCodes
# Change the Add button to Update
$State.Controls.btnAddApplication.Content = "Update App"
}
# Function to add a new Apps Script Variable from the UI
function Add-AppsScriptVariable {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[psobject]$State
)
$key = $State.Controls.txtAppsScriptKey.Text.Trim()
$value = $State.Controls.txtAppsScriptValue.Text.Trim()
if ([string]::IsNullOrWhiteSpace($key)) {
[System.Windows.MessageBox]::Show("Apps Script Variable Key cannot be empty.", "Input Error", "OK", "Warning")
return
}
# Check for duplicate keys
$existingKey = $State.Controls.lstAppsScriptVariables.Items | Where-Object { $_.Key -eq $key }
if ($existingKey) {
[System.Windows.MessageBox]::Show("An Apps Script Variable with the key '$key' already exists.", "Duplicate Key", "OK", "Warning")
return
}
$newItem = [PSCustomObject]@{
IsSelected = $false # Add IsSelected property
Key = $key
Value = $value
}
$State.Data.appsScriptVariablesDataList.Add($newItem)
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
$State.Controls.txtAppsScriptKey.Clear()
$State.Controls.txtAppsScriptValue.Clear()
# Update the header checkbox state
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
Update-SelectAllHeaderCheckBoxState -ListView $State.Controls.lstAppsScriptVariables -HeaderCheckBox $State.Controls.chkSelectAllAppsScriptVariables
}
}
# Function to remove selected Apps Script Variables from the list
function Remove-SelectedAppsScriptVariable {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[psobject]$State
)
$itemsToRemove = @($State.Data.appsScriptVariablesDataList | Where-Object { $_.IsSelected })
if ($itemsToRemove.Count -eq 0) {
[System.Windows.MessageBox]::Show("Please select one or more Apps Script Variables to remove.", "Selection Error", "OK", "Warning")
return
}
foreach ($itemToRemove in $itemsToRemove) {
$State.Data.appsScriptVariablesDataList.Remove($itemToRemove)
}
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
# Update the header checkbox state
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
Update-SelectAllHeaderCheckBoxState -ListView $State.Controls.lstAppsScriptVariables -HeaderCheckBox $State.Controls.chkSelectAllAppsScriptVariables
}
}
# Function to save BYO applications to JSON
function Save-BYOApplicationList {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Path,
[Parameter(Mandatory)]
[psobject]$State
)
$listView = $State.Controls.lstApplications
if (-not $listView -or $listView.Items.Count -eq 0) {
[System.Windows.MessageBox]::Show("No applications to save.", "Save Applications", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
return
}
try {
# Ensure items are sorted by current priority before saving
# Exclude UI-only properties (CopyStatus, IgnoreExitCodes) and ensure Priority is an integer
$propertiesToSave = 'Priority', 'Name', 'CommandLine', 'Arguments', 'Source', 'AdditionalExitCodes', 'IgnoreNonZeroExitCodes'
$applications = $listView.Items | Sort-Object Priority | Select-Object @{N = 'Priority'; E = { [int]$_.Priority } }, Name, CommandLine, Arguments, Source, AdditionalExitCodes, IgnoreNonZeroExitCodes
$applications | ConvertTo-Json -Depth 5 | Set-Content -Path $Path -Force -Encoding UTF8
[System.Windows.MessageBox]::Show("Applications saved successfully to `"$Path`".", "Save Applications", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
}
catch {
[System.Windows.MessageBox]::Show("Failed to save applications: $_", "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error)
}
}
# Function to load BYO applications from JSON
function Import-BYOApplicationList {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Path,
[Parameter(Mandatory)]
[psobject]$State
)
if (-not (Test-Path $Path)) {
[System.Windows.MessageBox]::Show("Application list file not found at `"$Path`".", "Import Applications", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
return
}
try {
$applications = Get-Content -Path $Path -Raw | ConvertFrom-Json
$listView = $State.Controls.lstApplications
$listView.Items.Clear()
# Add items and sort by priority from the file
$sortedApps = $applications | Sort-Object Priority
foreach ($app in $sortedApps) {
$ignoreNonZero = if ($app.PSObject.Properties['IgnoreNonZeroExitCodes']) { $app.IgnoreNonZeroExitCodes } else { $false }
$appObject = [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 = ""
}
$listView.Items.Add($appObject)
}
# Reorder priorities sequentially after loading
Update-ListViewPriorities -ListView $listView
# Update the Copy Apps button state
Update-CopyButtonState -State $State
Update-BYOAppsActionButtonsState -State $State
[System.Windows.MessageBox]::Show("Applications imported successfully from `"$Path`".", "Import Applications", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
}
catch {
[System.Windows.MessageBox]::Show("Failed to import applications: $_", "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error)
}
}
# Function to invoke the parallel copy process for BYO apps
function Invoke-CopyBYOApps {
param(
[psobject]$State,
[System.Windows.Controls.Button]$Button
)
$localAppsPath = $State.Controls.txtApplicationPath.Text
$userAppListPath = Join-Path -Path $localAppsPath -ChildPath 'UserAppList.json'
$listView = $State.Controls.lstApplications
try {
# 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
WriteLog "Successfully updated UserAppList.json with all applications from the UI."
}
catch {
$errorMessage = "Failed to update UserAppList.json: $_"
WriteLog $errorMessage
[System.Windows.MessageBox]::Show($errorMessage, "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error)
return
}
$allAppsWithSource = $State.Controls.lstApplications.Items | Where-Object { -not [string]::IsNullOrWhiteSpace($_.Source) }
if (-not $allAppsWithSource) {
[System.Windows.MessageBox]::Show("No applications with a source path were found to copy.", "Copy BYO Apps", "OK", "Information")
return
}
$win32BasePath = Join-Path -Path $localAppsPath -ChildPath "Win32"
$appsToProcess = [System.Collections.Generic.List[object]]::new()
$appsThatExist = [System.Collections.Generic.List[string]]::new()
$appsToConfirm = [System.Collections.Generic.List[object]]::new()
foreach ($app in $allAppsWithSource) {
$destinationPath = Join-Path -Path $win32BasePath -ChildPath $app.Name
if (Test-Path -Path $destinationPath -PathType Container) {
$appsThatExist.Add($app.Name)
$appsToConfirm.Add($app)
}
else {
$appsToProcess.Add($app)
}
}
if ($appsThatExist.Count -gt 0) {
$message = "The following application folders already exist in the destination and will be overwritten:`n`n$($appsThatExist -join "`n")`n`nDo you want to proceed with copying and overwriting them?"
$result = [System.Windows.MessageBox]::Show($message, "Confirm Overwrite", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Warning)
if ($result -eq 'Yes') {
$appsToProcess.AddRange($appsToConfirm)
}
}
if ($appsToProcess.Count -eq 0) {
# This message can be suppressed if you prefer no notification when the user clicks "No"
# [System.Windows.MessageBox]::Show("No applications selected for copying.", "Copy BYO Apps", "OK", "Information")
return
}
$Button.IsEnabled = $false
$State.Controls.pbOverallProgress.Visibility = 'Visible'
$State.Controls.pbOverallProgress.Value = 0
$State.Controls.txtStatus.Text = "Starting BYO app copy..."
# Create hashtable for task-specific arguments
$taskArguments = @{
AppsPath = $localAppsPath
}
# Select only necessary properties before passing
$itemsToProcess = $appsToProcess | Select-Object Priority, Name, CommandLine, Arguments, Source
# Invoke the centralized parallel processing function
# Pass task type and task-specific arguments
Invoke-ParallelProcessing -ItemsToProcess $itemsToProcess `
-ListViewControl $State.Controls.lstApplications `
-IdentifierProperty 'Name' `
-StatusProperty 'CopyStatus' `
-TaskType 'CopyBYO' `
-TaskArguments $taskArguments `
-CompletedStatusText "Copied" `
-ErrorStatusPrefix "Error: " `
-WindowObject $State.Window `
-MainThreadLogPath $State.LogFilePath `
-ThrottleLimit $State.Controls.txtThreads.Text
# Final status update (handled by Invoke-ParallelProcessing)
$State.Controls.pbOverallProgress.Visibility = 'Collapsed'
$Button.IsEnabled = $true
}
# Function to copy a single BYO application (Modified for ForEach-Object -Parallel)
function Start-CopyBYOApplicationTask {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[PSCustomObject]$ApplicationItemData, # Pass data, not the UI object
[Parameter(Mandatory)]
[string]$AppsPath, # Pass necessary path
[Parameter(Mandatory = $true)]
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue # Add queue parameter
# REMOVED: UI-related parameters
)
$priority = $ApplicationItemData.Priority
$appName = $ApplicationItemData.Name
$commandLine = $ApplicationItemData.CommandLine
$arguments = $ApplicationItemData.Arguments
$sourcePath = $ApplicationItemData.Source
$status = "Starting..." # Initial local status
$success = $false
# Initial status update
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status
if ([string]::IsNullOrWhiteSpace($AppsPath)) {
$status = "Error: Apps Path not set"
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status
WriteLog "Copy error for $($appName): Apps Path not set."
return [PSCustomObject]@{ Name = $appName; Status = $status; Success = $success }
}
if ([string]::IsNullOrWhiteSpace($sourcePath)) {
$status = "No source specified"
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status
# This isn't an error, just nothing to do. Consider it success.
$success = $true
return [PSCustomObject]@{ Name = $appName; Status = $status; Success = $success }
}
if (-not (Test-Path -Path $sourcePath -PathType Container)) {
$status = "Source path not found"
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status
WriteLog "Copy error for $($appName): Source path '$sourcePath' not found."
return [PSCustomObject]@{ Name = $appName; Status = $status; Success = $success }
}
$win32BasePath = Join-Path -Path $AppsPath -ChildPath "Win32"
$destinationPath = Join-Path -Path $win32BasePath -ChildPath $appName
try {
# Ensure base directory exists
if (-not (Test-Path -Path $win32BasePath -PathType Container)) {
New-Item -Path $win32BasePath -ItemType Directory -Force | Out-Null
WriteLog "Created directory: $win32BasePath"
}
# If destination exists, remove it to ensure a clean copy and prevent nesting.
if (Test-Path -Path $destinationPath -PathType Container) {
WriteLog "Removing existing destination folder: $destinationPath"
Remove-Item -Path $destinationPath -Recurse -Force -ErrorAction Stop
}
# Perform the copy
$status = "Copying..."
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status
WriteLog "Copying '$sourcePath' to '$destinationPath'..."
Copy-Item -Path $sourcePath -Destination $destinationPath -Recurse -Force -ErrorAction Stop
$status = "Copied successfully"
$success = $true
WriteLog "Successfully copied '$appName' to '$destinationPath'."
}
catch {
$errorMessage = $_.Exception.Message
$status = "Error: $($errorMessage)"
WriteLog "Copy error for $($appName): $($errorMessage)"
$success = $false
# Enqueue error status
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appName -Status $status
}
# Return the final status
return [PSCustomObject]@{ Name = $appName; Status = $status; Success = $success }
}
Export-ModuleMember -Function *
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,711 @@
<#
.SYNOPSIS
Provides functions for discovering, downloading, and processing Dell device drivers.
.DESCRIPTION
This module contains the logic specific to handling Dell drivers for the FFU Builder UI. It includes functions to parse Dell's large XML driver catalog to retrieve a list of supported models (Get-DellDriversModelList). It also provides a parallel-capable task function (Save-DellDriversTask) that finds, downloads, extracts, and optionally compresses all the latest driver packages for a specified Dell model and operating system.
#>
# Function to get the list of Dell models from the catalog using XML streaming
function Get-DellDriversModelList {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[int]$WindowsRelease,
[Parameter(Mandatory = $true)]
[string]$DriversFolder, # Base drivers folder (e.g., C:\FFUDevelopment\Drivers)
[Parameter(Mandatory = $true)]
[string]$Make # Should be 'Dell'
)
# Define Dell specific drivers folder and catalog file names
$dellDriversFolder = Join-Path -Path $DriversFolder -ChildPath "Dell"
$catalogBaseName = if ($WindowsRelease -le 11) { "CatalogPC" } else { "Catalog" }
$dellCabFile = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).cab"
$dellCatalogXML = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).xml"
$catalogUrl = if ($WindowsRelease -le 11) { "http://downloads.dell.com/catalog/CatalogPC.cab" } else { "https://downloads.dell.com/catalog/Catalog.cab" }
$uniqueModelNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$reader = $null
try {
# Check if the Dell catalog XML exists and is recent
$downloadCatalog = $true
if (Test-Path -Path $dellCatalogXML -PathType Leaf) {
WriteLog "Dell Catalog XML found: $dellCatalogXML"
$dellCatalogCreationTime = (Get-Item $dellCatalogXML).CreationTime
WriteLog "Dell Catalog XML Creation time: $dellCatalogCreationTime"
# Check if the XML file is less than 7 days old
if (((Get-Date) - $dellCatalogCreationTime).TotalDays -lt 7) {
WriteLog "Using existing Dell Catalog XML (less than 7 days old): $dellCatalogXML"
$downloadCatalog = $false
}
else {
WriteLog "Existing Dell Catalog XML is older than 7 days: $dellCatalogXML"
}
}
else {
WriteLog "Dell Catalog XML not found: $dellCatalogXML"
}
if ($downloadCatalog) {
WriteLog "Attempting to download and extract Dell Catalog for Get-DellDriversModelList..."
# Ensure Dell drivers folder exists
if (-not (Test-Path -Path $dellDriversFolder -PathType Container)) {
WriteLog "Creating Dell drivers folder: $dellDriversFolder"
New-Item -Path $dellDriversFolder -ItemType Directory -Force | Out-Null
}
# Check URL accessibility
try {
$request = [System.Net.WebRequest]::Create($catalogUrl)
$request.Method = 'HEAD'; $response = $request.GetResponse(); $response.Close()
}
catch { throw "Dell Catalog URL '$catalogUrl' not accessible: $($_.Exception.Message)" }
# Remove existing files before download if they exist
if (Test-Path -Path $dellCabFile) { Remove-Item -Path $dellCabFile -Force -ErrorAction SilentlyContinue }
if (Test-Path -Path $dellCatalogXML) { Remove-Item -Path $dellCatalogXML -Force -ErrorAction SilentlyContinue }
WriteLog "Downloading Dell Catalog cab file: $catalogUrl to $dellCabFile"
Start-BitsTransferWithRetry -Source $catalogUrl -Destination $dellCabFile
WriteLog "Dell Catalog cab file downloaded to $dellCabFile"
WriteLog "Extracting Dell Catalog cab file '$dellCabFile' to '$dellCatalogXML'"
Invoke-Process -FilePath "Expand.exe" -ArgumentList """$dellCabFile"" ""$dellCatalogXML""" | Out-Null
WriteLog "Dell Catalog cab file extracted to $dellCatalogXML"
# Delete the CAB file after extraction
WriteLog "Deleting Dell Catalog CAB file: $dellCabFile"
Remove-Item -Path $dellCabFile -Force -ErrorAction SilentlyContinue
}
# Ensure the XML file exists before trying to read it
if (-not (Test-Path -Path $dellCatalogXML -PathType Leaf)) {
throw "Dell Catalog XML file '$dellCatalogXML' not found after download/check attempt."
}
# Use XmlReader for streaming from the XML file
$settings = New-Object System.Xml.XmlReaderSettings
$settings.IgnoreWhitespace = $true
$settings.IgnoreComments = $true
# $settings.DtdProcessing = [System.Xml.DtdProcessing]::Ignore # Optional
$reader = [System.Xml.XmlReader]::Create($dellCatalogXML, $settings)
WriteLog "Starting XML stream parsing for Dell models from '$dellCatalogXML'..."
$isDriverComponent = $false
$isModelElement = $false
$modelDepth = -1 # Track depth to handle nested elements if needed
# Read through the XML stream node by node
while ($reader.Read()) {
switch ($reader.NodeType) {
([System.Xml.XmlNodeType]::Element) {
switch ($reader.Name) {
'SoftwareComponent' { $isDriverComponent = $false } # Reset flag
'ComponentType' { if ($reader.GetAttribute('value') -eq 'DRVR') { $isDriverComponent = $true } }
'Model' { if ($isDriverComponent) { $isModelElement = $true; $modelDepth = $reader.Depth } }
}
}
([System.Xml.XmlNodeType]::CDATA) {
if ($isModelElement -and $isDriverComponent) {
$modelName = $reader.Value.Trim()
if (-not [string]::IsNullOrWhiteSpace($modelName)) { $uniqueModelNames.Add($modelName) | Out-Null }
$isModelElement = $false # Reset after reading CDATA
}
}
([System.Xml.XmlNodeType]::EndElement) {
switch ($reader.Name) {
'SoftwareComponent' { $isDriverComponent = $false; $isModelElement = $false; $modelDepth = -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 {
# Ensure the reader is closed and disposed
if ($null -ne $reader) {
$reader.Dispose()
}
# Ensure CAB file is deleted even if extraction failed but download succeeded
if (Test-Path -Path $dellCabFile) {
WriteLog "Cleaning up downloaded Dell CAB file: $dellCabFile"
Remove-Item -Path $dellCabFile -Force -ErrorAction SilentlyContinue
}
}
# Convert HashSet to sorted list of PSCustomObjects
$models = [System.Collections.Generic.List[PSCustomObject]]::new()
foreach ($modelName in ($uniqueModelNames | Sort-Object)) {
$models.Add([PSCustomObject]@{
Make = $Make
Model = $modelName
# Link is not applicable here like for Microsoft
})
}
return $models
}
# Function to download and extract drivers for a specific Dell model (Modified for ForEach-Object -Parallel)
function Save-DellDriversTask {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[PSCustomObject]$DriverItemData, # Contains Model property
[Parameter(Mandatory = $true)]
[string]$DriversFolder, # Base drivers folder (e.g., C:\FFUDevelopment\Drivers)
[Parameter(Mandatory = $true)]
[string]$WindowsArch,
[Parameter(Mandatory = $true)]
[int]$WindowsRelease,
[Parameter()]
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null
[Parameter()]
[bool]$CompressToWim = $false, # New parameter for compression
[Parameter()]
[bool]$PreserveSourceOnCompress = $false
)
$modelName = $DriverItemData.Model
$make = "Dell" # Hardcoded for this task
$status = "Starting..." # Initial local status
$success = $false
# Initial status update
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Checking..." }
$sanitizedModelName = ConvertTo-SafeName -Name $modelName
if ($sanitizedModelName -ne $modelName) { WriteLog "Sanitized model name: '$modelName' -> '$sanitizedModelName'" }
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $Make
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $sanitizedModelName
$driverRelativePath = Join-Path -Path $make -ChildPath $sanitizedModelName # Relative path for the driver folder
try {
# Check for existing drivers
$existingDriver = Test-ExistingDriver -Make $make -Model $sanitizedModelName -DriversFolder $DriversFolder -Identifier $modelName -ProgressQueue $ProgressQueue
if ($null -ne $existingDriver) {
# Add the 'Model' property to the return object for consistency if it's not there
if (-not $existingDriver.PSObject.Properties['Model']) {
$existingDriver | Add-Member -MemberType NoteProperty -Name 'Model' -Value $modelName
}
# Special handling for existing folders that need compression
if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') {
$wimFilePath = Join-Path -Path $makeDriversPath -ChildPath "$($sanitizedModelName).wim"
$sourceFolderPath = Join-Path -Path $makeDriversPath -ChildPath $sanitizedModelName
WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Compressing existing..." }
try {
Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $modelName -WimDescription "Drivers for $modelName" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
$existingDriver.Status = "Already downloaded & Compressed"
$existingDriver.DriverPath = Join-Path -Path $make -ChildPath "$($sanitizedModelName).wim"
$existingDriver.Success = $true
WriteLog "Successfully compressed existing drivers for $modelName to $wimFilePath."
}
catch {
WriteLog "Error compressing existing drivers for $($modelName): $($_.Exception.Message)"
$existingDriver.Status = "Already downloaded (Compression failed)"
$existingDriver.Success = $false
}
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $existingDriver.Status }
}
return $existingDriver
}
# Define paths for Dell catalog. The catalog is assumed to be prepared by the calling function.
$dellDriversFolder = Join-Path -Path $DriversFolder -ChildPath "Dell"
$catalogBaseName = if ($WindowsRelease -le 11) { "CatalogPC" } else { "Catalog" }
$dellCatalogXML = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).xml"
# 3. Parse the *EXISTING* XML and Find Drivers for *this specific model*
$status = "Finding drivers..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Check if the provided XML path exists
if (-not (Test-Path -Path $dellCatalogXML -PathType Leaf)) {
throw "Dell Catalog XML file not found at specified path: $dellCatalogXML"
}
WriteLog "Parsing existing Dell Catalog XML for model '$modelName' from: $dellCatalogXML"
# Initialize variables
$baseLocation = $null
$latestDrivers = @{} # Hashtable to store latest drivers for this model
$modelSpecificDriversFound = $false
# Create XML reader settings
$settings = New-Object System.Xml.XmlReaderSettings
$settings.IgnoreWhitespace = $true
$settings.IgnoreComments = $true
# Create XML reader
$reader = $null
try {
$reader = [System.Xml.XmlReader]::Create($dellCatalogXML, $settings)
# First pass - get baseLocation from manifest
while ($reader.Read()) {
if ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq "Manifest") {
$baseLocationAttr = $reader.GetAttribute("baseLocation")
if ($null -ne $baseLocationAttr) {
$baseLocation = "https://" + $baseLocationAttr + "/"
break
}
}
}
if ($null -eq $baseLocation) {
throw "Invalid Dell Catalog XML format: Missing 'baseLocation' attribute in Manifest element."
}
# Reset reader for second pass
$reader.Dispose()
$reader = [System.Xml.XmlReader]::Create($dellCatalogXML, $settings)
# Process SoftwareComponents
while ($reader.Read()) {
if ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq "SoftwareComponent") {
# Read the entire SoftwareComponent subtree
$componentXml = $reader.ReadSubtree()
$component = New-Object System.Xml.XmlDocument
$component.Load($componentXml)
$componentXml.Dispose()
# Check if it's a driver component
$componentTypeNode = $component.SelectSingleNode("//ComponentType[@value='DRVR']")
if ($null -eq $componentTypeNode) {
continue
}
# Check if component supports the model
$modelNodes = $component.SelectNodes("//SupportedSystems/Brand/Model")
$modelMatch = $false
foreach ($modelNode in $modelNodes) {
$displayNode = $modelNode.SelectSingleNode("Display")
if ($null -ne $displayNode -and $displayNode.InnerText.Trim() -eq $modelName) {
$modelMatch = $true
break
}
}
if ($modelMatch) {
# Check OS compatibility
$validOS = $null
$osNodes = $component.SelectNodes("//SupportedOperatingSystems/OperatingSystem")
if ($null -ne $osNodes) {
foreach ($osNode in $osNodes) {
$osArch = $osNode.GetAttribute("osArch")
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 {
$compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $modelName -WimDescription $modelName -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
if ($compressResult) {
WriteLog "Compression successful for '$modelName'."
$status = "Completed & Compressed"
}
else {
WriteLog "Compression failed for '$modelName'. Check verbose/error output from Compress-DriverFolderToWim."
$status = "Completed (Compression Failed)"
}
}
catch {
WriteLog "Error during compression for '$modelName': $($_.Exception.Message)"
$status = "Completed (Compression Error)"
}
}
else {
$status = "Completed" # Final status if not compressing
}
# --- End Compression ---
$success = $true # Mark success as download/extract was okay
}
catch {
$status = "Error: $($_.Exception.Message.Split('.')[0])" # Shorten error message
WriteLog "Error saving Dell drivers for $($modelName): $($_.Exception.ToString())" # Log full exception string
$success = $false
# Enqueue the error status before returning
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Ensure return object is created even on error
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $success; DriverPath = $null }
}
# 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 *
@@ -0,0 +1,396 @@
<#
.SYNOPSIS
Provides functions to retrieve HP model lists and download corresponding driver packs.
.DESCRIPTION
This module contains the logic specific to handling HP drivers for the FFU Builder UI. It includes functions to:
- Download and parse the HP PlatformList.xml to generate a list of supported HP computer models.
- For a selected model, find the most appropriate driver pack based on the specified Windows release and version, with intelligent fallback logic.
- Download the driver pack, extract all individual driver installers, and then extract the driver files from each installer.
- Optionally, compress the final extracted drivers into a single WIM file for easier deployment.
These functions are designed to be called by the main UI logic, often in parallel, to efficiently manage driver acquisition.
#>
# Function to get the list of HP models from the PlatformList.xml
function Get-HPDriversModelList {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[string]$DriversFolder,
[Parameter(Mandatory = $true)]
[string]$Make # Expected to be 'HP'
)
WriteLog "Getting HP driver model list..."
$hpDriversFolder = Join-Path -Path $DriversFolder -ChildPath $Make
$platformListUrl = 'https://hpia.hpcloud.hp.com/ref/platformList.cab'
$platformListCab = Join-Path -Path $hpDriversFolder -ChildPath "platformList.cab"
$platformListXml = Join-Path -Path $hpDriversFolder -ChildPath "PlatformList.xml"
$modelList = [System.Collections.Generic.List[PSCustomObject]]::new()
try {
# Ensure HP drivers folder exists
if (-not (Test-Path -Path $hpDriversFolder)) {
WriteLog "Creating HP Drivers folder: $hpDriversFolder"
New-Item -Path $hpDriversFolder -ItemType Directory -Force | Out-Null
}
# Download PlatformList.cab if it doesn't exist or is outdated (e.g., older than 7 days)
if (-not (Test-Path -Path $platformListCab) -or ((Get-Date) - (Get-Item $platformListCab).LastWriteTime).TotalDays -gt 7) {
WriteLog "Downloading $platformListUrl to $platformListCab"
# Use the private helper function for download with retry
Start-BitsTransferWithRetry -Source $platformListUrl -Destination $platformListCab -ErrorAction Stop
WriteLog "PlatformList.cab download complete."
# Force extraction if downloaded
if (Test-Path -Path $platformListXml) {
Remove-Item -Path $platformListXml -Force
}
}
else {
WriteLog "Using existing PlatformList.cab found at $platformListCab"
}
# Extract PlatformList.xml if it doesn't exist
if (-not (Test-Path -Path $platformListXml)) {
WriteLog "Expanding $platformListCab to $platformListXml"
# Use the private helper function for process invocation
Invoke-Process -FilePath "expand.exe" -ArgumentList @("`"$platformListCab`"", "`"$platformListXml`"") -ErrorAction Stop | Out-Null
WriteLog "PlatformList.xml extraction complete."
}
else {
WriteLog "Using existing PlatformList.xml found at $platformListXml"
}
# Parse the PlatformList.xml using XmlReader for efficiency
WriteLog "Parsing PlatformList.xml to extract HP models..."
$settings = New-Object System.Xml.XmlReaderSettings
$settings.Async = $false # Ensure synchronous reading
$reader = [System.Xml.XmlReader]::Create($platformListXml, $settings)
$uniqueModels = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
while ($reader.Read()) {
if ($reader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $reader.Name -eq 'Platform') {
# Read the inner content of the Platform node
$platformReader = $reader.ReadSubtree()
while ($platformReader.Read()) {
if ($platformReader.NodeType -eq [System.Xml.XmlNodeType]::Element -and $platformReader.Name -eq 'ProductName') {
$modelName = $platformReader.ReadElementContentAsString()
if (-not [string]::IsNullOrWhiteSpace($modelName) -and $uniqueModels.Add($modelName)) {
# Add to list only if it's a new unique model
$modelList.Add([PSCustomObject]@{
Make = $Make
Model = $modelName
})
}
}
}
$platformReader.Close()
}
}
$reader.Close()
WriteLog "Successfully parsed $($modelList.Count) unique HP models from PlatformList.xml."
}
catch {
WriteLog "Error getting HP driver model list: $($_.Exception.Message)"
}
# Sort the list alphabetically by Model name before returning
return $modelList | Sort-Object -Property Model
}
# Function to download and extract drivers for a specific HP model (Designed for ForEach-Object -Parallel)
function Save-HPDriversTask {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[PSCustomObject]$DriverItemData, # Contains Make, Model
[Parameter(Mandatory = $true)]
[string]$DriversFolder,
[Parameter(Mandatory = $true)]
[ValidateSet("x64", "x86", "ARM64")]
[string]$WindowsArch,
[Parameter(Mandatory = $true)]
[ValidateSet(10, 11)]
[int]$WindowsRelease,
[Parameter(Mandatory = $true)]
[string]$WindowsVersion, # e.g., 22H2, 23H2, etc.
[Parameter()] # Made optional
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null
[Parameter()]
[bool]$CompressToWim = $false, # New parameter for compression
[Parameter()]
[bool]$PreserveSourceOnCompress = $false
)
$modelName = $DriverItemData.Model
$make = $DriverItemData.Make # Should be 'HP'
$identifier = $modelName # Unique identifier for progress updates
$sanitizedModelName = ConvertTo-SafeName -Name $modelName
if ($sanitizedModelName -ne $modelName) { WriteLog "Sanitized model name: '$modelName' -> '$sanitizedModelName'" }
$hpDriversBaseFolder = Join-Path -Path $DriversFolder -ChildPath $make # Changed variable name for clarity
$platformListXml = Join-Path -Path $hpDriversBaseFolder -ChildPath "PlatformList.xml"
$modelSpecificFolder = Join-Path -Path $hpDriversBaseFolder -ChildPath $sanitizedModelName # Sanitize model name for folder path
$driverRelativePath = Join-Path -Path $make -ChildPath $sanitizedModelName # Relative path for the driver folder
$finalStatus = "" # Initialize final status
$successState = $true # Assume success unless an operation fails
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Checking HP drivers for $modelName..." }
try {
# Check for existing drivers
$existingDriver = Test-ExistingDriver -Make $make -Model $sanitizedModelName -DriversFolder $DriversFolder -Identifier $identifier -ProgressQueue $ProgressQueue
if ($null -ne $existingDriver) {
# The return object from Test-ExistingDriver uses 'Model' as the identifier key.
# We need to return 'Identifier' for HP's logic.
$existingDriver | Add-Member -MemberType NoteProperty -Name 'Identifier' -Value $identifier -Force
$existingDriver.PSObject.Properties.Remove('Model')
# Special handling for existing folders that need compression
if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') {
$wimFilePath = Join-Path -Path $hpDriversBaseFolder -ChildPath "$($sanitizedModelName).wim"
$sourceFolderPath = Join-Path -Path $hpDriversBaseFolder -ChildPath $sanitizedModelName
WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Compressing existing..." }
try {
Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
$existingDriver.Status = "Already downloaded & Compressed"
$existingDriver.DriverPath = Join-Path -Path $make -ChildPath "$($sanitizedModelName).wim"
$existingDriver.Success = $true
WriteLog "Successfully compressed existing drivers for $identifier to $wimFilePath."
}
catch {
WriteLog "Error compressing existing drivers for $($identifier): $($_.Exception.Message)"
$existingDriver.Status = "Already downloaded (Compression failed)"
$existingDriver.Success = $false
}
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $existingDriver.Status }
}
return $existingDriver
}
# If folder does not exist, proceed with download and extraction
WriteLog "HP drivers for '$identifier' not found locally. Starting download process..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Downloading..." }
# Ensure PlatformList.xml exists (it should have been downloaded by Get-HPDriversModelList)
if (-not (Test-Path -Path $platformListXml)) {
# Attempt to download/extract it again if missing
WriteLog "PlatformList.xml not found for HP task, attempting download/extract..."
$platformListUrl = 'https://hpia.hpcloud.hp.com/ref/platformList.cab'
$platformListCab = Join-Path -Path $hpDriversBaseFolder -ChildPath "platformList.cab"
# Base folder already checked/created
Start-BitsTransferWithRetry -Source $platformListUrl -Destination $platformListCab -ErrorAction Stop
if (Test-Path -Path $platformListXml) { Remove-Item -Path $platformListXml -Force }
Invoke-Process -FilePath "expand.exe" -ArgumentList @("`"$platformListCab`"", "`"$platformListXml`"") -ErrorAction Stop | Out-Null
WriteLog "PlatformList.xml download/extract complete for HP task."
if (-not (Test-Path -Path $platformListXml)) {
throw "Failed to obtain PlatformList.xml for HP driver task."
}
}
# Parse PlatformList.xml to find SystemID and OSReleaseID for the specific model
WriteLog "Parsing $platformListXml for model '$modelName' details..."
[xml]$platformListContent = Get-Content -Path $platformListXml -Raw -Encoding UTF8 -ErrorAction Stop
$platformNode = $platformListContent.ImagePal.Platform | Where-Object { $_.ProductName.'#text' -match "^$([regex]::Escape($modelName))$" } | Select-Object -First 1
if ($null -eq $platformNode) {
throw "Model '$modelName' not found in PlatformList.xml."
}
$systemID = $platformNode.SystemID
# --- OS Node Selection with Fallback Logic ---
$selectedOSNode = $null
$selectedOSVersion = $null
$selectedOSRelease = $WindowsRelease # Start with the requested release
# Complete list of Windows 11 feature-update versions (newest to oldest)
$win11Versions = @(
"24H2", "23H2", "22H2", "21H2"
)
# Complete list of Windows 10 feature-update versions (newest to oldest)
$win10Versions = @(
"22H2", "21H2", "21H1", "20H2", "2004", "1909", "1903", "1809", "1803", "1709", "1703", "1607", "1511", "1507"
)
# Helper function to find a matching OS node for a given release and version list
function Find-MatchingOSNode {
param(
[int]$ReleaseToSearch,
[array]$VersionsToSearch
)
$osNodesForRelease = $platformNode.OS | Where-Object {
($ReleaseToSearch -eq 11 -and $_.IsWindows11 -contains 'true') -or
($ReleaseToSearch -eq 10 -and ($null -eq $_.IsWindows11 -or $_.IsWindows11 -notcontains 'true'))
}
if ($null -eq $osNodesForRelease) { return $null }
foreach ($version in $VersionsToSearch) {
foreach ($osNode in $osNodesForRelease) {
$releaseIDs = $osNode.OSReleaseIdFileName -replace 'H', 'h' -split ' '
if ($releaseIDs -contains $version.ToLower()) {
return @{ Node = $osNode; Version = $version }
}
}
}
return $null
}
# 1. Attempt Exact Match (Requested Release and Version)
WriteLog "Attempting to find exact match for Win$($WindowsRelease) ($($WindowsVersion))..."
$exactMatchResult = Find-MatchingOSNode -ReleaseToSearch $WindowsRelease -VersionsToSearch @($WindowsVersion)
if ($null -ne $exactMatchResult) {
$selectedOSNode = $exactMatchResult.Node
$selectedOSVersion = $exactMatchResult.Version
WriteLog "Exact match found: Win$($selectedOSRelease) ($($selectedOSVersion))."
}
else {
WriteLog "Exact match not found for Win$($WindowsRelease) ($($WindowsVersion))."
# 2. Fallback: Same Release, Other Versions (Newest First)
WriteLog "Attempting fallback within Win$($WindowsRelease)..."
$versionsForCurrentRelease = if ($WindowsRelease -eq 11) { $win11Versions } else { $win10Versions }
$fallbackVersions = $versionsForCurrentRelease | Where-Object { $_ -ne $WindowsVersion }
$fallbackResult = Find-MatchingOSNode -ReleaseToSearch $WindowsRelease -VersionsToSearch $fallbackVersions
if ($null -ne $fallbackResult) {
$selectedOSNode = $fallbackResult.Node
$selectedOSVersion = $fallbackResult.Version
WriteLog "Fallback successful within Win$($selectedOSRelease). Using version: $($selectedOSVersion)."
}
else {
WriteLog "Fallback within Win$($WindowsRelease) unsuccessful."
# 3. Fallback: Other Release, Versions (Newest First)
$otherRelease = if ($WindowsRelease -eq 11) { 10 } else { 11 }
WriteLog "Attempting fallback to Win$($otherRelease)..."
$versionsForOtherRelease = if ($otherRelease -eq 11) { $win11Versions } else { $win10Versions }
$otherFallbackResult = Find-MatchingOSNode -ReleaseToSearch $otherRelease -VersionsToSearch $versionsForOtherRelease
if ($null -ne $otherFallbackResult) {
$selectedOSNode = $otherFallbackResult.Node
$selectedOSVersion = $otherFallbackResult.Version
$selectedOSRelease = $otherRelease
WriteLog "Fallback successful to Win$($selectedOSRelease). Using version: $($selectedOSVersion)."
}
else {
WriteLog "Fallback to Win$($otherRelease) also failed."
}
}
}
if ($null -eq $selectedOSNode) {
$allAvailableVersions = @()
if ($platformNode.OS) {
foreach ($osNode in $platformNode.OS) {
$osRel = if ($osNode.IsWindows11 -contains 'true') { 11 } else { 10 }
$relIDs = $osNode.OSReleaseIdFileName -replace 'H', 'h' -split ' '
foreach ($id in $relIDs) { $allAvailableVersions += "Win$($osRel) $($id)" }
}
}
$availableVersionsString = ($allAvailableVersions | Select-Object -Unique) -join ', '
if ([string]::IsNullOrWhiteSpace($availableVersionsString)) { $availableVersionsString = "None" }
throw "Could not find any suitable OS driver pack for model '$modelName' matching requested or fallback versions (Win$($WindowsRelease) $WindowsVersion). Available: $availableVersionsString"
}
$osReleaseIdFileName = $selectedOSNode.OSReleaseIdFileName -replace 'H', 'h'
WriteLog "Using SystemID: $systemID and OS Info: Win$($selectedOSRelease) ($($selectedOSVersion)) for '$modelName'"
$archSuffix = $WindowsArch -replace "^x", ""
$modelRelease = "$($systemID)_$($archSuffix)_$($selectedOSRelease).0.$($selectedOSVersion.ToLower())"
$driverCabUrl = "https://hpia.hpcloud.hp.com/ref/$systemID/$modelRelease.cab"
$driverCabFile = Join-Path -Path $hpDriversBaseFolder -ChildPath "$modelRelease.cab" # Store in base HP folder
$driverXmlFile = Join-Path -Path $hpDriversBaseFolder -ChildPath "$modelRelease.xml" # Store in base HP folder
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Downloading driver index..." }
WriteLog "Downloading HP Driver cab from $driverCabUrl to $driverCabFile"
Start-BitsTransferWithRetry -Source $driverCabUrl -Destination $driverCabFile -ErrorAction Stop
WriteLog "Expanding HP Driver cab $driverCabFile to $driverXmlFile"
if (Test-Path -Path $driverXmlFile) { Remove-Item -Path $driverXmlFile -Force }
Invoke-Process -FilePath "expand.exe" -ArgumentList @("`"$driverCabFile`"", "`"$driverXmlFile`"") -ErrorAction Stop | Out-Null
WriteLog "Parsing driver XML $driverXmlFile"
[xml]$driverXmlContent = Get-Content -Path $driverXmlFile -Raw -Encoding UTF8 -ErrorAction Stop
$updates = $driverXmlContent.ImagePal.Solutions.UpdateInfo | Where-Object { $_.Category -match '^Driver' }
$totalDrivers = ($updates | Measure-Object).Count
$downloadedCount = 0
WriteLog "Found $totalDrivers driver updates for $modelName."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Found $totalDrivers drivers. Downloading..." }
if (-not (Test-Path -Path $modelSpecificFolder)) {
New-Item -Path $modelSpecificFolder -ItemType Directory -Force -ErrorAction Stop | Out-Null
}
foreach ($update in $updates) {
$driverName = $update.Name -replace '[\\/:"*?<>|]', '_'
$category = $update.Category -replace '[\\/:"*?<>|]', '_'
$version = $update.Version -replace '[\\/:"*?<>|]', '_'
$driverUrl = "https://$($update.URL)"
$driverFileName = Split-Path -Path $driverUrl -Leaf
$downloadFolder = Join-Path -Path $modelSpecificFolder -ChildPath $category
$driverFilePath = Join-Path -Path $downloadFolder -ChildPath $driverFileName
$extractFolder = Join-Path -Path $downloadFolder -ChildPath ($driverName + "_" + $version + "_" + ($driverFileName -replace '\.exe$', ''))
$downloadedCount++
$progressMsg = "($downloadedCount/$totalDrivers) Downloading $driverName..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $progressMsg }
WriteLog "$progressMsg URL: $driverUrl"
if (Test-Path -Path $extractFolder) {
WriteLog "Driver already extracted to $extractFolder, skipping download."
continue
}
if (-not (Test-Path -Path $downloadFolder)) {
New-Item -Path $downloadFolder -ItemType Directory -Force -ErrorAction Stop | Out-Null
}
WriteLog "Downloading driver to: $driverFilePath"
Start-BitsTransferWithRetry -Source $driverUrl -Destination $driverFilePath -ErrorAction Stop
WriteLog "Driver downloaded: $driverFilePath"
WriteLog "Creating extraction folder: $extractFolder"
New-Item -Path $extractFolder -ItemType Directory -Force -ErrorAction Stop | Out-Null
$arguments = "/s /e /f `"$extractFolder`""
WriteLog "Extracting driver $driverFilePath with args: $arguments"
WriteLog "Running HP Driver Extraction Command: $driverFilePath $arguments"
Invoke-Process -FilePath $driverFilePath -ArgumentList $arguments -ErrorAction Stop | Out-Null
# Start-Process -FilePath $driverFilePath -ArgumentList $arguments -Wait -NoNewWindow -ErrorAction Stop | Out-Null
WriteLog "Driver extracted to: $extractFolder"
Remove-Item -Path $driverFilePath -Force -ErrorAction SilentlyContinue
WriteLog "Deleted driver installer: $driverFilePath"
}
Remove-Item -Path $driverCabFile, $driverXmlFile -Force -ErrorAction SilentlyContinue
WriteLog "Cleaned up driver cab and xml files for $modelName"
$finalStatus = "Completed"
if ($CompressToWim) {
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Compressing..." }
$wimFilePath = Join-Path -Path $hpDriversBaseFolder -ChildPath "$($identifier).wim"
WriteLog "Compressing '$modelSpecificFolder' to '$wimFilePath'..."
try {
Compress-DriverFolderToWim -SourceFolderPath $modelSpecificFolder -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
WriteLog "Compression successful for '$identifier'."
$finalStatus = "Completed & Compressed"
$driverRelativePath = Join-Path -Path $make -ChildPath "$($identifier).wim" # Update relative path to the WIM
}
catch {
WriteLog "Error during compression for '$identifier': $($_.Exception.Message)"
$finalStatus = "Completed (Compression Failed)"
}
}
$successState = $true
}
catch {
$errorMessage = "Error saving HP drivers for $($modelName): $($_.Exception.Message)"
WriteLog $errorMessage
$finalStatus = "Error: $($_.Exception.Message.Split([Environment]::NewLine)[0])"
$successState = $false
$driverRelativePath = $null # Ensure path is null on error
if (Test-Path -Path $modelSpecificFolder -PathType Container) {
WriteLog "Attempting to remove partially created folder $modelSpecificFolder due to error."
Remove-Item -Path $modelSpecificFolder -Recurse -Force -ErrorAction SilentlyContinue
}
}
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $finalStatus }
return [PSCustomObject]@{ Identifier = $identifier; Status = $finalStatus; Success = $successState; DriverPath = $driverRelativePath }
}
Export-ModuleMember -Function *
@@ -0,0 +1,475 @@
<#
.SYNOPSIS
Provides functions for discovering, downloading, and processing Lenovo drivers.
.DESCRIPTION
This module contains the logic specific to handling Lenovo drivers for the FFU Builder UI. It includes functions to query the Lenovo PSREF (Product Specification Reference) API to find and list available system models based on user search terms. It also provides the core background task for downloading all relevant driver packages for a selected model and Windows release. The download process involves parsing XML catalogs, downloading individual driver executables, silently extracting their contents, and organizing them into a structured folder. The module includes robust error handling, long path mitigation by using temporary extraction locations, and an option to compress the final driver set into a WIM archive.
#>
# Function to get the list of Lenovo models using the PSREF API
function Get-LenovoDriversModelList {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ModelSearchTerm, # User input for model/machine type
[Parameter(Mandatory = $true)]
[hashtable]$Headers,
[Parameter(Mandatory = $true)]
[string]$UserAgent
)
# Lenovo is special - they prevent access to the PSREF API without a cookie as of July 2025.
# This cookie must be retrieved via Javascript
# It appears that the cookie is hard-coded. We'll see how long this lasts.
# If anyone knows how to reliably get the the model and machine type information from Lenovo, let me know.
# https://download.lenovo.com/cdrt/td/catalogv2.xml only provides a subset of the information available from PSREF (e.g. it's missing 300w, 500w, and other consumer models).
# $lenovoCookie = "X-PSREF-USER-TOKEN=eyJ0eXAiOiJKV1QifQ.bjVTdWk0YklZeUc2WnFzL0lXU0pTeU1JcFo0aExzRXl1UGxHN3lnS1BtckI0ZVU5WEJyVGkvaFE0NmVNU2U1ZjNrK3ZqTEVIZ29nTk1TNS9DQmIwQ0pTN1Q1VytlY1RpNzZTUldXbm4wZ1g2RGJuQWg4MXRkTmxKT2YrOW9LRjBzQUZzV05HM3NpcU92WFVTM0o0blM1SDQyUlVXNThIV1VBS2R0c1B2NjJyQjIrUGxNZ2x6RTRhUjY5UDZWclBX.ZDBmM2EyMWRjZTg2N2JmYWMxZDIxY2NiYjQzMWFhNjg1YjEzZTAxNmU2M2RmN2M5ZjIyZWJhMzZkOWI1OWJhZg"
# 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
# Add the cookie to the headers
$Headers["Cookie"] = $lenovoCookie
WriteLog "Querying Lenovo PSREF API for model/machine type: $ModelSearchTerm"
$url = "https://psref.lenovo.com/api/search/DefinitionFilterAndSearch/Suggest?kw=$([uri]::EscapeDataString($ModelSearchTerm))"
$models = [System.Collections.Generic.List[PSCustomObject]]::new()
try {
$OriginalVerbosePreference = $VerbosePreference
$VerbosePreference = 'SilentlyContinue'
$response = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $Headers -UserAgent $UserAgent -ErrorAction Stop
$VerbosePreference = $OriginalVerbosePreference
WriteLog "PSREF API query complete."
$jsonResponse = $response.Content | ConvertFrom-Json
if ($null -ne $jsonResponse.data -and $jsonResponse.data.Count -gt 0) {
foreach ($item in $jsonResponse.data) {
$productName = $item.ProductName
$machineTypes = $item.MachineType -split " / " # Split if multiple machine types are listed
foreach ($machineTypeRaw in $machineTypes) {
$machineType = $machineTypeRaw.Trim()
# Only add if machine type is not empty
if (-not [string]::IsNullOrWhiteSpace($machineType)) {
# Create the combined display string
$displayModel = "$productName ($machineType)"
# Add each combination as a separate entry
$models.Add([PSCustomObject]@{
Make = 'Lenovo'
Model = $displayModel
ProductName = $productName
MachineType = $machineType
})
}
else {
WriteLog "Skipping entry for product '$productName' due to missing machine type."
}
}
}
WriteLog "Found $($models.Count) potential model/machine type combinations for '$ModelSearchTerm'."
}
else {
WriteLog "No models found matching '$ModelSearchTerm' in Lenovo PSREF."
}
}
catch {
WriteLog "Error querying Lenovo PSREF API: $($_.Exception.Message)"
# Return empty list on error
}
return $models
}
# Function to download and extract drivers for a specific Lenovo model (Background Task)
function Save-LenovoDriversTask {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[PSCustomObject]$DriverItemData, # Contains Model (ProductName) and MachineType
[Parameter(Mandatory = $true)]
[string]$DriversFolder,
[Parameter(Mandatory = $true)]
[int]$WindowsRelease,
[Parameter(Mandatory = $true)]
[hashtable]$Headers,
[Parameter(Mandatory = $true)]
[string]$UserAgent,
[Parameter()]
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null,
[Parameter()]
[bool]$CompressToWim = $false,
[Parameter()]
[bool]$PreserveSourceOnCompress = $false
)
# The Model property from the UI already contains the combined "ProductName (MachineType)" string
$identifier = $DriverItemData.Model
$machineType = $DriverItemData.MachineType
$make = "Lenovo"
$sanitizedIdentifier = ConvertTo-SafeName -Name $identifier
if ($sanitizedIdentifier -ne $identifier) { WriteLog "Sanitized model identifier: '$identifier' -> '$sanitizedIdentifier'" }
$status = "Starting..."
$success = $false
# Define paths
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $Make
# Use the identifier (which contains the model name and machine type) and sanitize it for the path
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $sanitizedIdentifier
$driverRelativePath = Join-Path -Path $make -ChildPath $sanitizedIdentifier # Relative path for the driver folder
$tempDownloadPath = Join-Path -Path $makeDriversPath -ChildPath "_TEMP_$($machineType)_$($PID)" # Temp folder for catalog/package XMLs
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Checking..." }
try {
# Check for existing drivers
$existingDriver = Test-ExistingDriver -Make $make -Model $sanitizedIdentifier -DriversFolder $DriversFolder -Identifier $identifier -ProgressQueue $ProgressQueue
if ($null -ne $existingDriver) {
# The return object from Test-ExistingDriver uses 'Model' as the identifier key.
# We need to return 'Identifier' for Lenovo's logic.
$existingDriver | Add-Member -MemberType NoteProperty -Name 'Identifier' -Value $identifier -Force
$existingDriver.PSObject.Properties.Remove('Model')
# Special handling for existing folders that need compression
if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') {
$wimFilePath = Join-Path -Path $makeDriversPath -ChildPath "$($sanitizedIdentifier).wim"
$sourceFolderPath = Join-Path -Path $makeDriversPath -ChildPath $sanitizedIdentifier
WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status "Compressing existing..." }
try {
Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $identifier -WimDescription "Drivers for $identifier" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
$existingDriver.Status = "Already downloaded & Compressed"
$existingDriver.DriverPath = Join-Path -Path $make -ChildPath "$($sanitizedIdentifier).wim"
$existingDriver.Success = $true
WriteLog "Successfully compressed existing drivers for $identifier to $wimFilePath."
}
catch {
WriteLog "Error compressing existing drivers for $($identifier): $($_.Exception.Message)"
$existingDriver.Status = "Already downloaded (Compression failed)"
$existingDriver.Success = $false
}
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $existingDriver.Status }
}
return $existingDriver
}
# Ensure base directories exist
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 }
if (-not (Test-Path -Path $tempDownloadPath)) { New-Item -Path $tempDownloadPath -ItemType Directory -Force | Out-Null }
# 2. Construct and Download Catalog URL
$modelRelease = $machineType + "_Win" + $WindowsRelease
$catalogUrl = "https://download.lenovo.com/catalog/$modelRelease.xml"
$lenovoCatalogXML = Join-Path -Path $tempDownloadPath -ChildPath "$modelRelease.xml"
$status = "Downloading Catalog..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
WriteLog "Downloading Lenovo Driver catalog for '$identifier' from $catalogUrl"
# Check URL accessibility first
try {
$request = [System.Net.WebRequest]::Create($catalogUrl); $request.Method = 'HEAD'; $response = $request.GetResponse(); $response.Close()
}
catch { throw "Lenovo Driver catalog URL is not accessible: $catalogUrl. Error: $($_.Exception.Message)" }
Start-BitsTransferWithRetry -Source $catalogUrl -Destination $lenovoCatalogXML
WriteLog "Catalog download Complete: $lenovoCatalogXML"
# 3. Parse Catalog and Process Packages
$status = "Parsing Catalog..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
[xml]$xmlContent = Get-Content -Path $lenovoCatalogXML -Encoding UTF8
$packages = @($xmlContent.packages.package) # Ensure it's an array
$totalPackages = $packages.Count
$processedPackages = 0
WriteLog "Found $totalPackages packages in catalog for '$identifier'."
foreach ($package in $packages) {
$processedPackages++
$category = $package.category
$packageUrl = $package.location # URL to the package's *XML* file
# Skip BIOS/Firmware based on category
if ($category -like 'BIOS*' -or $category -like 'Firmware*') {
WriteLog "($processedPackages/$totalPackages) Skipping BIOS/Firmware package: $category"
continue
}
# Sanitize category for path
$categoryClean = $category -replace '[\\/:"*?<>|]', '_'
if ($categoryClean -eq 'Motherboard Devices Backplanes core chipset onboard video PCIe switches') {
$categoryClean = 'Motherboard Devices' # Shorten long category name
}
$packageName = [System.IO.Path]::GetFileName($packageUrl)
$packageXMLPath = Join-Path -Path $tempDownloadPath -ChildPath $packageName
$baseURL = $packageUrl -replace [regex]::Escape($packageName), "" # Base URL for the driver file
$status = "($processedPackages/$totalPackages) Getting package info..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
# Download the package XML
WriteLog "($processedPackages/$totalPackages) Downloading package XML: $packageUrl"
try {
Start-BitsTransferWithRetry -Source $packageUrl -Destination $packageXMLPath
}
catch {
WriteLog "($processedPackages/$totalPackages) Failed to download package XML '$packageUrl'. Skipping. Error: $($_.Exception.Message)"
continue # Skip this package
}
# Load and parse the package XML
[xml]$packageXmlContent = Get-Content -Path $packageXMLPath -Encoding UTF8
$packageType = $packageXmlContent.Package.PackageType.type
$packageTitleRaw = $packageXmlContent.Package.title.InnerText
# Filter out non-driver packages (Type 2 = Driver)
if ($packageType -ne 2) {
WriteLog "($processedPackages/$totalPackages) Skipping package '$packageTitleRaw' (Type: $packageType) - Not a driver."
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue # Clean up package XML
continue
}
# Sanitize title for folder name
$packageTitle = $packageTitleRaw -replace '[\\/:"*?<>|]', '_' -replace ' - .*', ''
# Extract driver file name and extract command
$driverFileName = $null
$extractCommand = $null
try {
$driverFileName = $packageXmlContent.Package.Files.Installer.File.Name
$extractCommand = $packageXmlContent.Package.ExtractCommand
}
catch {
WriteLog "($processedPackages/$totalPackages) Error parsing package XML '$packageXMLPath' for file name/command. Skipping. Error: $($_.Exception.Message)"
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
continue
}
# Skip if essential info is missing
if ([string]::IsNullOrWhiteSpace($driverFileName) -or [string]::IsNullOrWhiteSpace($extractCommand)) {
WriteLog "($processedPackages/$totalPackages) Skipping package '$packageTitleRaw' - Missing driver file name or extract command in XML."
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
continue
}
# Construct paths
$driverUrl = $baseURL + $driverFileName
$categoryPath = Join-Path -Path $modelPath -ChildPath $categoryClean
$downloadFolder = Join-Path -Path $categoryPath -ChildPath $packageTitle # Final destination subfolder
$driverFilePath = Join-Path -Path $downloadFolder -ChildPath $driverFileName
$extractFolder = Join-Path -Path $downloadFolder -ChildPath ($driverFileName -replace '\.exe$', '') # Extract to subfolder named after exe
# Check if already extracted
if (Test-Path -Path $extractFolder -PathType Container) {
$extractSize = (Get-ChildItem -Path $extractFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($extractSize -gt 1KB) {
WriteLog "($processedPackages/$totalPackages) Driver '$packageTitleRaw' already extracted to '$extractFolder'. Skipping."
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue # Clean up package XML
continue
}
}
# Ensure download folder exists
if (-not (Test-Path -Path $downloadFolder)) {
New-Item -Path $downloadFolder -ItemType Directory -Force | Out-Null
}
# Download the driver .exe
$status = "($processedPackages/$totalPackages) Downloading $packageTitle..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
WriteLog "($processedPackages/$totalPackages) Downloading driver: $driverUrl to $driverFilePath"
try {
Start-BitsTransferWithRetry -Source $driverUrl -Destination $driverFilePath
WriteLog "($processedPackages/$totalPackages) Driver downloaded: $driverFileName"
}
catch {
WriteLog "($processedPackages/$totalPackages) Failed to download driver '$driverUrl'. Skipping. Error: $($_.Exception.Message)"
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue # Clean up package XML
continue # Skip this driver
}
# --- Extraction Logic ---
$status = "($processedPackages/$totalPackages) Extracting $packageTitle..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
# Always use a temporary extraction path to avoid long path issues
$originalExtractFolder = $extractFolder # Store the originally intended final path
$extractionSucceeded = $false
$tempExtractBase = $null # Initialize
# Create randomized number for use with temp folder name
$randomNumber = Get-Random -Minimum 1000 -Maximum 9999
$tempExtractBase = Join-Path $env:TEMP "LenovoDriverExtract_$randomNumber"
$extractFolder = Join-Path $tempExtractBase ($driverFileName -replace '\.exe$', '') # Actual temp extraction folder
WriteLog "($processedPackages/$totalPackages) Using temporary extraction path: $extractFolder"
# Ensure the base temp directory exists
if (-not (Test-Path -Path $tempExtractBase)) {
New-Item -Path $tempExtractBase -ItemType Directory -Force | Out-Null
}
# Ensure the target temporary extraction folder exists
if (-not (Test-Path -Path $extractFolder)) {
New-Item -Path $extractFolder -ItemType Directory -Force | Out-Null
}
# Modify the extract command to point to the temporary folder
$modifiedExtractCommand = $extractCommand -replace '%PACKAGEPATH%', "`"$extractFolder`""
WriteLog "($processedPackages/$totalPackages) Extracting driver: $driverFilePath using command: $modifiedExtractCommand"
try {
Invoke-Process -FilePath $driverFilePath -ArgumentList $modifiedExtractCommand -Wait $true | Out-Null
WriteLog "($processedPackages/$totalPackages) Driver extracted to temporary path: $extractFolder"
$extractionSucceeded = $true
}
catch {
WriteLog "($processedPackages/$totalPackages) Failed to extract driver '$driverFilePath' to temporary path. Skipping. Error: $($_.Exception.Message)"
# Don't delete the downloaded exe yet if extraction fails
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue # Clean up package XML
# Clean up temp folder if extraction failed
if ($tempExtractBase -and (Test-Path -Path $tempExtractBase)) {
Remove-Item -Path $tempExtractBase -Recurse -Force -ErrorAction SilentlyContinue
}
continue # Skip further processing for this driver
}
# --- Post-Extraction Handling (Move from Temp to Final Destination) ---
if ($extractionSucceeded) {
WriteLog "($processedPackages/$totalPackages) Performing post-extraction move from temp to final destination..."
try {
# Ensure the *original* final destination folder exists and is empty
if (Test-Path -Path $originalExtractFolder) {
WriteLog "($processedPackages/$totalPackages) Clearing existing final destination folder: $originalExtractFolder"
Get-ChildItem -Path $originalExtractFolder -Recurse | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
}
else {
WriteLog "($processedPackages/$totalPackages) Creating final destination folder: $originalExtractFolder"
New-Item -Path $originalExtractFolder -ItemType Directory -Force | Out-Null
}
# Get all items (files and folders) directly inside the temp extraction folder
$extractedItems = Get-ChildItem -Path $extractFolder -ErrorAction Stop
foreach ($item in $extractedItems) {
$itemName = $item.Name
$finalDestinationPath = $null
# Check if it's a directory containing 'Liteon'
if ($item.PSIsContainer -and $itemName -like '*Liteon*') {
# Rename Liteon folders with a random number suffix
$randomNumber = Get-Random -Minimum 1000 -Maximum 9999
$finalFolderName = "Liteon_$randomNumber"
$finalDestinationPath = Join-Path -Path $originalExtractFolder -ChildPath $finalFolderName
WriteLog "($processedPackages/$totalPackages) Moving Liteon folder '$itemName' to '$finalDestinationPath'"
}
else {
# For other files/folders, move them directly
$finalDestinationPath = Join-Path -Path $originalExtractFolder -ChildPath $itemName
WriteLog "($processedPackages/$totalPackages) Moving item '$itemName' to '$finalDestinationPath'"
}
# Perform the move
try {
Move-Item -Path $item.FullName -Destination $finalDestinationPath -Force -ErrorAction Stop
}
catch {
WriteLog "($processedPackages/$totalPackages) Failed to move item '$($item.FullName)' to '$finalDestinationPath'. Error: $($_.Exception.Message)"
# Decide if this should stop the whole process or just skip this item
# For now, we'll log and continue, but mark overall success as false
$extractionSucceeded = $false
}
} # End foreach ($item in $extractedItems)
if ($extractionSucceeded) {
WriteLog "($processedPackages/$totalPackages) All driver contents moved successfully from temp to final destination."
}
else {
WriteLog "($processedPackages/$totalPackages) Some driver contents failed to move. Check logs."
}
}
catch {
WriteLog "($processedPackages/$totalPackages) Error during post-extraction move: $($_.Exception.Message). Files might remain in temp."
$extractionSucceeded = $false # Mark as failed for cleanup logic below
}
finally {
# Clean up the base temporary directory regardless of move success/failure
if ($tempExtractBase -and (Test-Path -Path $tempExtractBase)) {
WriteLog "($processedPackages/$totalPackages) Cleaning up temporary extraction base: $tempExtractBase"
Remove-Item -Path $tempExtractBase -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
# --- Final Cleanup ---
# Delete the downloaded .exe only if extraction AND move were successful
if ($extractionSucceeded) {
WriteLog "($processedPackages/$totalPackages) Deleting driver installation file: $driverFilePath"
Remove-Item -Path $driverFilePath -Force -ErrorAction SilentlyContinue
}
else {
WriteLog "($processedPackages/$totalPackages) Keeping driver installation file due to extraction/move failure: $driverFilePath"
}
# Always delete the package XML
WriteLog "($processedPackages/$totalPackages) Deleting package XML file: $packageXMLPath"
Remove-Item -Path $packageXMLPath -Force -ErrorAction SilentlyContinue
} # End foreach package
# --- Compress to WIM if requested (after all drivers processed) ---
if ($CompressToWim) {
$status = "Compressing..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
$wimFileName = "$($sanitizedIdentifier).wim" # Use sanitized identifier for filename
$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 {
$compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $identifier -WimDescription $identifier -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
if ($compressResult) {
WriteLog "Compression successful for '$identifier'."
$status = "Completed & Compressed"
}
else {
WriteLog "Compression failed for '$identifier'. Check verbose/error output from Compress-DriverFolderToWim."
$status = "Completed (Compression Failed)"
}
}
catch {
WriteLog "Error during compression for '$identifier': $($_.Exception.Message)"
$status = "Completed (Compression Error)"
}
}
else {
$status = "Completed"
}
# --- End Compression ---
$success = $true # Mark success as download/extract was okay
}
catch {
$status = "Error: $($_.Exception.Message.Split('.')[0])" # Shorten error message
WriteLog "Error saving Lenovo drivers for '$identifier': $($_.Exception.ToString())" # Log full exception string
$success = $false
# Enqueue the error status before returning
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
# Ensure return object is created even on error
return [PSCustomObject]@{ Identifier = $identifier; Status = $status; Success = $success; DriverPath = $null }
}
finally {
# Clean up the main catalog XML and temp folder
WriteLog "Cleaning up temporary download folder: $tempDownloadPath"
Remove-Item -Path $tempDownloadPath -Recurse -Force -ErrorAction SilentlyContinue
}
# Enqueue the final status (success or error) before returning
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $identifier -Status $status }
# Return the final status
return [PSCustomObject]@{ Identifier = $identifier; Status = $status; Success = $success; DriverPath = $driverRelativePath }
}
Export-ModuleMember -Function *
@@ -0,0 +1,437 @@
<#
.SYNOPSIS
Provides functions for discovering, downloading, and processing Microsoft Surface device drivers.
.DESCRIPTION
This module contains the logic specific to handling Microsoft Surface drivers for the FFU UI. It includes a function to scrape the official Microsoft support website to build a list of available Surface models and their driver download pages. It also provides a robust, parallel-capable function to download the correct driver package (MSI or ZIP) based on the selected Windows release, extract its contents, and optionally compress them into a WIM archive. The download process includes logic to handle MSI installer mutexes to prevent conflicts during parallel execution.
#>
# Function to get the list of Microsoft Surface models
function Get-MicrosoftDriversModelList {
[CmdletBinding()]
param(
[hashtable]$Headers, # Pass necessary headers
[string]$UserAgent # Pass UserAgent
)
$url = "https://support.microsoft.com/en-us/surface/download-drivers-and-firmware-for-surface-09bb2e09-2a4b-cb69-0951-078a7739e120"
$models = @()
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 Save-MicrosoftDriversTask {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[PSCustomObject]$DriverItemData, # Pass data, not the UI object
[Parameter(Mandatory = $true)]
[string]$DriversFolder,
[Parameter(Mandatory = $true)]
[int]$WindowsRelease,
[Parameter(Mandatory = $true)]
[hashtable]$Headers, # Pass necessary headers
[Parameter(Mandatory = $true)]
[string]$UserAgent, # Pass UserAgent
[Parameter()] # Made optional
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue = $null, # Default to null
[Parameter()]
[bool]$CompressToWim = $false, # New parameter for compression
[Parameter()]
[bool]$PreserveSourceOnCompress = $false
# REMOVED: UI-related parameters
)
$modelName = $DriverItemData.Model
$modelLink = $DriverItemData.Link
$make = $DriverItemData.Make
$driverRelativePath = Join-Path -Path $make -ChildPath $modelName # Relative path for the driver folder
$status = "Getting download link..." # Initial local status
$success = $false
# Initial status update
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Checking..." }
try {
# Check for existing drivers
$existingDriver = Test-ExistingDriver -Make $make -Model $modelName -DriversFolder $DriversFolder -Identifier $modelName -ProgressQueue $ProgressQueue
if ($null -ne $existingDriver) {
# Add the 'Model' property to the return object for consistency if it's not there
if (-not $existingDriver.PSObject.Properties['Model']) {
$existingDriver | Add-Member -MemberType NoteProperty -Name 'Model' -Value $modelName
}
# Special handling for existing folders that need compression
if ($CompressToWim -and $existingDriver.Status -eq 'Already downloaded') {
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $make
$wimFilePath = Join-Path -Path $makeDriversPath -ChildPath "$($modelName).wim"
$sourceFolderPath = Join-Path -Path $makeDriversPath -ChildPath $modelName
WriteLog "Attempting compression of existing folder '$sourceFolderPath' to '$wimFilePath'."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status "Compressing existing..." }
try {
Compress-DriverFolderToWim -SourceFolderPath $sourceFolderPath -DestinationWimPath $wimFilePath -WimName $modelName -WimDescription "Drivers for $modelName" -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
$existingDriver.Status = "Already downloaded & Compressed"
$existingDriver.DriverPath = Join-Path -Path $make -ChildPath "$($modelName).wim"
$existingDriver.Success = $true
WriteLog "Successfully compressed existing drivers for $modelName to $wimFilePath."
}
catch {
WriteLog "Error compressing existing drivers for $($modelName): $($_.Exception.Message)"
$existingDriver.Status = "Already downloaded (Compression failed)"
$existingDriver.Success = $false
}
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $existingDriver.Status }
}
return $existingDriver
}
### GET THE DOWNLOAD LINK
$status = "Getting download link..."
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..."
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)
$win10Link = $null
$win10FileName = $null
$win11Link = $null
$win11FileName = $null
# 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"
}
}
# Decision logic to select the appropriate download link
$downloadLink = $null
$fileName = $null
$downloadedVersion = $null # Track which version we are actually downloading
if ($WindowsRelease -eq 10 -and $win10Link) {
$downloadLink = $win10Link
$fileName = $win10FileName
$downloadedVersion = 10
WriteLog "Exact match found for Win10."
}
elseif ($WindowsRelease -eq 11 -and $win11Link) {
$downloadLink = $win11Link
$fileName = $win11FileName
$downloadedVersion = 11
WriteLog "Exact match found for Win11."
}
elseif (-not $win10Link -and $win11Link) {
# Only Win11 available, regardless of $WindowsRelease
$downloadLink = $win11Link
$fileName = $win11FileName
$downloadedVersion = 11
WriteLog "Exact match for Win$($WindowsRelease) not found. Falling back to available Win11 driver."
}
elseif ($win10Link -and -not $win11Link) {
# Only Win10 available, regardless of $WindowsRelease
$downloadLink = $win10Link
$fileName = $win10FileName
$downloadedVersion = 10
WriteLog "Exact match for Win$($WindowsRelease) not found. Falling back to available Win10 driver."
}
# If both Win10 and Win11 links exist, but neither matches $WindowsRelease, $downloadLink remains $null.
### DOWNLOAD AND EXTRACT
if ($downloadLink) {
WriteLog "Selected Download Link for $modelName (Actual: Windows $downloadedVersion): $downloadLink"
$status = "Downloading (Win$downloadedVersion)..." # Update status message
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Create directories
if (-not (Test-Path -Path $DriversFolder)) {
WriteLog "Creating Drivers folder: $DriversFolder"
New-Item -Path $DriversFolder -ItemType Directory -Force | Out-Null
}
$sanitizedModelName = ConvertTo-SafeName -Name $modelName
if ($sanitizedModelName -ne $modelName) { WriteLog "Sanitized model name: '$modelName' -> '$sanitizedModelName'" }
$makeDriversPath = Join-Path -Path $DriversFolder -ChildPath $Make
$modelPath = Join-Path -Path $makeDriversPath -ChildPath $sanitizedModelName
if (-Not (Test-Path -Path $modelPath)) {
WriteLog "Creating model folder: $modelPath"
New-Item -Path $modelPath -ItemType Directory -Force | Out-Null
}
else {
WriteLog "Model folder already exists: $modelPath"
}
### DOWNLOAD
$filePath = Join-Path -Path $makeDriversPath -ChildPath ($fileName)
WriteLog "Downloading $modelName driver file to $filePath"
# Use Start-BitsTransferWithRetry
Start-BitsTransferWithRetry -Source $downloadLink -Destination $filePath
WriteLog "Download complete"
$fileExtension = [System.IO.Path]::GetExtension($filePath).ToLower()
### EXTRACT
if ($fileExtension -eq ".msi") {
$status = "Waiting for MSI lock..." # Set initial 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
$msiMutexName = "Global\FFUDevelopmentMSIExtractionMutex"
$msiMutex = New-Object System.Threading.Mutex($false, $msiMutexName)
try {
WriteLog "Waiting to acquire global MSI extraction lock for '$modelName'..."
$msiMutex.WaitOne() | Out-Null
WriteLog "Acquired global MSI extraction lock for '$modelName'."
# Loop indefinitely to wait for system mutex and handle MSIExec exit codes
while ($true) {
$mutexClear = $false
# 1. Check System-level MSI Mutex
try {
$sysMutex = [System.Threading.Mutex]::OpenExisting("Global\_MSIExecute")
$sysMutex.Dispose()
$status = "Waiting for MSIExec..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
WriteLog "Another MSIExec installer is running (System Mutex Held). Waiting 5 seconds before rechecking for $modelName..."
Start-Sleep -Seconds 5
continue # Go back to start of while loop to re-check mutex
}
catch [System.Threading.WaitHandleCannotBeOpenedException] {
# Mutex is clear, proceed to extraction attempt
WriteLog "System MSI mutex clear. Proceeding with MSI extraction attempt for $modelName."
$status = "Extracting MSI..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
$mutexClear = $true
}
catch {
# Handle other potential errors when checking the mutex
WriteLog "Warning: Error checking system MSI mutex for $($modelName): $_. Proceeding with caution."
$status = "Extracting MSI (Mutex Error)..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
$mutexClear = $true # Proceed despite mutex error
}
# 2. Attempt Extraction (only if mutex was clear)
if ($mutexClear) {
WriteLog "Extracting MSI file to $modelPath"
$arguments = "/a `"$($filePath)`" /qn TARGETDIR=`"$($modelPath)`""
try {
# Use Invoke-Process. It will throw an error for any non-zero exit code.
Invoke-Process -FilePath "msiexec.exe" -ArgumentList $arguments -Wait $true -ErrorAction Stop | Out-Null
# If Invoke-Process succeeded (didn't throw), extraction is complete.
WriteLog "Extraction complete for $modelName (Exit Code 0)."
# Verification Step: Ensure the target folder is not empty.
$itemsInDest = Get-ChildItem -Path $modelPath -Recurse
if ($itemsInDest.Count -eq 0) {
WriteLog "VERIFICATION FAILED: MSI extraction for '$modelName' produced an empty folder. Retrying..."
$status = "Retrying (Empty Folder)"
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
Start-Sleep -Seconds 5
continue # Retry the whole process
}
WriteLog "VERIFICATION PASSED: Target folder for '$modelName' is not empty."
break # Success, exit the while loop
}
catch {
# Catch errors thrown by Invoke-Process
$errorMessage = $_.Exception.Message
if ($errorMessage -match 'Process exited with code 1618') {
# Specific handling for MSIExec busy error (1618)
WriteLog "MSIExec collision detected (Exit Code 1618) for $modelName. Retrying after wait..."
$status = "Waiting (MSI Collision)..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
Start-Sleep -Seconds 5 # Wait before retrying
continue # Go back to start of while loop to re-check mutex/retry
}
else {
# Handle other errors from Invoke-Process (e.g., file not found, permissions, other exit codes)
WriteLog "Error during MSI extraction process for $($modelName): $errorMessage"
throw # Re-throw the original exception to be caught by the outer try/catch
}
}
} # End if ($mutexClear)
} # End while ($true)
}
finally {
if ($null -ne $msiMutex) {
$msiMutex.ReleaseMutex()
$msiMutex.Dispose()
WriteLog "Released global MSI extraction lock for '$modelName'."
}
}
}
elseif ($fileExtension -eq ".zip") {
$status = "Extracting ZIP..." # Set status before extraction
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
WriteLog "Extracting ZIP file to $modelPath"
$ProgressPreference = 'SilentlyContinue'
Expand-Archive -Path $filePath -DestinationPath $modelPath -Force
$ProgressPreference = 'Continue'
WriteLog "Extraction complete"
}
else {
WriteLog "Unsupported file type: $fileExtension"
throw "Unsupported file type: $fileExtension"
}
# Remove downloaded file
$status = "Cleaning up..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
WriteLog "Removing $filePath"
Remove-Item -Path $filePath -Force
WriteLog "Cleanup complete." # Changed log message slightly
# --- Compress to WIM if requested ---
if ($CompressToWim) {
$status = "Compressing..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
$wimFileName = "$($modelName).wim"
# Corrected WIM path: WIM file should be next to the model folder, not inside it.
$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 {
# Use the function from the imported common module
$compressResult = Compress-DriverFolderToWim -SourceFolderPath $modelPath -DestinationWimPath $destinationWimPath -WimName $modelName -WimDescription $modelName -PreserveSource:$PreserveSourceOnCompress -ErrorAction Stop
if ($compressResult) {
WriteLog "Compression successful for '$modelName'."
$status = "Completed & Compressed"
}
else {
WriteLog "Compression failed for '$modelName'. Check verbose/error output from Compress-DriverFolderToWim."
$status = "Completed (Compression Failed)"
# Don't mark overall success as false, download/extract succeeded
}
}
catch {
WriteLog "Error during compression for '$modelName': $($_.Exception.Message)"
$status = "Completed (Compression Error)"
# Don't mark overall success as false
}
}
else {
$status = "Completed" # Final status if not compressing
}
# --- End Compression ---
$success = $true # Mark success as download/extract was okay
} # End if/elseif for .msi/.zip
else {
WriteLog "No suitable download link found for Windows $WindowsRelease (or fallback) for model $modelName."
$status = "Error: No Win$($WindowsRelease)/Fallback link"
$success = $false
}
}
else {
WriteLog "Failed to parse the download page for the driver file for model $modelName."
$status = "Error: Parse failed"
$success = $false
}
}
catch {
$status = "Error: $($_.Exception.Message.Split('.')[0])" # Shorten error message
WriteLog "Error saving Microsoft drivers for $($modelName): $($_.Exception.Message)"
$success = $false
# Enqueue the error status before returning
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Ensure return object is created even on error
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $success; DriverPath = $null }
}
# 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 (this is still used by Receive-Job for final confirmation)
return [PSCustomObject]@{ Model = $modelName; Status = $status; Success = $success; DriverPath = $driverRelativePath }
}
Export-ModuleMember -Function *
@@ -0,0 +1,786 @@
<#
.SYNOPSIS
Provides functions for managing and downloading hardware drivers in the FFU Builder UI.
.DESCRIPTION
This module contains all the business logic for the 'Drivers' tab in the FFU Builder UI. It handles fetching driver model lists from various manufacturers (Microsoft, Dell, HP, Lenovo), displaying and filtering them in the UI, and managing the selection state. It also includes functions to import and export driver selections to a JSON file (Drivers.json) and to orchestrate the parallel download of selected driver packages using the common parallel processing module.
#>
# Helper function to get models for a selected Make and standardize them
function Get-ModelsForMake {
param(
[Parameter(Mandatory = $true)]
[string]$SelectedMake,
[Parameter(Mandatory = $true)]
[psobject]$State
)
$standardizedModels = [System.Collections.Generic.List[PSCustomObject]]::new()
$rawModels = @()
# Get necessary values from UI or script scope
$localDriversFolder = $State.Controls.txtDriversFolder.Text
$localWindowsRelease = $null
if ($null -ne $State.Controls.cmbWindowsRelease.SelectedItem) {
$localWindowsRelease = $State.Controls.cmbWindowsRelease.SelectedItem.Value
}
# Get headers and user agent from Get-CoreStaticVariables
$staticVars = Get-CoreStaticVariables
$Headers = $staticVars.Headers
$UserAgent = $staticVars.UserAgent
if (-not $localWindowsRelease -and ($SelectedMake -eq 'Dell' -or $SelectedMake -eq 'Lenovo')) {
[System.Windows.MessageBox]::Show("Please select a Windows Release first for $SelectedMake.", "Missing Information", "OK", "Warning")
throw "Windows Release not selected for $SelectedMake."
}
switch ($SelectedMake) {
'Microsoft' {
$rawModels = Get-MicrosoftDriversModelList -Headers $Headers -UserAgent $UserAgent
}
'Dell' {
$rawModels = Get-DellDriversModelList -WindowsRelease $localWindowsRelease -DriversFolder $localDriversFolder -Make $SelectedMake
}
'HP' {
$rawModels = Get-HPDriversModelList -DriversFolder $localDriversFolder -Make $SelectedMake
}
'Lenovo' {
$modelSearchTerm = [Microsoft.VisualBasic.Interaction]::InputBox("Enter Lenovo Model Name or Machine Type (e.g., T480 or 20L5):", "Lenovo Model Search", "")
if ([string]::IsNullOrWhiteSpace($modelSearchTerm)) {
# User cancelled or entered nothing
return @()
}
$State.Controls.txtStatus.Text = "Searching Lenovo models for '$modelSearchTerm'..."
$rawModels = Get-LenovoDriversModelList -ModelSearchTerm $modelSearchTerm -Headers $Headers -UserAgent $UserAgent
}
default {
[System.Windows.MessageBox]::Show("Selected Make '$SelectedMake' is not supported for automatic model retrieval.", "Unsupported Make", "OK", "Warning")
return @()
}
}
if ($null -ne $rawModels) {
foreach ($rawModel in $rawModels) {
# Filter out Chromebooks for Lenovo before standardization
if ($SelectedMake -eq 'Lenovo' -and $rawModel.Model -match 'Chromebook') {
WriteLog "Get-ModelsForMake: Skipping Chromebook model: $($rawModel.Model)"
continue
}
$standardizedModels.Add((ConvertTo-StandardizedDriverModel -RawDriverObject $rawModel -Make $SelectedMake -State $State))
}
}
return $standardizedModels.ToArray()
}
# Helper function to convert raw driver objects to a standardized format
function ConvertTo-StandardizedDriverModel {
param(
[Parameter(Mandatory = $true)]
[PSCustomObject]$RawDriverObject,
[Parameter(Mandatory = $true)]
[string]$Make,
[Parameter(Mandatory = $true)]
[psobject]$State
)
$modelDisplay = $RawDriverObject.Model # Default
$id = $RawDriverObject.Model # Default
$link = $null
$productName = $null
$machineType = $null
if ($RawDriverObject.PSObject.Properties['Link']) {
$link = $RawDriverObject.Link
}
# Lenovo specific handling
if ($Make -eq 'Lenovo') {
$modelDisplay = $RawDriverObject.Model
$productName = $RawDriverObject.ProductName
$machineType = $RawDriverObject.MachineType
$id = $RawDriverObject.MachineType
}
return [PSCustomObject]@{
IsSelected = $false
Make = $Make
Model = $modelDisplay
Link = $link
Id = $id
ProductName = $productName
MachineType = $machineType
Version = "" # Placeholder
Type = "" # Placeholder
Size = "" # Placeholder
Arch = "" # Placeholder
DownloadStatus = "" # Initial download status
}
}
# Function to filter the driver model list based on text input
function Search-DriverModels {
param(
[string]$filterText,
[Parameter(Mandatory = $true)]
[psobject]$State
)
# Check if UI elements and the full list are available
if ($null -eq $State.Controls.lstDriverModels -or $null -eq $State.Data.allDriverModels) {
WriteLog "Search-DriverModels: ListView or full model list not available."
return
}
# Ensure the ItemsSource is always the master list. This prevents inconsistency.
if ($State.Controls.lstDriverModels.ItemsSource -ne $State.Data.allDriverModels) {
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
}
# Get the default view of the items source, which supports filtering.
$collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($State.Controls.lstDriverModels.ItemsSource)
if ($null -eq $collectionView) {
WriteLog "Search-DriverModels: Could not get CollectionView. Filtering may not work."
return
}
WriteLog "Applying filter with text: '$filterText'"
if ([string]::IsNullOrWhiteSpace($filterText)) {
# If filter is empty, remove any existing filter
$collectionView.Filter = $null
}
else {
# Apply a filter predicate. This is the correct WPF way to filter.
$collectionView.Filter = {
param($item)
# $item is the PSCustomObject from the list
return $item.Model -like "*$filterText*"
}
}
# The view will automatically refresh. No need to call .Refresh() explicitly for filtering.
$filteredCount = 0
if ($null -ne $collectionView) {
foreach ($item in $collectionView) { $filteredCount++ }
}
WriteLog "Filter applied. View now contains $filteredCount models."
}
# Function to save selected driver models to a JSON file
function Save-DriversJson {
param(
[Parameter(Mandatory = $true)]
[psobject]$State
)
WriteLog "Save-DriversJson function called."
$selectedDrivers = @($State.Controls.lstDriverModels.Items | Where-Object { $_.IsSelected })
if (-not $selectedDrivers) {
[System.Windows.MessageBox]::Show("No drivers selected to save.", "Save Drivers", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
WriteLog "No drivers selected to save."
return
}
$outputJson = @{} # Use a Hashtable for the desired structure
$selectedDrivers | Group-Object -Property Make | ForEach-Object {
$makeName = $_.Name
$modelsForThisMake = @() # Initialize an array to hold model objects
foreach ($driverItem in $_.Group) {
$modelObject = $null
switch ($makeName) {
'Microsoft' {
$modelObject = @{
Name = $driverItem.Model # Model is the display name
Link = $driverItem.Link
}
}
'Dell' {
$modelObject = @{
Name = $driverItem.Model
}
}
'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) {
$modelsForThisMake += $modelObject
}
}
if ($modelsForThisMake.Count -gt 0) {
# Store the array of model objects under a "Models" key
$outputJson[$makeName] = @{
"Models" = $modelsForThisMake
}
}
}
$sfd = New-Object System.Windows.Forms.SaveFileDialog
$sfd.Filter = "JSON files (*.json)|*.json|All files (*.*)|*.*"
$sfd.Title = "Save Selected Drivers"
$sfd.FileName = "Drivers.json"
$sfd.InitialDirectory = $FFUDevelopmentPath
if ($sfd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
try {
$outputJson | ConvertTo-Json -Depth 5 | Set-Content -Path $sfd.FileName -Encoding UTF8
[System.Windows.MessageBox]::Show("Selected drivers saved to $($sfd.FileName)", "Save Successful", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
WriteLog "Selected drivers saved to $($sfd.FileName)"
}
catch {
[System.Windows.MessageBox]::Show("Error saving drivers file: $($_.Exception.Message)", "Save Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error)
WriteLog "Error saving drivers file to $($sfd.FileName): $($_.Exception.Message)"
}
}
else {
WriteLog "Save drivers operation cancelled by user."
}
}
# Function to import driver models from a JSON file
function Import-DriversJson {
param(
[Parameter(Mandatory = $true)]
[psobject]$State
)
WriteLog "Import-DriversJson function called."
$ofd = New-Object System.Windows.Forms.OpenFileDialog
$ofd.Filter = "JSON files (*.json)|*.json|All files (*.*)|*.*"
$ofd.Title = "Import Drivers"
$ofd.InitialDirectory = Join-Path -Path $State.FFUDevelopmentPath -ChildPath "Drivers"
if ($ofd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
try {
$importedData = Get-Content -Path $ofd.FileName -Raw | ConvertFrom-Json
if ($null -eq $importedData -or $importedData -isnot [System.Management.Automation.PSCustomObject]) {
[System.Windows.MessageBox]::Show("Invalid JSON file format. Expected a JSON object with Makes as keys.", "Import Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error)
WriteLog "Import-DriversJson: Invalid JSON format in $($ofd.FileName). Expected an object."
return
}
$newModelsAdded = 0
$existingModelsUpdated = 0
if ($null -eq $State.Data.allDriverModels) {
$State.Data.allDriverModels = @()
}
$importedData.PSObject.Properties | ForEach-Object {
$makeName = $_.Name
$makeData = $_.Value # This is the object containing "Models" array
# Check if $makeData is null, not a PSCustomObject, or does not have a 'Models' property
if ($null -eq $makeData -or $makeData -isnot [System.Management.Automation.PSCustomObject] -or -not ($makeData.PSObject.Properties | Where-Object { $_.Name -eq 'Models' })) {
WriteLog "Import-DriversJson: Skipping Make '$makeName' due to invalid structure or missing 'Models' key."
return # Corresponds to 'continue' in ForEach-Object script block
}
$modelObjectArray = $makeData.Models # This is now an array of objects
if ($null -eq $modelObjectArray -or $modelObjectArray -isnot [array]) {
WriteLog "Import-DriversJson: Skipping Make '$makeName' because 'Models' value is not an array."
return
}
foreach ($importedModelObject in $modelObjectArray) {
if ($null -eq $importedModelObject -or -not $importedModelObject.PSObject.Properties['Name']) {
WriteLog "Import-DriversJson: Skipping model for Make '$makeName' due to missing 'Name' property or null object."
continue
}
$importedModelNameFromObject = $importedModelObject.Name
if ([string]::IsNullOrWhiteSpace($importedModelNameFromObject)) {
WriteLog "Import-DriversJson: Skipping empty model name for Make '$makeName'."
continue
}
$existingModel = $State.Data.allDriverModels | Where-Object { $_.Make -eq $makeName -and $_.Model -eq $importedModelNameFromObject } | Select-Object -First 1
if ($null -ne $existingModel) {
$existingModel.IsSelected = $true
$existingModel.DownloadStatus = "Imported"
if ($makeName -eq 'Microsoft' -and $importedModelObject.PSObject.Properties['Link']) {
if ($existingModel.Link -ne $importedModelObject.Link) {
$existingModel.Link = $importedModelObject.Link
WriteLog "Import-DriversJson: Updated Link for existing Microsoft model '$($existingModel.Model)'."
}
}
elseif ($makeName -eq 'Lenovo') {
$updateExistingLenovo = $false
if ($importedModelObject.PSObject.Properties['ProductName'] -and $existingModel.PSObject.Properties['ProductName'] -and $existingModel.ProductName -ne $importedModelObject.ProductName) {
$existingModel.ProductName = $importedModelObject.ProductName
$updateExistingLenovo = $true
}
if ($importedModelObject.PSObject.Properties['MachineType'] -and $existingModel.PSObject.Properties['MachineType'] -and $existingModel.MachineType -ne $importedModelObject.MachineType) {
$existingModel.MachineType = $importedModelObject.MachineType
$existingModel.Id = $importedModelObject.MachineType # Update Id as well
$updateExistingLenovo = $true
}
if ($updateExistingLenovo) {
WriteLog "Import-DriversJson: Updated ProductName/MachineType/Id for existing Lenovo model '$($existingModel.Model)'."
}
}
$existingModelsUpdated++
WriteLog "Import-DriversJson: Marked existing model '$($existingModel.Make) - $($existingModel.Model)' as imported."
}
else {
# Model does not exist, create a new one
$importedLink = if ($makeName -eq 'Microsoft' -and $importedModelObject.PSObject.Properties['Link']) { $importedModelObject.Link } else { $null }
$importedId = $importedModelNameFromObject # Default Id
$importedProductName = $null
$importedMachineType = $null
if ($makeName -eq 'Lenovo') {
$importedProductName = if ($importedModelObject.PSObject.Properties['ProductName']) { $importedModelObject.ProductName } else { $null }
$importedMachineType = if ($importedModelObject.PSObject.Properties['MachineType']) { $importedModelObject.MachineType } else { $null }
if ($null -ne $importedMachineType) {
$importedId = $importedMachineType # Override Id for Lenovo
}
# Fallback parsing if ProductName/MachineType are missing from JSON but Name has the pattern
if (($null -eq $importedProductName -or $null -eq $importedMachineType) -and $importedModelNameFromObject -match '(.+?)\s*\((.+?)\)$') {
WriteLog "Import-DriversJson: Lenovo model '$importedModelNameFromObject' missing ProductName or MachineType in JSON. Attempting to parse from Name."
if ($null -eq $importedProductName) { $importedProductName = $matches[1].Trim() }
if ($null -eq $importedMachineType) {
$importedMachineType = $matches[2].Trim()
$importedId = $importedMachineType # Update Id if MachineType was parsed here
}
}
if ($null -eq $importedProductName -or $null -eq $importedMachineType) {
WriteLog "Import-DriversJson: Warning - Lenovo model '$importedModelNameFromObject' is missing ProductName or MachineType after parsing. ID might be based on full name."
}
}
$newDriverModel = [PSCustomObject]@{
IsSelected = $true
Make = $makeName
Model = $importedModelNameFromObject # Full display name
Link = $importedLink
Id = $importedId
ProductName = $importedProductName
MachineType = $importedMachineType
Version = ""
Type = ""
Size = ""
Arch = ""
DownloadStatus = "Imported"
}
$State.Data.allDriverModels.Add($newDriverModel)
$newModelsAdded++
WriteLog "Import-DriversJson: Added new model '$($newDriverModel.Make) - $($newDriverModel.Model)' from import. ID: $($newDriverModel.Id), Link: $($newDriverModel.Link)"
}
}
}
# Sort the full list of models
$sortedModels = $State.Data.allDriverModels | Sort-Object @{Expression = { $_.IsSelected }; Descending = $true }, Make, Model
# Create a new list from the sorted results and assign it to the state.
# This prevents the "ItemsControl inconsistent" error by replacing the source instead of modifying it.
$newList = [System.Collections.Generic.List[PSCustomObject]]::new()
if ($null -ne $sortedModels) {
foreach ($model in @($sortedModels)) {
$newList.Add($model)
}
}
$State.Data.allDriverModels = $newList
# Update the UI and apply any existing filter
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
Search-DriverModels -filterText $State.Controls.txtModelFilter.Text -State $State
$message = "Driver import complete.`nNew models added: $newModelsAdded`nExisting models updated: $existingModelsUpdated"
[System.Windows.MessageBox]::Show($message, "Import Successful", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
WriteLog $message
}
catch {
[System.Windows.MessageBox]::Show("Error importing drivers file: $($_.Exception.Message)", "Import Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error)
WriteLog "Error importing drivers file from $($ofd.FileName): $($_.Exception.Message)"
}
}
else {
WriteLog "Import drivers operation cancelled by user."
}
}
# Function to handle the 'Get Models' button click logic
function Invoke-GetModels {
param(
[Parameter(Mandatory = $true)]
[psobject]$State,
[Parameter(Mandatory = $true)]
[object]$Button
)
$selectedMake = $State.Controls.cmbMake.SelectedItem
$State.Controls.txtStatus.Text = "Getting models for $selectedMake..."
$State.Window.Cursor = [System.Windows.Input.Cursors]::Wait
$Button.IsEnabled = $false
try {
# Get ALL previously selected models to preserve them, regardless of make.
$allPreviouslySelectedModels = @($State.Data.allDriverModels | Where-Object { $_.IsSelected })
# Get newly fetched models for the current make
$newlyFetchedStandardizedModels = Get-ModelsForMake -SelectedMake $selectedMake -State $State
$combinedModelsList = [System.Collections.Generic.List[PSCustomObject]]::new()
$modelIdentifiersInCombinedList = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
# Add all previously selected models first to preserve their 'IsSelected' state.
foreach ($item in $allPreviouslySelectedModels) {
$combinedModelsList.Add($item)
$modelIdentifiersInCombinedList.Add("$($item.Make)::$($item.Model)") | Out-Null
}
# Add newly fetched models, but only if they are not already in the list.
# This prevents overwriting a selected model with an unselected one.
$addedNewCount = 0
foreach ($item in $newlyFetchedStandardizedModels) {
if ($modelIdentifiersInCombinedList.Add("$($item.Make)::$($item.Model)")) {
$combinedModelsList.Add($item)
$addedNewCount++
}
}
# Sort the combined list
$sortedModels = $combinedModelsList | Sort-Object @{Expression = { $_.IsSelected }; Descending = $true }, Make, Model
# Create a new list object from the sorted results. This is safer than modifying the existing list
# that the UI is bound to, which can cause inconsistency errors.
$newList = [System.Collections.Generic.List[PSCustomObject]]::new()
if ($null -ne $sortedModels) {
# Sort-Object can return a single object or an array. Ensure it's always treated as a collection.
foreach ($model in @($sortedModels)) {
$newList.Add($model)
}
}
$State.Data.allDriverModels = $newList
# Update the UI ItemsSource to point to the new list and clear the filter
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
$State.Controls.txtModelFilter.Text = ""
if ($State.Data.allDriverModels.Count -gt 0) {
$State.Controls.spModelFilterSection.Visibility = 'Visible'
$State.Controls.lstDriverModels.Visibility = 'Visible'
$State.Controls.spDriverActionButtons.Visibility = 'Visible'
$statusText = "Displaying $($State.Data.allDriverModels.Count) models."
if ($newlyFetchedStandardizedModels.Count -gt 0 -and $addedNewCount -eq 0 -and $allPreviouslySelectedModels.Count -gt 0) {
$statusText = "Fetched $($newlyFetchedStandardizedModels.Count) models for $selectedMake; all were already in the selected list. Displaying $($State.Data.allDriverModels.Count) total selected models."
}
elseif ($addedNewCount -gt 0) {
$statusText = "Added $addedNewCount new models for $selectedMake. Displaying $($State.Data.allDriverModels.Count) total models."
}
elseif ($newlyFetchedStandardizedModels.Count -eq 0 -and $selectedMake -eq 'Lenovo' ) {
$statusText = if ($allPreviouslySelectedModels.Count -gt 0) { "No new models found for $selectedMake. Displaying $($allPreviouslySelectedModels.Count) previously selected models." } else { "No models found for $selectedMake." }
}
elseif ($newlyFetchedStandardizedModels.Count -eq 0) {
$statusText = "No new models found for $selectedMake. Displaying $($State.Data.allDriverModels.Count) previously selected models."
}
$State.Controls.txtStatus.Text = $statusText
}
else {
$State.Controls.spModelFilterSection.Visibility = 'Collapsed'
$State.Controls.lstDriverModels.Visibility = 'Collapsed'
$State.Controls.spDriverActionButtons.Visibility = 'Collapsed'
$State.Controls.txtStatus.Text = "No models to display for $selectedMake."
}
}
catch {
$State.Controls.txtStatus.Text = "Error getting models: $($_.Exception.Message)"
[System.Windows.MessageBox]::Show("Error getting models: $($_.Exception.Message)", "Error", "OK", "Error")
if ($null -eq $State.Data.allDriverModels -or $State.Data.allDriverModels.Count -eq 0) {
$State.Controls.spModelFilterSection.Visibility = 'Collapsed'
$State.Controls.lstDriverModels.Visibility = 'Collapsed'
$State.Controls.spDriverActionButtons.Visibility = 'Collapsed'
$State.Controls.lstDriverModels.ItemsSource = $null
$State.Controls.txtModelFilter.Text = ""
}
}
finally {
$State.Window.Cursor = $null
$Button.IsEnabled = $true
}
}
# Function to handle the 'Download Selected Drivers' button click logic
function Invoke-DownloadSelectedDrivers {
param(
[Parameter(Mandatory = $true)]
[psobject]$State,
[Parameter(Mandatory = $true)]
[object]$Button
)
$selectedDrivers = @($State.Data.allDriverModels | Where-Object { $_.IsSelected })
if (-not $selectedDrivers) {
[System.Windows.MessageBox]::Show("No drivers selected to download.", "Download Drivers", "OK", "Information")
return
}
$Button.IsEnabled = $false
$State.Controls.pbOverallProgress.Visibility = 'Visible'
$State.Controls.pbOverallProgress.Value = 0
$State.Controls.txtStatus.Text = "Preparing driver downloads..."
# Define common necessary task-specific variables locally
# Ensure required selections are made
if ($null -eq $State.Controls.cmbWindowsRelease.SelectedItem) {
[System.Windows.MessageBox]::Show("Please select a Windows Release.", "Missing Information", "OK", "Warning")
$Button.IsEnabled = $true
$State.Controls.pbOverallProgress.Visibility = 'Collapsed'
$State.Controls.txtStatus.Text = "Driver download cancelled."
return
}
if ($null -eq $State.Controls.cmbWindowsArch.SelectedItem) {
[System.Windows.MessageBox]::Show("Please select a Windows Architecture.", "Missing Information", "OK", "Warning")
$Button.IsEnabled = $true
$State.Controls.pbOverallProgress.Visibility = 'Collapsed'
$State.Controls.txtStatus.Text = "Driver download cancelled."
return
}
if (($selectedDrivers | Where-Object { $_.Make -eq 'HP' }) -and $null -ne $State.Controls.cmbWindowsVersion -and $null -eq $State.Controls.cmbWindowsVersion.SelectedItem) {
[System.Windows.MessageBox]::Show("HP drivers are selected. Please select a Windows Version.", "Missing Information", "OK", "Warning")
$Button.IsEnabled = $true
$State.Controls.pbOverallProgress.Visibility = 'Collapsed'
$State.Controls.txtStatus.Text = "Driver download cancelled."
return
}
$localDriversFolder = $State.Controls.txtDriversFolder.Text
$localWindowsRelease = $State.Controls.cmbWindowsRelease.SelectedItem.Value
$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 }
$coreStaticVars = Get-CoreStaticVariables
$localHeaders = $coreStaticVars.Headers
$localUserAgent = $coreStaticVars.UserAgent
$compressDrivers = $State.Controls.chkCompressDriversToWIM.IsChecked
# Determine if we must preserve source folders (used later for PE driver harvesting)
$preserveSource = ($State.Controls.chkUseDriversAsPEDrivers.IsChecked -and $State.Controls.chkCompressDriversToWIM.IsChecked)
$State.Controls.txtStatus.Text = "Processing all selected drivers..."
WriteLog "Processing all selected drivers: $($selectedDrivers.Model -join ', ')"
# Pre-process Dell Catalog if needed, so it's not done in parallel
if ($selectedDrivers | Where-Object { $_.Make -eq 'Dell' }) {
WriteLog "Dell drivers selected. Ensuring Dell Catalog is up-to-date..."
try {
$dellDriversFolder = Join-Path -Path $localDriversFolder -ChildPath "Dell"
$catalogBaseName = if ($localWindowsRelease -le 11) { "CatalogPC" } else { "Catalog" }
$dellCabFile = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).cab"
$dellCatalogXML = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).xml"
$catalogUrl = if ($localWindowsRelease -le 11) { "http://downloads.dell.com/catalog/CatalogPC.cab" } else { "https://downloads.dell.com/catalog/Catalog.cab" }
$downloadCatalog = $true
if (Test-Path -Path $dellCatalogXML -PathType Leaf) {
if (((Get-Date) - (Get-Item $dellCatalogXML).CreationTime).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 "Downloading and extracting Dell Catalog for driver download process..."
if (-not (Test-Path -Path $dellDriversFolder -PathType Container)) {
New-Item -Path $dellDriversFolder -ItemType Directory -Force | Out-Null
}
if (Test-Path -Path $dellCabFile) { Remove-Item -Path $dellCabFile -Force -ErrorAction SilentlyContinue }
if (Test-Path -Path $dellCatalogXML) { Remove-Item -Path $dellCatalogXML -Force -ErrorAction SilentlyContinue }
Start-BitsTransferWithRetry -Source $catalogUrl -Destination $dellCabFile
Invoke-Process -FilePath "Expand.exe" -ArgumentList """$dellCabFile"" ""$dellCatalogXML""" | Out-Null
Remove-Item -Path $dellCabFile -Force -ErrorAction SilentlyContinue
WriteLog "Dell Catalog prepared successfully."
}
}
catch {
$errorMessage = "Failed to prepare Dell Catalog: $($_.Exception.Message)"
WriteLog $errorMessage
[System.Windows.MessageBox]::Show($errorMessage, "Dell Catalog Error", "OK", "Error")
$Button.IsEnabled = $true
$State.Controls.pbOverallProgress.Visibility = 'Collapsed'
$State.Controls.txtStatus.Text = "Driver download cancelled due to Dell Catalog error."
return
}
}
$preserveSource = ($State.Controls.chkUseDriversAsPEDrivers.IsChecked -and $State.Controls.chkCompressDriversToWIM.IsChecked)
$taskArguments = @{
DriversFolder = $localDriversFolder
WindowsRelease = $localWindowsRelease
WindowsArch = $localWindowsArch
WindowsVersion = $localWindowsVersion
Headers = $localHeaders
UserAgent = $localUserAgent
CompressToWim = $compressDrivers
PreserveSourceOnCompress = $preserveSource
}
$parallelResults = Invoke-ParallelProcessing -ItemsToProcess $selectedDrivers `
-ListViewControl $State.Controls.lstDriverModels `
-IdentifierProperty 'Model' `
-StatusProperty 'DownloadStatus' `
-TaskType 'DownloadDriverByMake' `
-TaskArguments $taskArguments `
-CompletedStatusText 'Completed' `
-ErrorStatusPrefix 'Error: ' `
-WindowObject $State.Window `
-MainThreadLogPath $State.LogFilePath `
-ThrottleLimit $State.Controls.txtThreads.Text
$overallSuccess = $true
$successfullyDownloaded = [System.Collections.Generic.List[PSCustomObject]]::new()
# Check the results from the parallel processing tasks
if ($null -ne $parallelResults) {
# Create a lookup from the original selected drivers to get the 'Make' property,
# as the result object might only have 'Identifier' or 'Model'.
$makeLookup = @{}
$selectedDrivers | ForEach-Object { $makeLookup[$_.Model] = $_.Make }
# Filter for objects that could be results, avoiding stray log strings
foreach ($result in ($parallelResults | Where-Object { $_ -is [hashtable] })) {
if ($null -eq $result) { continue }
# The result from Invoke-ParallelProcessing is a hashtable.
# Access properties using their keys.
$modelName = $result['Identifier']
$resultCode = $result['ResultCode']
$driverPath = $result['DriverPath']
if ([string]::IsNullOrWhiteSpace($modelName)) {
WriteLog "Could not determine model name from result object: $($result | ConvertTo-Json -Compress -Depth 3)"
$overallSuccess = $false
continue
}
if ($resultCode -ne 0) {
$overallSuccess = $false
WriteLog "Error detected for model $modelName."
}
elseif (-not [string]::IsNullOrWhiteSpace($driverPath)) {
# The task was successful and returned a driver path.
$make = $makeLookup[$modelName]
if ($make) {
$successfullyDownloaded.Add([PSCustomObject]@{
Make = $make
Model = $modelName
DriverPath = $driverPath
})
}
else {
WriteLog "Warning: Could not find 'Make' for successful download of model '$modelName'. Skipping from DriverMapping.json."
}
}
}
}
# Update the driver mapping JSON if there are any successful downloads
if ($successfullyDownloaded.Count -gt 0) {
try {
WriteLog "Updating DriverMapping.json with $($successfullyDownloaded.Count) successfully downloaded drivers."
Update-DriverMappingJson -DownloadedDrivers $successfullyDownloaded -DriversFolder $localDriversFolder
}
catch {
WriteLog "Failed to update DriverMapping.json: $($_.Exception.Message)"
# This is not a fatal error for the download process itself, so just show a warning.
[System.Windows.MessageBox]::Show("The driver download process completed, but failed to update the DriverMapping.json file. Please check the log for details.", "Driver Mapping Error", "OK", "Warning")
}
}
# Automatically save the selected drivers to the specified Drivers.json path
$driversJsonPath = $State.Controls.txtDriversJsonPath.Text
if (-not [string]::IsNullOrWhiteSpace($driversJsonPath) -and $selectedDrivers.Count -gt 0) {
WriteLog "Attempting to automatically save selected drivers list to $driversJsonPath"
try {
$outputJson = @{} # Use a Hashtable for the desired structure
$selectedDrivers | Group-Object -Property Make | ForEach-Object {
$makeName = $_.Name
$modelsForThisMake = @() # Initialize an array to hold model objects
foreach ($driverItem in $_.Group) {
$modelObject = $null
switch ($makeName) {
'Microsoft' {
$modelObject = @{
Name = $driverItem.Model # Model is the display name
Link = $driverItem.Link
}
}
'Dell' {
$modelObject = @{
Name = $driverItem.Model # Model is the display name
}
}
'HP' {
$modelObject = @{
Name = $driverItem.Model
}
}
'Lenovo' {
$modelObject = @{
Name = $driverItem.Model
ProductName = $driverItem.ProductName
MachineType = $driverItem.MachineType
}
}
default {
WriteLog "Auto-Save Drivers.json: Unrecognized Make '$makeName' for driver '$($driverItem.Model)'. Skipping."
}
}
if ($null -ne $modelObject) {
$modelsForThisMake += $modelObject
}
}
# Add the models array to the make-specific object
$outputJson[$makeName] = @{ Models = $modelsForThisMake }
}
# Ensure directory exists
$parentDir = Split-Path -Path $driversJsonPath -Parent
if (-not (Test-Path -Path $parentDir -PathType Container)) {
WriteLog "Creating directory for Drivers.json: $parentDir"
New-Item -Path $parentDir -ItemType Directory -Force | Out-Null
}
$outputJson | ConvertTo-Json -Depth 5 | Set-Content -Path $driversJsonPath -Encoding UTF8
WriteLog "Successfully auto-saved selected drivers to $driversJsonPath"
}
catch {
WriteLog "Failed to automatically save selected drivers to $driversJsonPath. Error: $($_.Exception.Message)"
# This is a best-effort operation, so we only log the error and don't bother the user with a popup.
}
}
$State.Controls.pbOverallProgress.Visibility = 'Collapsed'
$Button.IsEnabled = $true
if ($overallSuccess) {
$State.Controls.txtStatus.Text = "All selected driver downloads processed."
[System.Windows.MessageBox]::Show("All selected driver downloads processed. Check status column for details.", "Download Process Finished", "OK", "Information")
}
else {
$State.Controls.txtStatus.Text = "Driver downloads processed with some errors. Check status column and log."
[System.Windows.MessageBox]::Show("Driver downloads processed, but some errors occurred. Please check the status column for each driver and the log file for details.", "Download Process Finished with Errors", "OK", "Warning")
}
}
Export-ModuleMember -Function *
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,739 @@
<#
.SYNOPSIS
Initializes the user interface for the BuildFFUVM_UI application.
.DESCRIPTION
This script module contains functions responsible for initializing the WPF user interface.
It handles several key tasks:
- Caching references to all UI controls for efficient access.
- Populating UI elements like combo boxes with data (e.g., Hyper-V switches).
- Setting default values for all controls based on configuration or predefined settings.
- Dynamically creating and configuring complex UI components, such as sortable/selectable GridView columns and feature selection grids.
This module is critical for setting up the initial state of the application window when it first loads.
#>
function Initialize-UIControls {
param([PSCustomObject]$State)
WriteLog "Initializing UI control references..."
$window = $State.Window
# Find all controls ONCE and store them in the state object
$State.Controls.cmbWindowsRelease = $window.FindName('cmbWindowsRelease')
$State.Controls.cmbWindowsVersion = $window.FindName('cmbWindowsVersion')
$State.Controls.txtISOPath = $window.FindName('txtISOPath')
$State.Controls.btnBrowseISO = $window.FindName('btnBrowseISO')
$State.Controls.cmbWindowsArch = $window.FindName('cmbWindowsArch')
$State.Controls.cmbWindowsLang = $window.FindName('cmbWindowsLang')
$State.Controls.WindowsLangStackPanel = $window.FindName('WindowsLangStackPanel')
$State.Controls.cmbWindowsSKU = $window.FindName('cmbWindowsSKU')
$State.Controls.cmbMediaType = $window.FindName('cmbMediaType')
$State.Controls.MediaTypeStackPanel = $window.FindName('MediaTypeStackPanel')
$State.Controls.featuresPanel = $window.FindName('stackFeaturesContainer')
$State.Controls.chkDownloadDrivers = $window.FindName('chkDownloadDrivers')
$State.Controls.cmbMake = $window.FindName('cmbMake')
$State.Controls.spMakeSection = $window.FindName('spMakeSection')
$State.Controls.btnGetModels = $window.FindName('btnGetModels')
$State.Controls.spModelFilterSection = $window.FindName('spModelFilterSection')
$State.Controls.txtModelFilter = $window.FindName('txtModelFilter')
$State.Controls.lstDriverModels = $window.FindName('lstDriverModels')
$State.Controls.spDriverActionButtons = $window.FindName('spDriverActionButtons')
$State.Controls.btnSaveDriversJson = $window.FindName('btnSaveDriversJson')
$State.Controls.btnImportDriversJson = $window.FindName('btnImportDriversJson')
$State.Controls.btnDownloadSelectedDrivers = $window.FindName('btnDownloadSelectedDrivers')
$State.Controls.btnClearDriverList = $window.FindName('btnClearDriverList')
$State.Controls.chkInstallOffice = $window.FindName('chkInstallOffice')
$State.Controls.chkInstallApps = $window.FindName('chkInstallApps')
$State.Controls.OfficePathStackPanel = $window.FindName('OfficePathStackPanel')
$State.Controls.OfficePathGrid = $window.FindName('OfficePathGrid')
$State.Controls.CopyOfficeConfigXMLStackPanel = $window.FindName('CopyOfficeConfigXMLStackPanel')
$State.Controls.OfficeConfigurationXMLFileStackPanel = $window.FindName('OfficeConfigurationXMLFileStackPanel')
$State.Controls.OfficeConfigurationXMLFileGrid = $window.FindName('OfficeConfigurationXMLFileGrid')
$State.Controls.chkCopyOfficeConfigXML = $window.FindName('chkCopyOfficeConfigXML')
$State.Controls.chkLatestCU = $window.FindName('chkUpdateLatestCU')
$State.Controls.chkPreviewCU = $window.FindName('chkUpdatePreviewCU')
$State.Controls.btnCheckUSBDrives = $window.FindName('btnCheckUSBDrives')
$State.Controls.lstUSBDrives = $window.FindName('lstUSBDrives')
$State.Controls.chkBuildUSBDriveEnable = $window.FindName('chkBuildUSBDriveEnable')
$State.Controls.usbSection = $window.FindName('usbDriveSection')
$State.Controls.chkSelectSpecificUSBDrives = $window.FindName('chkSelectSpecificUSBDrives')
$State.Controls.usbSelectionPanel = $window.FindName('usbDriveSelectionPanel')
$State.Controls.chkAllowExternalHardDiskMedia = $window.FindName('chkAllowExternalHardDiskMedia')
$State.Controls.chkPromptExternalHardDiskMedia = $window.FindName('chkPromptExternalHardDiskMedia')
$State.Controls.chkCopyAdditionalFFUFiles = $window.FindName('chkCopyAdditionalFFUFiles')
$State.Controls.additionalFFUPanel = $window.FindName('additionalFFUPanel')
$State.Controls.lstAdditionalFFUs = $window.FindName('lstAdditionalFFUs')
$State.Controls.btnRefreshAdditionalFFUs = $window.FindName('btnRefreshAdditionalFFUs')
$State.Controls.chkInstallWingetApps = $window.FindName('chkInstallWingetApps')
$State.Controls.wingetPanel = $window.FindName('wingetPanel')
$State.Controls.btnCheckWingetModule = $window.FindName('btnCheckWingetModule')
$State.Controls.txtWingetVersion = $window.FindName('txtWingetVersion')
$State.Controls.txtWingetModuleVersion = $window.FindName('txtWingetModuleVersion')
$State.Controls.applicationPathPanel = $window.FindName('applicationPathPanel')
$State.Controls.appListJsonPathPanel = $window.FindName('appListJsonPathPanel')
$State.Controls.btnBrowseApplicationPath = $window.FindName('btnBrowseApplicationPath')
$State.Controls.btnBrowseAppListJsonPath = $window.FindName('btnBrowseAppListJsonPath')
$State.Controls.chkBringYourOwnApps = $window.FindName('chkBringYourOwnApps')
$State.Controls.byoApplicationPanel = $window.FindName('byoApplicationPanel')
$State.Controls.wingetSearchPanel = $window.FindName('wingetSearchPanel')
$State.Controls.txtWingetSearch = $window.FindName('txtWingetSearch')
$State.Controls.btnWingetSearch = $window.FindName('btnWingetSearch')
$State.Controls.lstWingetResults = $window.FindName('lstWingetResults')
$State.Controls.btnSaveWingetList = $window.FindName('btnSaveWingetList')
$State.Controls.btnImportWingetList = $window.FindName('btnImportWingetList')
$State.Controls.btnClearWingetList = $window.FindName('btnClearWingetList')
$State.Controls.btnDownloadSelected = $window.FindName('btnDownloadSelected')
$State.Controls.btnBrowseAppSource = $window.FindName('btnBrowseAppSource')
$State.Controls.btnBrowseFFUDevPath = $window.FindName('btnBrowseFFUDevPath')
$State.Controls.btnBrowseFFUCaptureLocation = $window.FindName('btnBrowseFFUCaptureLocation')
$State.Controls.btnBrowseOfficePath = $window.FindName('btnBrowseOfficePath')
$State.Controls.btnBrowseDriversFolder = $window.FindName('btnBrowseDriversFolder')
$State.Controls.btnBrowsePEDriversFolder = $window.FindName('btnBrowsePEDriversFolder')
$State.Controls.txtAppName = $window.FindName('txtAppName')
$State.Controls.txtAppCommandLine = $window.FindName('txtAppCommandLine')
$State.Controls.txtAppArguments = $window.FindName('txtAppArguments')
$State.Controls.txtAppSource = $window.FindName('txtAppSource')
$State.Controls.txtAppAdditionalExitCodes = $window.FindName('txtAppAdditionalExitCodes')
$State.Controls.chkIgnoreExitCodes = $window.FindName('chkIgnoreExitCodes')
$State.Controls.btnAddApplication = $window.FindName('btnAddApplication')
$State.Controls.btnSaveBYOApplications = $window.FindName('btnSaveBYOApplications')
$State.Controls.btnLoadBYOApplications = $window.FindName('btnLoadBYOApplications')
$State.Controls.btnEditApplication = $window.FindName('btnEditApplication')
$State.Controls.btnClearBYOApplications = $window.FindName('btnClearBYOApplications')
$State.Controls.btnRemoveSelectedBYOApps = $window.FindName('btnRemoveSelectedBYOApps')
$State.Controls.btnCopyBYOApps = $window.FindName('btnCopyBYOApps')
$State.Controls.lstApplications = $window.FindName('lstApplications')
$State.Controls.btnMoveTop = $window.FindName('btnMoveTop')
$State.Controls.btnMoveUp = $window.FindName('btnMoveUp')
$State.Controls.btnMoveDown = $window.FindName('btnMoveDown')
$State.Controls.btnMoveBottom = $window.FindName('btnMoveBottom')
$State.Controls.txtStatus = $window.FindName('txtStatus')
$State.Controls.pbOverallProgress = $window.FindName('progressBar')
$State.Controls.txtOverallStatus = $window.FindName('txtStatus')
$State.Controls.cmbVMSwitchName = $window.FindName('cmbVMSwitchName')
$State.Controls.txtVMHostIPAddress = $window.FindName('txtVMHostIPAddress')
$State.Controls.txtCustomVMSwitchName = $window.FindName('txtCustomVMSwitchName')
$State.Controls.txtFFUDevPath = $window.FindName('txtFFUDevPath')
$State.Controls.txtCustomFFUNameTemplate = $window.FindName('txtCustomFFUNameTemplate')
$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.txtMaxUSBDrives = $window.FindName('txtMaxUSBDrives')
$State.Controls.chkCompactOS = $window.FindName('chkCompactOS')
$State.Controls.chkOptimize = $window.FindName('chkOptimize')
$State.Controls.chkAllowVHDXCaching = $window.FindName('chkAllowVHDXCaching')
$State.Controls.chkCreateCaptureMedia = $window.FindName('chkCreateCaptureMedia')
$State.Controls.chkCreateDeploymentMedia = $window.FindName('chkCreateDeploymentMedia')
$State.Controls.chkInjectUnattend = $window.FindName('chkInjectUnattend')
$State.Controls.chkVerbose = $window.FindName('chkVerbose')
$State.Controls.chkCopyAutopilot = $window.FindName('chkCopyAutopilot')
$State.Controls.chkCopyUnattend = $window.FindName('chkCopyUnattend')
$State.Controls.chkCopyPPKG = $window.FindName('chkCopyPPKG')
$State.Controls.chkCleanupAppsISO = $window.FindName('chkCleanupAppsISO')
$State.Controls.chkCleanupCaptureISO = $window.FindName('chkCleanupCaptureISO')
$State.Controls.chkCleanupDeployISO = $window.FindName('chkCleanupDeployISO')
$State.Controls.chkCleanupDrivers = $window.FindName('chkCleanupDrivers')
$State.Controls.chkRemoveFFU = $window.FindName('chkRemoveFFU')
$State.Controls.txtDiskSize = $window.FindName('txtDiskSize')
$State.Controls.txtMemory = $window.FindName('txtMemory')
$State.Controls.txtProcessors = $window.FindName('txtProcessors')
$State.Controls.txtVMLocation = $window.FindName('txtVMLocation')
$State.Controls.txtVMNamePrefix = $window.FindName('txtVMNamePrefix')
$State.Controls.cmbLogicalSectorSize = $window.FindName('cmbLogicalSectorSize')
$State.Controls.txtProductKey = $window.FindName('txtProductKey')
$State.Controls.txtOfficePath = $window.FindName('txtOfficePath')
$State.Controls.txtOfficeConfigXMLFilePath = $window.FindName('txtOfficeConfigXMLFilePath')
$State.Controls.btnBrowseOfficeConfigXMLFile = $window.FindName('btnBrowseOfficeConfigXMLFile')
$State.Controls.txtDriversFolder = $window.FindName('txtDriversFolder')
$State.Controls.txtPEDriversFolder = $window.FindName('txtPEDriversFolder')
$State.Controls.chkCopyPEDrivers = $window.FindName('chkCopyPEDrivers')
$State.Controls.chkUseDriversAsPEDrivers = $window.FindName('chkUseDriversAsPEDrivers')
$State.Controls.chkUpdateLatestCU = $window.FindName('chkUpdateLatestCU')
$State.Controls.chkUpdateLatestNet = $window.FindName('chkUpdateLatestNet')
$State.Controls.chkUpdateLatestDefender = $window.FindName('chkUpdateLatestDefender')
$State.Controls.chkUpdateEdge = $window.FindName('chkUpdateEdge')
$State.Controls.chkUpdateOneDrive = $window.FindName('chkUpdateOneDrive')
$State.Controls.chkUpdateLatestMSRT = $window.FindName('chkUpdateLatestMSRT')
$State.Controls.chkUpdatePreviewCU = $window.FindName('chkUpdatePreviewCU')
$State.Controls.txtApplicationPath = $window.FindName('txtApplicationPath')
$State.Controls.txtAppListJsonPath = $window.FindName('txtAppListJsonPath')
$State.Controls.chkInstallDrivers = $window.FindName('chkInstallDrivers')
$State.Controls.chkCopyDrivers = $window.FindName('chkCopyDrivers')
$State.Controls.chkCompressDriversToWIM = $window.FindName('chkCompressDriversToWIM')
$State.Controls.chkRemoveApps = $window.FindName('chkRemoveApps')
$State.Controls.chkRemoveUpdates = $window.FindName('chkRemoveUpdates')
$State.Controls.chkUpdateLatestMicrocode = $window.FindName('chkUpdateLatestMicrocode')
$State.Controls.chkDefineAppsScriptVariables = $window.FindName('chkDefineAppsScriptVariables')
$State.Controls.appsScriptVariablesPanel = $window.FindName('appsScriptVariablesPanel')
$State.Controls.txtAppsScriptKey = $window.FindName('txtAppsScriptKey')
$State.Controls.txtAppsScriptValue = $window.FindName('txtAppsScriptValue')
$State.Controls.btnAddAppsScriptVariable = $window.FindName('btnAddAppsScriptVariable')
$State.Controls.lstAppsScriptVariables = $window.FindName('lstAppsScriptVariables')
$State.Controls.btnRemoveSelectedAppsScriptVariables = $window.FindName('btnRemoveSelectedAppsScriptVariables')
$State.Controls.btnClearAppsScriptVariables = $window.FindName('btnClearAppsScriptVariables')
$State.Controls.txtDriversJsonPath = $window.FindName('txtDriversJsonPath')
$State.Controls.btnBrowseDriversJsonPath = $window.FindName('btnBrowseDriversJsonPath')
$State.Controls.chkUpdateADK = $window.FindName('chkUpdateADK')
$State.Controls.btnLoadConfig = $window.FindName('btnLoadConfig')
$State.Controls.btnRestoreDefaults = $window.FindName('btnRestoreDefaults')
$State.Controls.btnBuildConfig = $window.FindName('btnBuildConfig')
# Monitor Tab
$State.Controls.MainTabControl = $window.FindName('MainTabControl')
$State.Controls.MonitorTab = $window.FindName('MonitorTab')
$State.Controls.lstLogOutput = $window.FindName('lstLogOutput')
# Initialize and bind the log data collection
$State.Data.logData = New-Object System.Collections.ObjectModel.ObservableCollection[string]
$State.Controls.lstLogOutput.ItemsSource = $State.Data.logData
}
function Initialize-VMSwitchData {
param([PSCustomObject]$State)
WriteLog "Initializing VM Switch data..."
# Hyper-V Settings: Populate VM Switch ComboBox
$vmSwitchData = Get-VMSwitchData
$State.Data.vmSwitchMap = $vmSwitchData.SwitchMap
$State.Controls.cmbVMSwitchName.Items.Clear()
foreach ($switchName in $vmSwitchData.SwitchNames) {
$State.Controls.cmbVMSwitchName.Items.Add($switchName) | Out-Null
}
$State.Controls.cmbVMSwitchName.Items.Add('Other') | Out-Null
if ($State.Controls.cmbVMSwitchName.Items.Count -gt 1) {
$State.Controls.cmbVMSwitchName.SelectedIndex = 0
$firstSwitch = $State.Controls.cmbVMSwitchName.SelectedItem
if ($null -ne $firstSwitch -and $State.Data.vmSwitchMap.ContainsKey($firstSwitch)) {
$State.Controls.txtVMHostIPAddress.Text = $State.Data.vmSwitchMap[$firstSwitch]
}
else {
$State.Controls.txtVMHostIPAddress.Text = $State.Defaults.generalDefaults.VMHostIPAddress # Use default if IP not found or key null
}
$State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
}
else {
$State.Controls.cmbVMSwitchName.SelectedItem = 'Other'
$State.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
$State.Controls.txtVMHostIPAddress.Text = $State.Defaults.generalDefaults.VMHostIPAddress # Use default
}
}
function Initialize-UIDefaults {
param([PSCustomObject]$State)
WriteLog "Initializing UI defaults..."
# Get default values from helper functions
$State.Defaults.windowsSettingsDefaults = Get-WindowsSettingsDefaults
$State.Defaults.generalDefaults = Get-GeneralDefaults -FFUDevelopmentPath $State.FFUDevelopmentPath
# Build tab defaults from General Defaults
$State.Controls.txtFFUDevPath.Text = $State.FFUDevelopmentPath
$State.Controls.txtCustomFFUNameTemplate.Text = $State.Defaults.generalDefaults.CustomFFUNameTemplate
$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.txtMaxUSBDrives.Text = $State.Defaults.generalDefaults.MaxUSBDrives
$State.Controls.chkBuildUSBDriveEnable.IsChecked = $State.Defaults.generalDefaults.BuildUSBDriveEnable
$State.Controls.chkCompactOS.IsChecked = $State.Defaults.generalDefaults.CompactOS
$State.Controls.chkUpdateADK.IsChecked = $State.Defaults.generalDefaults.UpdateADK
$State.Controls.chkOptimize.IsChecked = $State.Defaults.generalDefaults.Optimize
$State.Controls.chkAllowVHDXCaching.IsChecked = $State.Defaults.generalDefaults.AllowVHDXCaching
$State.Controls.chkInjectUnattend.IsChecked = $State.Defaults.generalDefaults.InjectUnattend
$State.Controls.chkCreateCaptureMedia.IsChecked = $State.Defaults.generalDefaults.CreateCaptureMedia
$State.Controls.chkCreateDeploymentMedia.IsChecked = $State.Defaults.generalDefaults.CreateDeploymentMedia
$State.Controls.chkAllowExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.AllowExternalHardDiskMedia
$State.Controls.chkPromptExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.PromptExternalHardDiskMedia
$State.Controls.chkSelectSpecificUSBDrives.IsChecked = $State.Defaults.generalDefaults.SelectSpecificUSBDrives
$State.Controls.chkCopyAutopilot.IsChecked = $State.Defaults.generalDefaults.CopyAutopilot
$State.Controls.chkCopyUnattend.IsChecked = $State.Defaults.generalDefaults.CopyUnattend
$State.Controls.chkCopyPPKG.IsChecked = $State.Defaults.generalDefaults.CopyPPKG
$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.chkCleanupDrivers.IsChecked = $State.Defaults.generalDefaults.CleanupDrivers
$State.Controls.chkRemoveFFU.IsChecked = $State.Defaults.generalDefaults.RemoveFFU
$State.Controls.chkRemoveApps.IsChecked = $State.Defaults.generalDefaults.RemoveApps
$State.Controls.chkRemoveUpdates.IsChecked = $State.Defaults.generalDefaults.RemoveUpdates
$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.chkSelectSpecificUSBDrives.IsEnabled = $State.Controls.chkBuildUSBDriveEnable.IsChecked
$State.Controls.chkPromptExternalHardDiskMedia.IsEnabled = $State.Controls.chkAllowExternalHardDiskMedia.IsChecked
$State.Controls.chkCopyAdditionalFFUFiles.IsChecked = $State.Defaults.generalDefaults.CopyAdditionalFFUFiles
$State.Controls.additionalFFUPanel.Visibility = if ($State.Controls.chkCopyAdditionalFFUFiles.IsChecked) { 'Visible' } else { 'Collapsed' }
# Hyper-V Settings defaults from General Defaults
Initialize-VMSwitchData -State $State
$State.Controls.txtDiskSize.Text = $State.Defaults.generalDefaults.DiskSizeGB
$State.Controls.txtMemory.Text = $State.Defaults.generalDefaults.MemoryGB
$State.Controls.txtProcessors.Text = $State.Defaults.generalDefaults.Processors
$State.Controls.txtVMLocation.Text = $State.Defaults.generalDefaults.VMLocation
$State.Controls.txtVMNamePrefix.Text = $State.Defaults.generalDefaults.VMNamePrefix
$State.Controls.cmbLogicalSectorSize.SelectedItem = ($State.Controls.cmbLogicalSectorSize.Items | Where-Object { $_.Content -eq $State.Defaults.generalDefaults.LogicalSectorSize.ToString() })
# Populate Windows Release, Version, and SKU comboboxes
Get-WindowsSettingsCombos -isoPath $State.Defaults.windowsSettingsDefaults.DefaultISOPath -State $State
# Windows Settings tab defaults
$State.Controls.cmbWindowsLang.ItemsSource = $State.Defaults.windowsSettingsDefaults.AllowedLanguages
$State.Controls.cmbWindowsLang.SelectedItem = $State.Defaults.windowsSettingsDefaults.DefaultWindowsLang
$State.Controls.cmbMediaType.ItemsSource = $State.Defaults.windowsSettingsDefaults.AllowedMediaTypes
$State.Controls.cmbMediaType.SelectedItem = $State.Defaults.windowsSettingsDefaults.DefaultMediaType
$State.Controls.txtProductKey.Text = $State.Defaults.windowsSettingsDefaults.DefaultProductKey
# Updates tab defaults from General Defaults
$State.Controls.chkUpdateLatestCU.IsChecked = $State.Defaults.generalDefaults.UpdateLatestCU
$State.Controls.chkUpdateLatestNet.IsChecked = $State.Defaults.generalDefaults.UpdateLatestNet
$State.Controls.chkUpdateLatestDefender.IsChecked = $State.Defaults.generalDefaults.UpdateLatestDefender
$State.Controls.chkUpdateEdge.IsChecked = $State.Defaults.generalDefaults.UpdateEdge
$State.Controls.chkUpdateOneDrive.IsChecked = $State.Defaults.generalDefaults.UpdateOneDrive
$State.Controls.chkUpdateLatestMSRT.IsChecked = $State.Defaults.generalDefaults.UpdateLatestMSRT
$State.Controls.chkUpdateLatestMicrocode.IsChecked = $State.Defaults.generalDefaults.UpdateLatestMicrocode
$State.Controls.chkUpdatePreviewCU.IsChecked = $State.Defaults.generalDefaults.UpdatePreviewCU
# Set initial state for CU checkbox interplay
$State.Controls.chkPreviewCU.IsEnabled = -not $State.Controls.chkLatestCU.IsChecked
$State.Controls.chkLatestCU.IsEnabled = -not $State.Controls.chkPreviewCU.IsChecked
# Applications tab defaults from General Defaults
$State.Controls.chkInstallApps.IsChecked = $State.Defaults.generalDefaults.InstallApps
$State.Controls.txtApplicationPath.Text = $State.Defaults.generalDefaults.ApplicationPath
$State.Controls.txtAppListJsonPath.Text = $State.Defaults.generalDefaults.AppListJsonPath
$State.Controls.chkInstallWingetApps.IsChecked = $State.Defaults.generalDefaults.InstallWingetApps
$State.Controls.chkBringYourOwnApps.IsChecked = $State.Defaults.generalDefaults.BringYourOwnApps
# M365 Apps/Office tab defaults from General Defaults
$State.Controls.chkInstallOffice.IsChecked = $State.Defaults.generalDefaults.InstallOffice
$State.Controls.txtOfficePath.Text = $State.Defaults.generalDefaults.OfficePath
$State.Controls.chkCopyOfficeConfigXML.IsChecked = $State.Defaults.generalDefaults.CopyOfficeConfigXML
$State.Controls.txtOfficeConfigXMLFilePath.Text = $State.Defaults.generalDefaults.OfficeConfigXMLFilePath
# Drivers tab defaults from General Defaults
$State.Controls.txtDriversFolder.Text = $State.Defaults.generalDefaults.DriversFolder
$State.Controls.txtPEDriversFolder.Text = $State.Defaults.generalDefaults.PEDriversFolder
$State.Controls.txtDriversJsonPath.Text = $State.Defaults.generalDefaults.DriversJsonPath
$State.Controls.chkDownloadDrivers.IsChecked = $State.Defaults.generalDefaults.DownloadDrivers
$State.Controls.chkInstallDrivers.IsChecked = $State.Defaults.generalDefaults.InstallDrivers
$State.Controls.chkCopyDrivers.IsChecked = $State.Defaults.generalDefaults.CopyDrivers
$State.Controls.chkCopyPEDrivers.IsChecked = $State.Defaults.generalDefaults.CopyPEDrivers
$State.Controls.chkUseDriversAsPEDrivers.IsChecked = $State.Defaults.generalDefaults.UseDriversAsPEDrivers
$State.Controls.chkCompressDriversToWIM.IsChecked = $State.Defaults.generalDefaults.CompressDownloadedDriversToWim
# Drivers tab UI logic
$makeList = @('Microsoft', 'Dell', 'HP', 'Lenovo')
foreach ($m in $makeList) {
[void]$State.Controls.cmbMake.Items.Add($m)
}
if ($State.Controls.cmbMake.Items.Count -gt 0) {
$State.Controls.cmbMake.SelectedIndex = 0
}
Update-DriverDownloadPanelVisibility -State $State
# Set initial state for driver checkbox interplay
Update-DriverCheckboxStates -State $State
# Set initial state for InstallApps checkbox based on updates
Update-InstallAppsState -State $State
# Set initial state for Office panel visibility
Update-OfficePanelVisibility -State $State
# Set initial state for Application panel visibility
Update-ApplicationPanelVisibility -State $State
# Set initial state for BYO Apps copy button
Update-CopyButtonState -State $State
}
function Initialize-DynamicUIElements {
param([PSCustomObject]$State)
WriteLog "Initializing dynamic UI elements (Grids, Columns)..."
# Driver Models ListView setup
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell
$itemStyleDriverModels = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
$itemStyleDriverModels.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstDriverModels.ItemContainerStyle = $itemStyleDriverModels
$driverModelsGridView = New-Object System.Windows.Controls.GridView
$State.Controls.lstDriverModels.View = $driverModelsGridView # Assign GridView to ListView first
# Add the selectable column using the new function
Add-SelectableGridViewColumn -ListView $State.Controls.lstDriverModels -State $State -HeaderCheckBoxKeyName "chkSelectAllDriverModels" -ColumnWidth 70
# 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 "Model" -binding "Model" -width 200 -headerHorizontalAlignment Left
Add-SortableColumn -gridView $driverModelsGridView -header "Status" -binding "DownloadStatus" -width 150 -headerHorizontalAlignment Left
$State.Controls.lstDriverModels.AddHandler(
[System.Windows.Controls.GridViewColumnHeader]::ClickEvent,
[System.Windows.RoutedEventHandler] {
param($eventSource, $e) # $eventSource is the ListView control
$header = $e.OriginalSource
if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) {
# Retrieve the main UI state object from the window's Tag property
$listViewControl = $eventSource
$window = [System.Windows.Window]::GetWindow($listViewControl)
$uiStateFromWindowTag = $window.Tag
Invoke-ListViewSort -listView $eventSource -property $header.Tag -State $uiStateFromWindowTag
}
}
)
# Winget Search ListView setup
$wingetGridView = New-Object System.Windows.Controls.GridView
$State.Controls.lstWingetResults.View = $wingetGridView # Assign GridView to ListView first
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell
$itemStyleWingetResults = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
$itemStyleWingetResults.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstWingetResults.ItemContainerStyle = $itemStyleWingetResults
# Add the selectable column using the new function
Add-SelectableGridViewColumn -ListView $State.Controls.lstWingetResults -State $State -HeaderCheckBoxKeyName "chkSelectAllWingetResults" -ColumnWidth 60
# Add other sortable columns with left-aligned headers
Add-SortableColumn -gridView $wingetGridView -header "Name" -binding "Name" -width 200 -headerHorizontalAlignment Left
Add-SortableColumn -gridView $wingetGridView -header "Id" -binding "Id" -width 200 -headerHorizontalAlignment Left
Add-SortableColumn -gridView $wingetGridView -header "Version" -binding "Version" -width 100 -headerHorizontalAlignment Left
Add-SortableColumn -gridView $wingetGridView -header "Source" -binding "Source" -width 100 -headerHorizontalAlignment Left
# --- START: Add Architecture Column ---
$archColumn = New-Object System.Windows.Controls.GridViewColumn
$archHeader = New-Object System.Windows.Controls.GridViewColumnHeader
$archHeader.Tag = "Architecture" # For sorting
$archHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
# Create header content with correct padding to match other columns
$commonPaddingForHeader = New-Object System.Windows.Thickness(5, 2, 5, 2)
$headerTextElementFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock])
$headerTextElementFactory.SetValue([System.Windows.Controls.TextBlock]::TextProperty, "Architecture")
$headerTextBlockPadding = New-Object System.Windows.Thickness($commonPaddingForHeader.Left, $commonPaddingForHeader.Top, $commonPaddingForHeader.Right, $commonPaddingForHeader.Bottom)
$headerTextElementFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, $headerTextBlockPadding)
$headerTextElementFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
$headerDataTemplate = New-Object System.Windows.DataTemplate
$headerDataTemplate.VisualTree = $headerTextElementFactory
$archHeader.ContentTemplate = $headerDataTemplate
$archColumn.Header = $archHeader
$archColumn.Width = 120
# Create the CellTemplate with a ComboBox
$archCellTemplate = New-Object System.Windows.DataTemplate
$comboBoxFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.ComboBox])
# The ItemsSource for the ComboBox
$availableArchitectures = @('x86', 'x64', 'arm64', 'x86 x64', 'NA')
$comboBoxFactory.SetValue([System.Windows.Controls.ItemsControl]::ItemsSourceProperty, $availableArchitectures)
# Bind the text property to the 'Architecture' property of the data item.
# This ensures the initial value is displayed correctly.
$binding = New-Object System.Windows.Data.Binding("Architecture")
$binding.Mode = [System.Windows.Data.BindingMode]::TwoWay
$comboBoxFactory.SetBinding([System.Windows.Controls.ComboBox]::TextProperty, $binding)
# Create a style to disable the ComboBox for 'msstore' source
$comboBoxStyle = New-Object System.Windows.Style
$comboBoxStyle.TargetType = [System.Windows.Controls.ComboBox]
$dataTrigger = New-Object System.Windows.DataTrigger
$dataTrigger.Binding = New-Object System.Windows.Data.Binding("Source")
$dataTrigger.Value = "msstore"
$dataTrigger.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ComboBox]::IsEnabledProperty, $false)))
$comboBoxStyle.Triggers.Add($dataTrigger)
$comboBoxFactory.SetValue([System.Windows.FrameworkElement]::StyleProperty, $comboBoxStyle)
$archCellTemplate.VisualTree = $comboBoxFactory
$archColumn.CellTemplate = $archCellTemplate
$wingetGridView.Columns.Add($archColumn)
# --- END: Add Architecture Column ---
# --- START: Add Additional Exit Codes Column ---
$exitCodesColumn = New-Object System.Windows.Controls.GridViewColumn
$exitCodesHeader = New-Object System.Windows.Controls.GridViewColumnHeader
$exitCodesHeader.Tag = "AdditionalExitCodes"
$exitCodesHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
$exitHeaderTextFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock])
$exitHeaderTextFactory.SetValue([System.Windows.Controls.TextBlock]::TextProperty, "Additional Exit Codes")
$exitHeaderTextFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, (New-Object System.Windows.Thickness(5, 2, 5, 2)))
$exitHeaderTextFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
$exitHeaderTemplate = New-Object System.Windows.DataTemplate
$exitHeaderTemplate.VisualTree = $exitHeaderTextFactory
$exitCodesHeader.ContentTemplate = $exitHeaderTemplate
$exitCodesColumn.Header = $exitCodesHeader
$exitCodesColumn.Width = 140
$exitCodesCellTemplate = New-Object System.Windows.DataTemplate
$exitCodesTextBoxFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBox])
$exitBinding = New-Object System.Windows.Data.Binding("AdditionalExitCodes")
$exitBinding.Mode = [System.Windows.Data.BindingMode]::TwoWay
$exitCodesTextBoxFactory.SetBinding([System.Windows.Controls.TextBox]::TextProperty, $exitBinding)
$exitCodesCellTemplate.VisualTree = $exitCodesTextBoxFactory
$exitCodesColumn.CellTemplate = $exitCodesCellTemplate
$wingetGridView.Columns.Add($exitCodesColumn)
# --- END: Add Additional Exit Codes Column ---
# --- START: Add Ignore Non-Zero Exit Codes Column ---
$ignoreColumn = New-Object System.Windows.Controls.GridViewColumn
$ignoreHeader = New-Object System.Windows.Controls.GridViewColumnHeader
$ignoreHeader.Tag = "IgnoreNonZeroExitCodes"
$ignoreHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
$ignoreHeaderTextFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock])
$ignoreHeaderTextFactory.SetValue([System.Windows.Controls.TextBlock]::TextProperty, "Ignore Exit Codes")
$ignoreHeaderTextFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, (New-Object System.Windows.Thickness(5, 2, 5, 2)))
$ignoreHeaderTextFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
$ignoreHeaderTemplate = New-Object System.Windows.DataTemplate
$ignoreHeaderTemplate.VisualTree = $ignoreHeaderTextFactory
$ignoreHeader.ContentTemplate = $ignoreHeaderTemplate
$ignoreColumn.Header = $ignoreHeader
$ignoreColumn.Width = 140
$ignoreCellTemplate = New-Object System.Windows.DataTemplate
# Center the checkbox in the cell
$ignoreCellGridFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.Grid])
$ignoreCellGridFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)
$ignoreCellGridFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Stretch)
$ignoreCheckFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.CheckBox])
$ignoreCheckFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Center)
$ignoreCheckFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
$ignoreBinding = New-Object System.Windows.Data.Binding("IgnoreNonZeroExitCodes")
$ignoreBinding.Mode = [System.Windows.Data.BindingMode]::TwoWay
$ignoreCheckFactory.SetBinding([System.Windows.Controls.Primitives.ToggleButton]::IsCheckedProperty, $ignoreBinding)
# Build the visual tree: Grid -> CheckBox
$ignoreCellGridFactory.AppendChild($ignoreCheckFactory)
$ignoreCellTemplate.VisualTree = $ignoreCellGridFactory
$ignoreColumn.CellTemplate = $ignoreCellTemplate
$wingetGridView.Columns.Add($ignoreColumn)
# --- END: Add Ignore Non-Zero Exit Codes Column ---
Add-SortableColumn -gridView $wingetGridView -header "Download Status" -binding "DownloadStatus" -width 150 -headerHorizontalAlignment Left
$State.Controls.lstWingetResults.AddHandler(
[System.Windows.Controls.GridViewColumnHeader]::ClickEvent,
[System.Windows.RoutedEventHandler] {
param($eventSource, $e) # $eventSource is the ListView control
$header = $e.OriginalSource
if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) {
# Retrieve the main UI state object from the window's Tag property
$listViewControl = $eventSource
$window = [System.Windows.Window]::GetWindow($listViewControl)
$uiStateFromWindowTag = $window.Tag
Invoke-ListViewSort -listView $eventSource -property $header.Tag -State $uiStateFromWindowTag
}
}
)
# BYO Applications ListView setup
$byoAppsGridView = New-Object System.Windows.Controls.GridView
$State.Controls.lstApplications.View = $byoAppsGridView
# Set ListViewItem style to stretch content horizontally
$itemStyleBYOApps = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
$itemStyleBYOApps.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstApplications.ItemContainerStyle = $itemStyleBYOApps
# Add the selectable column
Add-SelectableGridViewColumn -ListView $State.Controls.lstApplications -State $State -HeaderCheckBoxKeyName "chkSelectAllBYOApps" -ColumnWidth 60
# Add other sortable columns
Add-SortableColumn -gridView $byoAppsGridView -header "Priority" -binding "Priority" -width 60 -headerHorizontalAlignment Left
Add-SortableColumn -gridView $byoAppsGridView -header "Name" -binding "Name" -width 150 -headerHorizontalAlignment Left
Add-SortableColumn -gridView $byoAppsGridView -header "Command Line" -binding "CommandLine" -width 200 -headerHorizontalAlignment Left
Add-SortableColumn -gridView $byoAppsGridView -header "Arguments" -binding "Arguments" -width 200 -headerHorizontalAlignment Left
Add-SortableColumn -gridView $byoAppsGridView -header "Source" -binding "Source" -width 150 -headerHorizontalAlignment Left
Add-SortableColumn -gridView $byoAppsGridView -header "Exit Codes" -binding "AdditionalExitCodes" -width 100 -headerHorizontalAlignment Left
Add-SortableColumn -gridView $byoAppsGridView -header "Ignore Exit Codes" -binding "IgnoreExitCodes" -width 120 -headerHorizontalAlignment Left
Add-SortableColumn -gridView $byoAppsGridView -header "Copy Status" -binding "CopyStatus" -width 150 -headerHorizontalAlignment Left
# Apps Script Variables ListView setup
# Bind ItemsSource to the data list
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell
$itemStyleAppsScriptVars = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
$itemStyleAppsScriptVars.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstAppsScriptVariables.ItemContainerStyle = $itemStyleAppsScriptVars
# The GridView for lstAppsScriptVariables is defined in XAML. We need to get it and add the column.
if ($State.Controls.lstAppsScriptVariables.View -is [System.Windows.Controls.GridView]) {
Add-SelectableGridViewColumn -ListView $State.Controls.lstAppsScriptVariables -State $State -HeaderCheckBoxKeyName "chkSelectAllAppsScriptVariables" -ColumnWidth 60
# Make Key and Value columns sortable
$appsScriptVarsGridView = $State.Controls.lstAppsScriptVariables.View
# Key Column (should be at index 1 after selectable column is inserted at 0)
if ($appsScriptVarsGridView.Columns.Count -gt 1) {
$keyColumn = $appsScriptVarsGridView.Columns[1]
$keyHeader = New-Object System.Windows.Controls.GridViewColumnHeader
$keyHeader.Content = "Key"
$keyHeader.Tag = "Key" # Property to sort by
$keyHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
$keyColumn.Header = $keyHeader
}
# Value Column (should be at index 2)
if ($appsScriptVarsGridView.Columns.Count -gt 2) {
$valueColumn = $appsScriptVarsGridView.Columns[2]
$valueHeader = New-Object System.Windows.Controls.GridViewColumnHeader
$valueHeader.Content = "Value"
$valueHeader.Tag = "Value" # Property to sort by
$valueHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
$valueColumn.Header = $valueHeader
}
# Add Click event handler for sorting
$State.Controls.lstAppsScriptVariables.AddHandler(
[System.Windows.Controls.GridViewColumnHeader]::ClickEvent,
[System.Windows.RoutedEventHandler] {
param($eventSource, $e) # $eventSource is the ListView control
$header = $e.OriginalSource
if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) {
# Retrieve the main UI state object from the window's Tag property
$listViewControl = $eventSource
$window = [System.Windows.Window]::GetWindow($listViewControl)
$uiStateFromWindowTag = $window.Tag
Invoke-ListViewSort -listView $eventSource -property $header.Tag -State $uiStateFromWindowTag
}
}
)
}
else {
WriteLog "Warning: lstAppsScriptVariables.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
}
# Build dynamic multi-column checkboxes for optional features
if ($State.Controls.featuresPanel -and $State.Defaults.windowsSettingsDefaults) {
BuildFeaturesGrid -parent $State.Controls.featuresPanel -allowedFeatures $State.Defaults.windowsSettingsDefaults.AllowedFeatures -State $State
}
else {
WriteLog "Initialize-DynamicUIElements: Could not build features grid. Panel or defaults missing."
}
# USB Drives ListView setup
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell
$itemStyleUSBDrives = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
$itemStyleUSBDrives.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstUSBDrives.ItemContainerStyle = $itemStyleUSBDrives
if ($State.Controls.lstUSBDrives.View -is [System.Windows.Controls.GridView]) {
# Add the selectable column using the shared function
Add-SelectableGridViewColumn -ListView $State.Controls.lstUSBDrives -State $State -HeaderCheckBoxKeyName "chkSelectAllUSBDrivesHeader" -ColumnWidth 70
# Make other columns sortable
$usbDrivesGridView = $State.Controls.lstUSBDrives.View
# Model Column (index 0 in XAML, now 1)
if ($usbDrivesGridView.Columns.Count -gt 1) {
$modelColumn = $usbDrivesGridView.Columns[1]
$modelHeader = New-Object System.Windows.Controls.GridViewColumnHeader
$modelHeader.Content = "Model"
$modelHeader.Tag = "Model" # Property to sort by
$modelHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
$modelColumn.Header = $modelHeader
}
# Serial Number Column (index 1 in XAML, now 2)
if ($usbDrivesGridView.Columns.Count -gt 2) {
$serialColumn = $usbDrivesGridView.Columns[2]
$serialHeader = New-Object System.Windows.Controls.GridViewColumnHeader
$serialHeader.Content = "Serial Number"
$serialHeader.Tag = "SerialNumber" # Property to sort by
$serialHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
$serialColumn.Header = $serialHeader
}
# Size Column (index 2 in XAML, now 3)
if ($usbDrivesGridView.Columns.Count -gt 3) {
$sizeColumn = $usbDrivesGridView.Columns[3]
$sizeHeader = New-Object System.Windows.Controls.GridViewColumnHeader
$sizeHeader.Content = "Size (GB)"
$sizeHeader.Tag = "Size" # Property to sort by
$sizeHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
$sizeColumn.Header = $sizeHeader
}
# Add Click event handler for sorting
$State.Controls.lstUSBDrives.AddHandler(
[System.Windows.Controls.GridViewColumnHeader]::ClickEvent,
[System.Windows.RoutedEventHandler] {
param($eventSource, $e) # $eventSource is the ListView control
$header = $e.OriginalSource
if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) {
# Retrieve the main UI state object from the window's Tag property
$listViewControl = $eventSource
$window = [System.Windows.Window]::GetWindow($listViewControl)
$uiStateFromWindowTag = $window.Tag
Invoke-ListViewSort -listView $eventSource -property $header.Tag -State $uiStateFromWindowTag
}
}
)
}
else {
WriteLog "Warning: lstUSBDrives.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
}
# Additional FFUs ListView setup
$itemStyleAdditionalFFUs = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
$itemStyleAdditionalFFUs.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstAdditionalFFUs.ItemContainerStyle = $itemStyleAdditionalFFUs
if ($State.Controls.lstAdditionalFFUs.View -is [System.Windows.Controls.GridView]) {
Add-SelectableGridViewColumn -ListView $State.Controls.lstAdditionalFFUs -State $State -HeaderCheckBoxKeyName "chkSelectAllAdditionalFFUs" -ColumnWidth 70
$additionalFFUsGridView = $State.Controls.lstAdditionalFFUs.View
if ($additionalFFUsGridView.Columns.Count -gt 1) {
$nameColumn = $additionalFFUsGridView.Columns[1]
$nameHeader = New-Object System.Windows.Controls.GridViewColumnHeader
$nameHeader.Content = "FFU Name"
$nameHeader.Tag = "Name"
$nameHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
$nameColumn.Header = $nameHeader
}
if ($additionalFFUsGridView.Columns.Count -gt 2) {
$lastModColumn = $additionalFFUsGridView.Columns[2]
$lastModHeader = New-Object System.Windows.Controls.GridViewColumnHeader
$lastModHeader.Content = "Last Modified"
$lastModHeader.Tag = "LastModified"
$lastModHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Left
$lastModColumn.Header = $lastModHeader
}
$State.Controls.lstAdditionalFFUs.AddHandler(
[System.Windows.Controls.GridViewColumnHeader]::ClickEvent,
[System.Windows.RoutedEventHandler] {
param($eventSource, $e)
$header = $e.OriginalSource
if ($header -is [System.Windows.Controls.GridViewColumnHeader] -and $header.Tag) {
$listViewControl = $eventSource
$window = [System.Windows.Window]::GetWindow($listViewControl)
$uiStateFromWindowTag = $window.Tag
Invoke-ListViewSort -listView $eventSource -property $header.Tag -State $uiStateFromWindowTag
}
}
)
}
else {
WriteLog "Warning: lstAdditionalFFUs.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
}
}
Export-ModuleMember -Function *
@@ -0,0 +1,954 @@
<#
.SYNOPSIS
Provides a collection of shared helper functions for manipulating WPF UI controls, handling asynchronous updates, and managing common UI interactions.
.DESCRIPTION
This module contains a variety of reusable functions designed to support the FFU Builder UI. It includes utilities for managing ListView controls, such as sorting, reordering items, and handling 'Select All' functionality. It also provides thread-safe mechanisms for updating the UI from background tasks, wrappers for modern and classic file/folder dialogs, and generic functions for clearing UI content. These shared functions help to reduce code duplication and ensure consistent behavior across different parts of the application.
#>
# Function to update priorities sequentially in a ListView
function Update-ListViewPriorities {
param(
[Parameter(Mandatory)]
[System.Windows.Controls.ListView]$ListView
)
$currentPriority = 1
foreach ($item in $ListView.Items) {
if ($null -ne $item -and $item.PSObject.Properties['Priority']) {
$item.Priority = $currentPriority
$currentPriority++
}
}
$ListView.Items.Refresh()
}
# Function to move selected item to the top
function Move-ListViewItemTop {
param(
[Parameter(Mandatory)]
[System.Windows.Controls.ListView]$ListView
)
$selectedItem = $ListView.SelectedItem
if ($null -eq $selectedItem) { return }
$currentIndex = $ListView.Items.IndexOf($selectedItem)
if ($currentIndex -gt 0) {
$ListView.Items.RemoveAt($currentIndex)
$ListView.Items.Insert(0, $selectedItem)
$ListView.SelectedItem = $selectedItem
Update-ListViewPriorities -ListView $ListView
}
}
# Function to move selected item up one position
function Move-ListViewItemUp {
param(
[Parameter(Mandatory)]
[System.Windows.Controls.ListView]$ListView
)
$selectedItem = $ListView.SelectedItem
if ($null -eq $selectedItem) { return }
$currentIndex = $ListView.Items.IndexOf($selectedItem)
if ($currentIndex -gt 0) {
$ListView.Items.RemoveAt($currentIndex)
$ListView.Items.Insert($currentIndex - 1, $selectedItem)
$ListView.SelectedItem = $selectedItem
Update-ListViewPriorities -ListView $ListView
}
}
# Function to move selected item down one position
function Move-ListViewItemDown {
param(
[Parameter(Mandatory)]
[System.Windows.Controls.ListView]$ListView
)
$selectedItem = $ListView.SelectedItem
if ($null -eq $selectedItem) { return }
$currentIndex = $ListView.Items.IndexOf($selectedItem)
if ($currentIndex -lt ($ListView.Items.Count - 1)) {
$ListView.Items.RemoveAt($currentIndex)
$ListView.Items.Insert($currentIndex + 1, $selectedItem)
$ListView.SelectedItem = $selectedItem
Update-ListViewPriorities -ListView $ListView
}
}
# Function to move selected item to the bottom
function Move-ListViewItemBottom {
param(
[Parameter(Mandatory)]
[System.Windows.Controls.ListView]$ListView
)
$selectedItem = $ListView.SelectedItem
if ($null -eq $selectedItem) { return }
$currentIndex = $ListView.Items.IndexOf($selectedItem)
if ($currentIndex -lt ($ListView.Items.Count - 1)) {
$ListView.Items.RemoveAt($currentIndex)
$ListView.Items.Add($selectedItem)
$ListView.SelectedItem = $selectedItem
Update-ListViewPriorities -ListView $ListView
}
}
# Function to update status of a specific item in a ListView
function Update-ListViewItemStatus {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object]$WindowObject,
[Parameter(Mandatory)]
[object]$ListView,
[Parameter(Mandatory)]
[string]$IdentifierProperty,
[Parameter(Mandatory)]
[string]$IdentifierValue,
[Parameter(Mandatory)]
[string]$StatusProperty,
[Parameter(Mandatory)]
[string]$StatusValue
)
# Ensure we are in UI mode and objects are of correct WPF types
if ($WindowObject -is [System.Windows.Window] -and $ListView -is [System.Windows.Controls.ListView]) {
# Directly update UI elements as this function is now called on the UI thread
try {
# Determine which collection to search: ItemsSource (preferred) or Items.
$collectionToSearch = $null
if ($null -ne $ListView.ItemsSource) {
$collectionToSearch = $ListView.ItemsSource
}
else {
$collectionToSearch = $ListView.Items
}
$itemToUpdate = $collectionToSearch | Where-Object { $_.$IdentifierProperty -eq $IdentifierValue } | Select-Object -First 1
if ($null -ne $itemToUpdate) {
$itemToUpdate.$StatusProperty = $StatusValue
$ListView.Items.Refresh() # Refresh the view to show the change
}
else {
# Log if item not found (for debugging)
WriteLog "Update-ListViewItemStatus: Item with $IdentifierProperty '$IdentifierValue' not found in ListView."
}
}
catch {
WriteLog "Update-ListViewItemStatus: Error updating ListView: $($_.Exception.Message)"
}
}
else {
# Log if called in non-UI mode or with incorrect types (should not happen if Invoke-ParallelProcessing $isUiMode is correct)
WriteLog "Update-ListViewItemStatus: Skipped UI update for $IdentifierValue due to non-UI mode or incorrect object types."
}
}
# Function to update overall progress bar and status text label
function Update-OverallProgress {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object]$WindowObject,
[Parameter(Mandatory)]
[int]$CompletedCount,
[Parameter(Mandatory)]
[int]$TotalCount,
[Parameter(Mandatory)]
[string]$StatusText,
[Parameter(Mandatory)]
[string]$ProgressBarName,
[Parameter(Mandatory)]
[string]$StatusLabelName
)
# Ensure we are in UI mode and WindowObject is of correct WPF type
if ($WindowObject -is [System.Windows.Window]) {
# Directly update UI elements as this function is now called on the UI thread
try {
# Find controls by name using the $WindowObject
$pb = $WindowObject.FindName($ProgressBarName)
$lbl = $WindowObject.FindName($StatusLabelName)
if ($null -eq $pb) {
WriteLog "Update-OverallProgress: ProgressBar '$ProgressBarName' not found."
return
}
if ($null -eq $lbl) {
WriteLog "Update-OverallProgress: StatusLabel '$StatusLabelName' not found."
return
}
# Update the progress bar
if ($TotalCount -gt 0) {
$percentComplete = ($CompletedCount / $TotalCount) * 100
$pb.Value = $percentComplete
}
else {
$pb.Value = 0
}
# Update the status label
$lbl.Text = $StatusText
}
catch {
WriteLog "Update-OverallProgress: Error updating progress: $($_.Exception.Message)"
}
}
else {
# Log if called in non-UI mode or with incorrect types
WriteLog "Update-OverallProgress: Skipped UI update ($StatusText) due to non-UI mode or incorrect WindowObject type."
}
}
# Helper function to enqueue progress updates to the UI thread
function Invoke-ProgressUpdate {
param(
[Parameter(Mandatory)]
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue,
[Parameter(Mandatory)]
[string]$Identifier,
[Parameter(Mandatory)]
[string]$Status
)
$ProgressQueue.Enqueue(@{ Identifier = $Identifier; Status = $Status })
}
# Add a function to create a sortable list view
function Add-SortableColumn {
param(
[System.Windows.Controls.GridView]$gridView,
[string]$header,
[string]$binding,
[int]$width = 'Auto',
[bool]$isCheckbox = $false,
[System.Windows.HorizontalAlignment]$headerHorizontalAlignment = [System.Windows.HorizontalAlignment]::Stretch
)
$column = New-Object System.Windows.Controls.GridViewColumn
$commonPadding = New-Object System.Windows.Thickness(5, 2, 5, 2)
$headerControl = New-Object System.Windows.Controls.GridViewColumnHeader
$headerControl.Tag = $binding # Used for sorting
if ($isCheckbox) {
# Cell template for a column of checkboxes
$cellTemplate = New-Object System.Windows.DataTemplate
$gridFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.Grid])
$checkBoxFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.CheckBox])
$checkBoxFactory.SetBinding([System.Windows.Controls.CheckBox]::IsCheckedProperty, (New-Object System.Windows.Data.Binding("IsSelected")))
$checkBoxFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Center)
$checkBoxFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
$checkBoxFactory.AddHandler([System.Windows.Controls.CheckBox]::ClickEvent, [System.Windows.RoutedEventHandler] {
param($eventSourceLocal, $eventArgsLocal)
})
$gridFactory.AppendChild($checkBoxFactory)
$cellTemplate.VisualTree = $gridFactory
$column.CellTemplate = $cellTemplate
}
else {
# For regular text columns
$headerControl.HorizontalContentAlignment = $headerHorizontalAlignment
$headerControl.Content = $header
$headerTextElementFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock])
$headerTextElementFactory.SetValue([System.Windows.Controls.TextBlock]::TextProperty, $header)
$headerTextBlockPadding = New-Object System.Windows.Thickness($commonPadding.Left, $commonPadding.Top, $commonPadding.Right, $commonPadding.Bottom)
$headerTextElementFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, $headerTextBlockPadding)
$headerTextElementFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
$headerDataTemplate = New-Object System.Windows.DataTemplate
$headerDataTemplate.VisualTree = $headerTextElementFactory
$headerControl.ContentTemplate = $headerDataTemplate
$cellTemplate = New-Object System.Windows.DataTemplate
$textBlockFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.TextBlock])
$textBlockFactory.SetBinding([System.Windows.Controls.TextBlock]::TextProperty, (New-Object System.Windows.Data.Binding($binding)))
# Adjust left padding to 0 for cell text to align with header text
$cellTextBlockPadding = New-Object System.Windows.Thickness(0, $commonPadding.Top, $commonPadding.Right, $commonPadding.Bottom)
$textBlockFactory.SetValue([System.Windows.Controls.TextBlock]::PaddingProperty, $cellTextBlockPadding)
$textBlockFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Left)
$textBlockFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
$cellTemplate.VisualTree = $textBlockFactory
$column.CellTemplate = $cellTemplate
}
$column.Header = $headerControl
if ($width -ne 'Auto') {
$column.Width = $width
}
$gridView.Columns.Add($column)
}
# Function to add a selectable GridViewColumn with a "Select All" header CheckBox
function Add-SelectableGridViewColumn {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[System.Windows.Controls.ListView]$ListView,
[Parameter(Mandatory)]
[psobject]$State,
[Parameter(Mandatory)]
[string]$HeaderCheckBoxKeyName,
[Parameter(Mandatory)]
[double]$ColumnWidth,
[string]$IsSelectedPropertyName = "IsSelected"
)
# Ensure the ListView has a GridView
if ($null -eq $ListView.View -or -not ($ListView.View -is [System.Windows.Controls.GridView])) {
WriteLog "Add-SelectableGridViewColumn: ListView '$($ListView.Name)' does not have a GridView or View is null. Cannot add column."
return
}
$gridView = $ListView.View
# Create the "Select All" CheckBox for the header
$headerCheckBox = New-Object System.Windows.Controls.CheckBox
$headerCheckBox.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center
# MODIFICATION: Store the actual ListView object in the header's Tag
$headerTagObject = [PSCustomObject]@{
PropertyName = $IsSelectedPropertyName
ListViewControl = $ListView
}
$headerCheckBox.Tag = $headerTagObject
$headerCheckBox.Add_Checked({
param($senderCheckBoxLocal, $eventArgsCheckedLocal)
$tagData = $senderCheckBoxLocal.Tag
$localPropertyName = $tagData.PropertyName
$actualListView = $tagData.ListViewControl
$collectionToUpdate = if ($null -ne $actualListView.ItemsSource) { $actualListView.ItemsSource } else { $actualListView.Items }
if ($null -ne $collectionToUpdate) {
foreach ($item in $collectionToUpdate) { $item.$($localPropertyName) = $true }
$actualListView.Items.Refresh()
}
})
$headerCheckBox.Add_Unchecked({
param($senderCheckBoxLocal, $eventArgsUncheckedLocal)
if ($senderCheckBoxLocal.IsChecked -eq $false) {
$tagData = $senderCheckBoxLocal.Tag
$localPropertyName = $tagData.PropertyName
$actualListView = $tagData.ListViewControl
$collectionToUpdate = if ($null -ne $actualListView.ItemsSource) { $actualListView.ItemsSource } else { $actualListView.Items }
if ($null -ne $collectionToUpdate) {
foreach ($item in $collectionToUpdate) { $item.$($localPropertyName) = $false }
$actualListView.Items.Refresh()
}
}
})
$State.Controls[$HeaderCheckBoxKeyName] = $headerCheckBox
WriteLog "Add-SelectableGridViewColumn: Stored header checkbox in State.Controls with key '$HeaderCheckBoxKeyName'."
$selectableColumn = New-Object System.Windows.Controls.GridViewColumn
$selectableColumn.Header = $headerCheckBox
$selectableColumn.Width = $ColumnWidth
$cellTemplate = New-Object System.Windows.DataTemplate
$borderFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.Border])
$borderFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)
$borderFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Stretch)
$checkBoxFactory = New-Object System.Windows.FrameworkElementFactory([System.Windows.Controls.CheckBox])
$checkBoxFactory.SetBinding([System.Windows.Controls.CheckBox]::IsCheckedProperty, (New-Object System.Windows.Data.Binding($IsSelectedPropertyName)))
$checkBoxFactory.SetValue([System.Windows.FrameworkElement]::HorizontalAlignmentProperty, [System.Windows.HorizontalAlignment]::Center)
$checkBoxFactory.SetValue([System.Windows.FrameworkElement]::VerticalAlignmentProperty, [System.Windows.VerticalAlignment]::Center)
# MODIFICATION: Store the actual ListView object in the item checkbox's Tag
$tagObject = [PSCustomObject]@{
HeaderCheckboxKeyName = $HeaderCheckBoxKeyName
ListViewControl = $ListView
}
$checkBoxFactory.SetValue([System.Windows.FrameworkElement]::TagProperty, $tagObject)
$checkBoxFactory.AddHandler([System.Windows.Controls.CheckBox]::ClickEvent, [System.Windows.RoutedEventHandler] {
param($eventSourceLocal, $eventArgsLocal)
$itemCheckBox = $eventSourceLocal -as [System.Windows.Controls.CheckBox]
$tagData = $itemCheckBox.Tag
$headerCheckboxKeyFromTag = $tagData.HeaderCheckboxKeyName
$targetListView = $tagData.ListViewControl
# Get the state from the window tag
$window = [System.Windows.Window]::GetWindow($targetListView)
if ($null -eq $window -or $null -eq $window.Tag) {
WriteLog "Add-SelectableGridViewColumn: ERROR - Could not get window or state from window tag."
return
}
$localState = $window.Tag
WriteLog "Add-SelectableGridViewColumn: Item Click. ListView: '$($targetListView.Name)', HeaderChkKey: '$headerCheckboxKeyFromTag'"
$headerChk = $localState.Controls[$headerCheckboxKeyFromTag]
if ($null -ne $headerChk) {
Update-SelectAllHeaderCheckBoxState -ListView $targetListView -HeaderCheckBox $headerChk
}
else {
WriteLog "Add-SelectableGridViewColumn: Error - Could not retrieve header checkbox from state with key '$headerCheckboxKeyFromTag'."
}
})
$borderFactory.AppendChild($checkBoxFactory)
$cellTemplate.VisualTree = $borderFactory
$selectableColumn.CellTemplate = $cellTemplate
$gridView.Columns.Insert(0, $selectableColumn)
WriteLog "Add-SelectableGridViewColumn: Successfully added selectable column to '$($ListView.Name)'."
}
# Function to update the IsChecked state of a "Select All" header CheckBox
function Update-SelectAllHeaderCheckBoxState {
param(
[Parameter(Mandatory)]
[System.Windows.Controls.ListView]$ListView,
[Parameter(Mandatory)]
[System.Windows.Controls.CheckBox]$HeaderCheckBox
)
$collectionToInspect = $null
if ($null -ne $ListView.ItemsSource) {
$collectionToInspect = @($ListView.ItemsSource)
}
elseif ($ListView.HasItems) {
# Check if Items collection has items and ItemsSource is null
$collectionToInspect = @($ListView.Items)
}
# If no items to inspect (either ItemsSource was null and Items was empty, or ItemsSource was empty)
if ($null -eq $collectionToInspect -or $collectionToInspect.Count -eq 0) {
$HeaderCheckBox.IsChecked = $false
return
}
$selectedCount = ($collectionToInspect | Where-Object { $_.IsSelected }).Count
WriteLog "Update-SelectAllHeaderCheckBoxState: Selected count is $selectedCount for ListView '$($ListView.Name)'."
$totalItemCount = $collectionToInspect.Count # Get the total count from the collection being inspected
WriteLog "Update-SelectAllHeaderCheckBoxState: Total item count is $totalItemCount for ListView '$($ListView.Name)'."
if ($totalItemCount -eq 0) {
# Handle empty list case specifically
$HeaderCheckBox.IsChecked = $false
}
elseif ($selectedCount -eq $totalItemCount) {
$HeaderCheckBox.IsChecked = $true
}
elseif ($selectedCount -eq 0) {
$HeaderCheckBox.IsChecked = $false
}
else {
# Indeterminate state
$HeaderCheckBox.IsChecked = $null
}
}
# Function to toggle the IsSelected state of the currently selected ListView item
function Invoke-ListViewItemToggle {
param(
[Parameter(Mandatory)]
[System.Windows.Controls.ListView]$ListView,
[Parameter(Mandatory)]
[psobject]$State,
[Parameter(Mandatory)]
[string]$HeaderCheckBoxKeyName
)
$selectedItem = $ListView.SelectedItem
if ($null -eq $selectedItem) { return }
# Store the current index to restore focus later
$currentIndex = $ListView.SelectedIndex
# Toggle the IsSelected property
$selectedItem.IsSelected = -not $selectedItem.IsSelected
$ListView.Items.Refresh()
# Update the 'Select All' header checkbox state
$headerChk = $State.Controls[$HeaderCheckBoxKeyName]
if ($null -ne $headerChk) {
Update-SelectAllHeaderCheckBoxState -ListView $ListView -HeaderCheckBox $headerChk
}
# Restore selection and focus to the item that was just toggled
if ($currentIndex -ge 0 -and $ListView.Items.Count -gt $currentIndex) {
$ListView.SelectedIndex = $currentIndex
# Ensure the UI is updated before trying to find the container
$ListView.UpdateLayout()
$listViewItem = $ListView.ItemContainerGenerator.ContainerFromIndex($currentIndex)
if ($null -ne $listViewItem) {
$listViewItem.Focus()
}
}
}
# Function to sort ListView items
function Invoke-ListViewSort {
param(
[System.Windows.Controls.ListView]$listView,
[string]$property,
[PSCustomObject]$State
)
# Ensure $State.Flags is a hashtable and contains the required sort properties
if ($State.Flags -is [hashtable]) {
if (-not $State.Flags.ContainsKey('lastSortProperty')) {
$State.Flags['lastSortProperty'] = $null
}
if (-not $State.Flags.ContainsKey('lastSortAscending')) {
$State.Flags['lastSortAscending'] = $true # Default to ascending
}
}
else {
Write-Warning "Invoke-ListViewSort: \$State.Flags is not a hashtable or is null. Sort state may not work correctly."
# Attempt to initialize if $State.Flags is null or unexpectedly not a hashtable,
# though this might indicate a deeper issue with $State.Flags initialization.
if ($null -eq $State.Flags) { $State.Flags = @{} }
if ($State.Flags -is [hashtable]) { # Check again after potential initialization
if (-not $State.Flags.ContainsKey('lastSortProperty')) { $State.Flags['lastSortProperty'] = $null }
if (-not $State.Flags.ContainsKey('lastSortAscending')) { $State.Flags['lastSortAscending'] = $true }
}
}
# Toggle sort direction if clicking the same column
if ($State.Flags.lastSortProperty -eq $property) {
$State.Flags.lastSortAscending = -not $State.Flags.lastSortAscending
}
else {
$State.Flags.lastSortAscending = $true
}
$State.Flags.lastSortProperty = $property
# Get items from ItemsSource or Items collection
$currentItemsSource = $listView.ItemsSource
$itemsToSort = @()
if ($null -ne $currentItemsSource) {
$itemsToSort = @($currentItemsSource)
}
else {
$itemsToSort = @($listView.Items)
}
if ($itemsToSort.Count -eq 0) {
return
}
$selectedItems = @($itemsToSort | Where-Object { $_.IsSelected })
$unselectedItems = @($itemsToSort | Where-Object { -not $_.IsSelected })
# Define the primary sort criterion
$primarySortDefinition = @{
Expression = {
$val = $_.$property
if ($null -eq $val) { '' } else { $val }
}
Ascending = $State.Flags.lastSortAscending
}
$sortCriteria = [System.Collections.Generic.List[hashtable]]::new()
$sortCriteria.Add($primarySortDefinition)
# Determine secondary sort property based on the ListView
$secondarySortPropertyName = $null
if ($listView.Name -eq 'lstDriverModels') {
$secondarySortPropertyName = "Model"
}
elseif ($listView.Name -eq 'lstWingetResults') {
$secondarySortPropertyName = "Name"
}
elseif ($listView.Name -eq 'lstAppsScriptVariables') {
if ($property -eq "Key") {
$secondarySortPropertyName = "Value"
}
elseif ($property -eq "Value") {
$secondarySortPropertyName = "Key"
}
else {
# Default secondary sort for IsSelected or other properties
$secondarySortPropertyName = "Key"
}
}
if ($null -ne $secondarySortPropertyName -and $property -ne $secondarySortPropertyName) {
$itemsHaveSecondaryProperty = $false
if ($unselectedItems.Count -gt 0) {
if ($null -ne $unselectedItems[0].PSObject.Properties[$secondarySortPropertyName]) {
$itemsHaveSecondaryProperty = $true
}
}
elseif ($selectedItems.Count -gt 0) {
if ($null -ne $selectedItems[0].PSObject.Properties[$secondarySortPropertyName]) {
$itemsHaveSecondaryProperty = $true
}
}
if ($itemsHaveSecondaryProperty) {
# Create a scriptblock for the secondary sort expression dynamically
$expressionScriptBlock = [scriptblock]::Create("`$_.$secondarySortPropertyName")
$secondarySortDefinition = @{
Expression = {
$val = Invoke-Command -ScriptBlock $expressionScriptBlock -ArgumentList $_
if ($null -eq $val) { '' } else { $val }
}
Ascending = $true # Secondary sort always ascending
}
$sortCriteria.Add($secondarySortDefinition)
}
}
$sortedUnselected = $unselectedItems | Sort-Object -Property $sortCriteria.ToArray()
# Ensure $sortedUnselected is not null before attempting to add its range
if ($null -eq $sortedUnselected) {
$sortedUnselected = @()
}
# Combine sorted items: selected items first, then sorted unselected items
$newSortedList = [System.Collections.Generic.List[object]]::new()
$newSortedList.AddRange($selectedItems)
$newSortedList.AddRange($sortedUnselected)
# Set the new sorted list as the ItemsSource
# Try nulling out ItemsSource first to force a more complete refresh
$listView.ItemsSource = $null
$listView.ItemsSource = $newSortedList.ToArray()
}
# --------------------------------------------------------------------------
# SECTION: Modern Folder Picker
# --------------------------------------------------------------------------
# 1) Define a C# class that uses the correct GUIDs for IFileDialog, IFileOpenDialog, and FileOpenDialog,
# while omitting conflicting "GetResults/GetSelectedItems" from IFileDialog.
if (-not ("ModernFolderBrowser" -as [type])) {
$modernFolderBrowserCode = @"
using System;
using System.Runtime.InteropServices;
public static class ModernFolderBrowser
{
// Flags for IFileDialog
[Flags]
private enum FileDialogOptions : uint
{
OverwritePrompt = 0x00000002,
StrictFileTypes = 0x00000004,
NoChangeDir = 0x00000008,
PickFolders = 0x00000020,
ForceFileSystem = 0x00000040,
AllNonStorageItems = 0x00000080,
NoValidate = 0x00000100,
AllowMultiSelect = 0x00000200,
PathMustExist = 0x00000800,
FileMustExist = 0x00001000,
CreatePrompt = 0x00002000,
ShareAware = 0x00004000,
NoReadOnlyReturn = 0x00008000,
NoTestFileCreate = 0x00010000,
DontAddToRecent = 0x02000000,
ForceShowHidden = 0x10000000
}
// IFileDialog (GUID from Windows SDK)
// - Omitting GetResults / GetSelectedItems to avoid overshadow.
[ComImport]
[Guid("42F85136-DB7E-439C-85F1-E4075D135FC8")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IFileDialog
{
[PreserveSig]
int Show(IntPtr parent);
void SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec);
void SetFileTypeIndex(uint iFileType);
void GetFileTypeIndex(out uint piFileType);
void Advise(IntPtr pfde, out uint pdwCookie);
void Unadvise(uint dwCookie);
void SetOptions(FileDialogOptions fos);
void GetOptions(out FileDialogOptions pfos);
void SetDefaultFolder(IShellItem psi);
void SetFolder(IShellItem psi);
void GetFolder(out IShellItem ppsi);
void GetCurrentSelection(out IShellItem ppsi);
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string pszName);
void GetFileName(out IntPtr pszName);
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string pszTitle);
void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string pszText);
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
void GetResult(out IShellItem ppsi);
void AddPlace(IShellItem psi, int fdap);
void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
void Close(int hr);
void SetClientGuid(ref Guid guid);
void ClearClientData();
void SetFilter(IntPtr pFilter);
// NOTE: We intentionally do NOT define GetResults and GetSelectedItems here,
// because they cause overshadow warnings in IFileOpenDialog.
}
// IFileOpenDialog extends IFileDialog by adding 2 new methods with the same name,
// which otherwise cause overshadow warnings. We'll define them only here.
[ComImport]
[Guid("D57C7288-D4AD-4768-BE02-9D969532D960")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IFileOpenDialog : IFileDialog
{
// These two come after the parent's vtable:
void GetResults(out IntPtr ppenum);
void GetSelectedItems(out IntPtr ppsai);
}
// The coclass for creating an IFileOpenDialog
[ComImport]
[Guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7")]
private class FileOpenDialog
{
}
// IShellItem
[ComImport]
[Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IShellItem
{
void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
void GetParent(out IShellItem ppsi);
void GetDisplayName(uint sigdnName, out IntPtr ppszName);
void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
void Compare(IShellItem psi, uint hint, out int piOrder);
}
[DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern int SHCreateItemFromParsingName([MarshalAs(UnmanagedType.LPWStr)] string pszPath, IntPtr pbc, ref Guid riid, [MarshalAs(UnmanagedType.Interface, IidParameterIndex = 2)] out IShellItem ppv);
private const uint SIGDN_FILESYSPATH = 0x80058000;
private static readonly Guid IID_IShellItem = new Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE");
public static string ShowDialog(string title, IntPtr parentHandle, string initialDirectory)
{
// Create COM dialog instance
IFileOpenDialog dialog = (IFileOpenDialog)(new FileOpenDialog());
// Get current options
FileDialogOptions opts;
dialog.GetOptions(out opts);
// Add flags for picking folders
opts |= FileDialogOptions.PickFolders | FileDialogOptions.PathMustExist | FileDialogOptions.ForceFileSystem;
dialog.SetOptions(opts);
// Set initial directory if provided
if (!string.IsNullOrEmpty(initialDirectory))
{
try
{
Guid iid = IID_IShellItem; // Create a local copy to pass by ref
if (SHCreateItemFromParsingName(initialDirectory, IntPtr.Zero, ref iid, out IShellItem initialFolder) == 0)
{
dialog.SetFolder(initialFolder);
Marshal.ReleaseComObject(initialFolder);
}
}
catch
{
// Ignore errors in setting initial directory (e.g., path doesn't exist)
}
}
// Set title
if (!string.IsNullOrEmpty(title))
{
dialog.SetTitle(title);
}
// Show the dialog
int hr = dialog.Show(parentHandle);
// 0 = S_OK. 1 or 0x800704C7 often means user canceled. Return null if so.
if (hr != 0)
{
if ((uint)hr == 0x800704C7 || hr == 1)
{
return null; // Canceled
}
else
{
Marshal.ThrowExceptionForHR(hr);
}
}
// Retrieve the selection (IShellItem)
IShellItem shellItem;
dialog.GetResult(out shellItem);
if (shellItem == null) return null;
// Convert to file system path
IntPtr pszPath = IntPtr.Zero;
shellItem.GetDisplayName(SIGDN_FILESYSPATH, out pszPath);
if (pszPath == IntPtr.Zero) return null;
string folderPath = Marshal.PtrToStringAuto(pszPath);
Marshal.FreeCoTaskMem(pszPath);
return folderPath;
}
}
"@
Add-Type -TypeDefinition $modernFolderBrowserCode -Language CSharp
}
# 2) Define a PowerShell function that invokes our C# wrapper
function Show-ModernFolderPicker {
param(
[string]$Title = "Select a folder",
[string]$InitialDirectory
)
# For a simple test, pass IntPtr.Zero as the parent window handle
return [ModernFolderBrowser]::ShowDialog($Title, [IntPtr]::Zero, $InitialDirectory)
}
function Invoke-BrowseAction {
param(
[Parameter(Mandatory = $true)]
[ValidateSet('Folder', 'OpenFile', 'SaveFile')]
[string]$Type,
[string]$Title,
[string]$Filter,
[string]$InitialDirectory,
[string]$FileName,
[string]$DefaultExt,
[switch]$AllowNewFile
)
switch ($Type) {
'Folder' {
return Show-ModernFolderPicker -Title $Title -InitialDirectory $InitialDirectory
}
'OpenFile' {
$dialog = New-Object Microsoft.Win32.OpenFileDialog
$dialog.Title = $Title
if (-not [string]::IsNullOrWhiteSpace($Filter)) { $dialog.Filter = $Filter }
if ($AllowNewFile) { $dialog.CheckFileExists = $false }
if (-not [string]::IsNullOrWhiteSpace($InitialDirectory)) {
$dialog.InitialDirectory = $InitialDirectory
}
if ($dialog.ShowDialog()) {
return $dialog.FileName
}
}
'SaveFile' {
$dialog = New-Object Microsoft.Win32.SaveFileDialog
$dialog.Title = $Title
if (-not [string]::IsNullOrWhiteSpace($Filter)) { $dialog.Filter = $Filter }
if ($AllowNewFile) { $dialog.CheckFileExists = $false } # This property is obsolete but used in existing code.
if (-not [string]::IsNullOrWhiteSpace($InitialDirectory)) {
$dialog.InitialDirectory = $InitialDirectory
}
if (-not [string]::IsNullOrWhiteSpace($FileName)) {
$dialog.FileName = $FileName
}
if (-not [string]::IsNullOrWhiteSpace($DefaultExt)) {
$dialog.DefaultExt = $DefaultExt
}
if ($dialog.ShowDialog()) {
return $dialog.FileName
}
}
}
return $null
}
function Clear-ListViewContent {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[psobject]$State,
[Parameter(Mandatory = $true)]
[System.Windows.Controls.ListView]$ListViewControl,
[Parameter(Mandatory = $true)]
[string]$ConfirmationTitle,
[Parameter(Mandatory = $true)]
[string]$ConfirmationMessage,
[Parameter(Mandatory = $false)]
[System.Collections.IList]$BackingDataList,
[Parameter(Mandatory = $false)]
[string]$StatusMessage,
[Parameter(Mandatory = $false)]
[System.Windows.Controls.TextBox[]]$TextBoxesToClear,
[Parameter(Mandatory = $false)]
[scriptblock]$PostClearAction
)
$result = [System.Windows.MessageBox]::Show($ConfirmationMessage, $ConfirmationTitle, [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question)
if ($result -ne [System.Windows.MessageBoxResult]::Yes) {
return
}
try {
# If a backing data list is provided, clear it and rebind. This is the preferred method.
if ($null -ne $BackingDataList) {
$BackingDataList.Clear()
$ListViewControl.ItemsSource = $BackingDataList.ToArray()
}
# If no backing list, determine how to clear the control.
else {
# If ItemsSource is in use, the only valid way to clear is to set it to null or an empty collection.
if ($null -ne $ListViewControl.ItemsSource) {
$ListViewControl.ItemsSource = $null
}
# If ItemsSource is NOT in use, we can safely clear the Items collection directly (for BYO Apps).
elseif ($null -ne $ListViewControl.Items) {
$ListViewControl.Items.Clear()
}
}
$ListViewControl.Items.Refresh()
# Clear any specified textboxes
if ($null -ne $TextBoxesToClear) {
foreach ($textBox in $TextBoxesToClear) {
$textBox.Clear()
}
}
# Update the status message if provided
if (-not [string]::IsNullOrWhiteSpace($StatusMessage) -and $null -ne $State.Controls.txtStatus) {
$State.Controls.txtStatus.Text = $StatusMessage
}
# Execute any post-clear custom actions. The scriptblock will have access to the $State and $ListViewControl variables from this function's scope.
if ($null -ne $PostClearAction) {
& $PostClearAction
}
}
catch {
WriteLog "Error in Clear-ListViewContent for $($ListViewControl.Name): $($_.Exception.Message)"
[System.Windows.MessageBox]::Show("An error occurred while clearing the list: $($_.Exception.Message)", "Error", "OK", "Error")
}
}
Export-ModuleMember -Function *
@@ -0,0 +1,682 @@
<#
.SYNOPSIS
Manages the business logic for the 'Windows Settings' tab in the FFU Builder UI.
.DESCRIPTION
This module contains all the functions and data required to populate and manage the controls on the 'Windows Settings' tab. It handles dynamic updates for Windows Release, Version, SKU, and Architecture ComboBoxes based on user input, such as specifying an ISO path. It also includes logic for populating available languages, media types, and the grid of optional Windows features.
#>
# --------------------------------------------------------------------------
# SECTION: Module Variables (Static Data)
# --------------------------------------------------------------------------
$script:allowedFeatures = @(
"AppServerClient", "Client-DeviceLockdown", "Client-EmbeddedBootExp", "Client-EmbeddedLogon",
"Client-EmbeddedShellLauncher", "Client-KeyboardFilter", "Client-ProjFS", "Client-UnifiedWriteFilter",
"Containers", "Containers-DisposableClientVM", "Containers-HNS", "Containers-SDN", "DataCenterBridging",
"DirectoryServices-ADAM-Client", "DirectPlay", "HostGuardian", "HypervisorPlatform", "IIS-ApplicationDevelopment",
"IIS-ApplicationInit", "IIS-ASP", "IIS-ASPNET45", "IIS-BasicAuthentication", "IIS-CertProvider",
"IIS-CGI", "IIS-ClientCertificateMappingAuthentication", "IIS-CommonHttpFeatures", "IIS-CustomLogging",
"IIS-DefaultDocument", "IIS-DirectoryBrowsing", "IIS-DigestAuthentication", "IIS-ESP", "IIS-FTPServer",
"IIS-FTPExtensibility", "IIS-FTPSvc", "IIS-HealthAndDiagnostics", "IIS-HostableWebCore", "IIS-HttpCompressionDynamic",
"IIS-HttpCompressionStatic", "IIS-HttpErrors", "IIS-HttpLogging", "IIS-HttpRedirect", "IIS-HttpTracing",
"IIS-IPSecurity", "IIS-IIS6ManagementCompatibility", "IIS-IISCertificateMappingAuthentication",
"IIS-ISAPIExtensions", "IIS-ISAPIFilter", "IIS-LoggingLibraries", "IIS-ManagementConsole", "IIS-ManagementService",
"IIS-ManagementScriptingTools", "IIS-Metabase", "IIS-NetFxExtensibility", "IIS-NetFxExtensibility45",
"IIS-ODBCLogging", "IIS-Performance", "IIS-RequestFiltering", "IIS-RequestMonitor", "IIS-Security", "IIS-ServerSideIncludes",
"IIS-StaticContent", "IIS-URLAuthorization", "IIS-WebDAV", "IIS-WebServer", "IIS-WebServerManagementTools",
"IIS-WebServerRole", "IIS-WebSockets", "LegacyComponents", "MediaPlayback", "Microsoft-Hyper-V", "Microsoft-Hyper-V-All",
"Microsoft-Hyper-V-Hypervisor", "Microsoft-Hyper-V-Management-Clients", "Microsoft-Hyper-V-Management-PowerShell",
"Microsoft-Hyper-V-Services", "Microsoft-Windows-Subsystem-Linux", "MSMQ-ADIntegration", "MSMQ-Container", "MSMQ-DCOMProxy",
"MSMQ-HTTP", "MSMQ-Multicast", "MSMQ-Server", "MSMQ-Triggers", "MultiPoint-Connector", "MultiPoint-Connector-Services",
"MultiPoint-Tools", "NetFx3", "NetFx4-AdvSrvs", "NetFx4Extended-ASPNET45", "NFS-Administration", "Printing-Foundation-Features",
"Printing-Foundation-InternetPrinting-Client", "Printing-Foundation-LPDPrintService", "Printing-Foundation-LPRPortMonitor",
"Printing-PrintToPDFServices-Features", "Printing-XPSServices-Features", "SearchEngine-Client-Package",
"ServicesForNFS-ClientOnly", "SimpleTCP", "SMB1Protocol", "SMB1Protocol-Client", "SMB1Protocol-Deprecation",
"SMB1Protocol-Server", "SmbDirect", "TFTP", "TelnetClient", "TIFFIFilter", "VirtualMachinePlatform", "WAS-ConfigurationAPI",
"WAS-NetFxEnvironment", "WAS-ProcessModel", "WAS-WindowsActivationService", "WCF-HTTP-Activation", "WCF-HTTP-Activation45",
"WCF-MSMQ-Activation45", "WCF-MSMQ-Activation", "WCF-NonHTTP-Activation", "WCF-Pipe-Activation45", "WCF-Services45",
"WCF-TCP-Activation45", "WCF-TCP-PortSharing45", "Windows-Defender-ApplicationGuard",
"Windows-Defender-Default-Definitions", "Windows-Identity-Foundation", "WindowsMediaPlayer", "WorkFolders-Client"
)
$script:skuList = @(
'Home',
'Home N',
'Home Single Language',
'Education',
'Education N',
'Pro',
'Pro N',
'Pro Education',
'Pro Education N',
'Pro for Workstations',
'Pro N for Workstations',
'Enterprise',
'Enterprise N',
'Enterprise 2016 LTSB',
'Enterprise N 2016 LTSB',
'Enterprise LTSC',
'Enterprise N LTSC',
'IoT Enterprise LTSC',
'IoT Enterprise N LTSC',
'Standard',
'Standard (Desktop Experience)',
'Datacenter',
'Datacenter (Desktop Experience)'
)
$script:allowedLangs = @(
'ar-sa',
'bg-bg',
'cs-cz',
'da-dk',
'de-de',
'el-gr',
'en-gb',
'en-us',
'es-es',
'es-mx',
'et-ee',
'fi-fi',
'fr-ca',
'fr-fr',
'he-il',
'hr-hr',
'hu-hu',
'it-it',
'ja-jp',
'ko-kr',
'lt-lt',
'lv-lv',
'nb-no',
'nl-nl',
'pl-pl',
'pt-br',
'pt-pt',
'ro-ro',
'ru-ru',
'sk-sk',
'sl-si',
'sr-latn-rs',
'sv-se',
'th-th',
'tr-tr',
'uk-ua',
'zh-cn',
'zh-tw'
)
$script:allWindowsReleases = @(
[PSCustomObject]@{ Display = "Windows 10"; Value = 10 },
[PSCustomObject]@{ Display = "Windows 11"; Value = 11 },
[PSCustomObject]@{ Display = "Windows Server 2016"; Value = 2016 },
[PSCustomObject]@{ Display = "Windows Server 2019"; Value = 2019 },
[PSCustomObject]@{ Display = "Windows Server 2022"; Value = 2022 },
[PSCustomObject]@{ Display = "Windows Server 2025"; Value = 2025 },
[PSCustomObject]@{ Display = "Windows 10 LTSB 2016"; Value = 2016 }, # Changed Value from 1607
[PSCustomObject]@{ Display = "Windows 10 LTSC 2019"; Value = 2019 }, # Changed Value from 1809
[PSCustomObject]@{ Display = "Windows 10 LTSC 2021"; Value = 2021 },
[PSCustomObject]@{ Display = "Windows 11 LTSC 2024"; Value = 2024 }
)
$script:mctWindowsReleases = @(
[PSCustomObject]@{ Display = "Windows 10"; Value = 10 },
[PSCustomObject]@{ Display = "Windows 11"; Value = 11 }
)
$script:windowsVersionMap = @{
10 = @("22H2")
11 = @("22H2", "23H2", "24H2", "25H2")
2016 = @("1607") # Windows 10 LTSB 2016 & Server 2016
2019 = @("1809") # Windows 10 LTSC 2019 & Server 2019
# Note: Server 2016 and LTSB 2016 now share the key 2016, mapping to version "1607"
# Note: Server 2019 and LTSC 2019 now share the key 2019, mapping to version "1809"
2021 = @("21H2") # LTSC 2021
2022 = @("21H2") # Server 2022
2024 = @("24H2") # LTSC 2024
2025 = @("24H2") # Server 2025
}
# SKU Groups
$script:clientSKUs = @(
'Home',
'Home N',
'Home Single Language',
'Education',
'Education N',
'Pro',
'Pro N',
'Pro Education',
'Pro Education N',
'Pro for Workstations',
'Pro N for Workstations',
'Enterprise',
'Enterprise N'
)
$script:serverSKUs = @(
'Standard',
'Standard (Desktop Experience)',
'Datacenter',
'Datacenter (Desktop Experience)'
)
$script:ltsc2016SKUs = @(
'Enterprise 2016 LTSB',
'Enterprise N 2016 LTSB'
)
$script:ltscGenericSKUs = @( # For LTSC 2019, 2021, 2024
'Enterprise LTSC',
'Enterprise N LTSC'
)
$script:iotLtscSKUs = @(
'IoT Enterprise LTSC',
'IoT Enterprise N LTSC'
)
# Map Windows Release Values to their corresponding SKU lists
$script:windowsReleaseSkuMap = @{
10 = $script:clientSKUs # Windows 10 Client
11 = $script:clientSKUs # Windows 11 Client
2016 = $script:serverSKUs # Windows Server 2016 (LTSB 2016 handled by Get-AvailableSkusForRelease)
2019 = $script:serverSKUs # Windows Server 2019 (LTSC 2019 handled by Get-AvailableSkusForRelease)
2022 = $script:serverSKUs # Windows Server 2022
2025 = $script:serverSKUs # Windows Server 2025
2021 = $script:ltscGenericSKUs + $script:iotLtscSKUs # Windows 10 LTSC 2021
2024 = $script:ltscGenericSKUs + $script:iotLtscSKUs # Windows 10 LTSC 2024
# Note: LTSC 2016 and LTSC 2019 SKUs are now conditionally returned by Get-AvailableSkusForRelease
}
# --------------------------------------------------------------------------
# SECTION: Functions
# --------------------------------------------------------------------------
# Function to return the default settings and static lists
function Get-WindowsSettingsDefaults {
[CmdletBinding()]
param()
return [PSCustomObject]@{
DefaultISOPath = ""
DefaultWindowsArch = "x64"
DefaultWindowsLang = "en-us"
DefaultWindowsSKU = "Pro"
DefaultMediaType = "Consumer"
DefaultOptionalFeatures = ""
DefaultProductKey = ""
AllowedFeatures = $script:allowedFeatures
AllowedLanguages = $script:allowedLangs
AllowedArchitectures = @('x86', 'x64', 'arm64')
AllowedMediaTypes = @('Consumer', 'Business')
SkuList = $script:skuList
AllWindowsReleases = $script:allWindowsReleases
MctWindowsReleases = $script:mctWindowsReleases
WindowsVersionMap = $script:windowsVersionMap
ClientSKUs = $script:clientSKUs
ServerSKUs = $script:serverSKUs
Ltsc2016SKUs = $script:ltsc2016SKUs
LtscGenericSKUs = $script:ltscGenericSKUs
IotLtscSKUs = $script:iotLtscSKUs
WindowsReleaseSkuMap = $script:windowsReleaseSkuMap
}
}
# Function to get the appropriate list of Windows Releases based on ISO path
function Get-AvailableWindowsReleases {
[CmdletBinding()]
param(
[string]$IsoPath,
[Parameter(Mandatory = $true)]
[psobject]$State
)
if (-not [string]::IsNullOrEmpty($IsoPath) -and $IsoPath.EndsWith('.iso', [System.StringComparison]::OrdinalIgnoreCase)) {
return $State.Defaults.WindowsSettingsDefaults.AllWindowsReleases
}
else {
return $State.Defaults.WindowsSettingsDefaults.MctWindowsReleases
}
}
# Function to get available Windows Versions for a given release and ISO path
function Get-AvailableWindowsVersions {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[int]$SelectedRelease,
[string]$IsoPath,
[Parameter(Mandatory = $true)]
[psobject]$State
)
$result = [PSCustomObject]@{
Versions = @()
DefaultVersion = $null
IsEnabled = $false
}
if (-not $State.Defaults.WindowsSettingsDefaults.WindowsVersionMap.ContainsKey($SelectedRelease)) {
return $result
}
$validVersions = $State.Defaults.WindowsSettingsDefaults.WindowsVersionMap[$SelectedRelease]
if (-not [string]::IsNullOrEmpty($IsoPath) -and $IsoPath.EndsWith('.iso', [System.StringComparison]::OrdinalIgnoreCase)) {
# Logic for when an ISO is specified
$result.Versions = $validVersions
# Set default selection logic (e.g., latest for Win11)
if ($SelectedRelease -eq 11 -and $validVersions -contains "24H2") {
$result.DefaultVersion = "24H2"
}
elseif ($validVersions.Count -gt 0) {
$result.DefaultVersion = $validVersions[0]
}
$result.IsEnabled = $true
}
else {
# Logic for when no ISO is specified (MCT scenario)
switch ($SelectedRelease) {
10 { $result.DefaultVersion = "22H2" }
11 { $result.DefaultVersion = "24H2" }
# Server versions typically require an ISO, but handle just in case
2016 { $result.DefaultVersion = "1607" }
2019 { $result.DefaultVersion = "1809" }
2022 { $result.DefaultVersion = "21H2" }
2025 { $result.DefaultVersion = "24H2" }
default {
if ($validVersions.Count -gt 0) { $result.DefaultVersion = $validVersions[0] }
}
}
$result.Versions = @($result.DefaultVersion) # Only the default is available/relevant
$result.IsEnabled = $false # Combo should be disabled
}
return $result
}
# Function to get available SKUs for a given Windows Release value and display name
function Get-AvailableSkusForRelease {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[int]$SelectedReleaseValue,
[Parameter(Mandatory)]
[string]$SelectedReleaseDisplayName,
[Parameter(Mandatory = $true)]
[psobject]$State
)
WriteLog "Get-AvailableSkusForRelease: Getting SKUs for Release Value '$SelectedReleaseValue', Display Name '$SelectedReleaseDisplayName'."
# Handle LTSC 2016 specifically
if ($SelectedReleaseValue -eq 2016 -and $SelectedReleaseDisplayName -like '*LTSB*') {
WriteLog "Get-AvailableSkusForRelease: Matched LTSB 2016. Returning LTSC 2016 SKUs."
return $State.Defaults.WindowsSettingsDefaults.Ltsc2016SKUs
}
# Handle LTSC 2019 specifically
# Ensure "Server" is not in the display name to avoid matching "Windows Server 2019"
elseif ($SelectedReleaseValue -eq 2019 -and $SelectedReleaseDisplayName -like '*LTSC*' -and $SelectedReleaseDisplayName -notlike '*Server*') {
WriteLog "Get-AvailableSkusForRelease: Matched LTSC 2019. Returning generic LTSC SKUs (including IoT)."
# Assuming LTSC 2019 uses the generic LTSC SKUs + IoT LTSC SKUs
return ($State.Defaults.WindowsSettingsDefaults.LtscGenericSKUs + $State.Defaults.WindowsSettingsDefaults.IotLtscSKUs | Select-Object -Unique)
}
# For all other cases, use the main SKU map
elseif ($State.Defaults.WindowsSettingsDefaults.WindowsReleaseSkuMap.ContainsKey($SelectedReleaseValue)) {
$availableSkus = $State.Defaults.WindowsSettingsDefaults.WindowsReleaseSkuMap[$SelectedReleaseValue]
WriteLog "Get-AvailableSkusForRelease: Found $($availableSkus.Count) SKUs for Release '$SelectedReleaseValue' using standard map."
return $availableSkus
}
else {
WriteLog "Get-AvailableSkusForRelease: Warning - Release Value '$SelectedReleaseValue' not found in SKU map. Returning default client SKUs."
# Fallback to a default list (e.g., client SKUs) or an empty list
return $State.Defaults.WindowsSettingsDefaults.ClientSKUs
}
}
# Function to refresh the Windows Release ComboBox based on ISO path
function Update-WindowsReleaseCombo {
param(
[string]$isoPath,
[Parameter(Mandatory = $true)]
[psobject]$State
)
if (-not $State.Controls.cmbWindowsRelease) { return }
$oldSelectedItemValue = $null
if ($null -ne $State.Controls.cmbWindowsRelease.SelectedItem) {
$oldSelectedItemValue = $State.Controls.cmbWindowsRelease.SelectedItem.Value
}
# Get the appropriate list of releases from the helper module
$availableReleases = Get-AvailableWindowsReleases -IsoPath $isoPath -State $State
# Update the ComboBox ItemsSource
$State.Controls.cmbWindowsRelease.ItemsSource = $availableReleases
$State.Controls.cmbWindowsRelease.DisplayMemberPath = 'Display'
$State.Controls.cmbWindowsRelease.SelectedValuePath = 'Value'
# Try to re-select the previously selected item, or default
$itemToSelect = $availableReleases | Where-Object { $_.Value -eq $oldSelectedItemValue } | Select-Object -First 1
if ($null -ne $itemToSelect) {
$State.Controls.cmbWindowsRelease.SelectedItem = $itemToSelect
}
elseif ($availableReleases.Count -gt 0) {
# Default to Windows 11 if available, otherwise the first item
$defaultItem = $availableReleases | Where-Object { $_.Value -eq 11 } | Select-Object -First 1
if ($null -eq $defaultItem) {
$defaultItem = $availableReleases[0]
}
$State.Controls.cmbWindowsRelease.SelectedItem = $defaultItem
}
else {
# No items available (should not happen with current logic)
$State.Controls.cmbWindowsRelease.SelectedIndex = -1
}
}
# Function to refresh the Windows Version ComboBox based on selected release and ISO path
function Update-WindowsVersionCombo {
param(
[int]$selectedRelease,
[string]$isoPath,
[Parameter(Mandatory = $true)]
[psobject]$State
)
$combo = $State.Controls.cmbWindowsVersion
if (-not $combo) { return }
# Get available versions and default from the helper module
$versionData = Get-AvailableWindowsVersions -SelectedRelease $selectedRelease -IsoPath $isoPath -State $State
# Update the ComboBox ItemsSource and IsEnabled state
$combo.ItemsSource = $versionData.Versions
$combo.IsEnabled = $versionData.IsEnabled
# Set the selected item
if ($null -ne $versionData.DefaultVersion -and $versionData.Versions -contains $versionData.DefaultVersion) {
$combo.SelectedItem = $versionData.DefaultVersion
}
elseif ($versionData.Versions.Count -gt 0) {
$combo.SelectedIndex = 0
}
else {
$combo.SelectedIndex = -1 # No items available
}
}
# Function to refresh the Windows SKU ComboBox based on selected release
function Update-WindowsSkuCombo {
param(
[Parameter(Mandatory = $true)]
[psobject]$State
)
$skuCombo = $State.Controls.cmbWindowsSKU
if (-not $skuCombo) {
WriteLog "Update-WindowsSkuCombo: SKU ComboBox not found."
return
}
$releaseCombo = $State.Controls.cmbWindowsRelease
if (-not $releaseCombo -or $null -eq $releaseCombo.SelectedItem) {
WriteLog "Update-WindowsSkuCombo: Windows Release ComboBox not found or no item selected. Cannot update SKUs."
$skuCombo.ItemsSource = @() # Clear SKUs
$skuCombo.SelectedIndex = -1
return
}
$selectedReleaseItem = $releaseCombo.SelectedItem
$selectedReleaseValue = $selectedReleaseItem.Value
$selectedReleaseDisplayName = $selectedReleaseItem.Display
$previousSelectedSku = $null
if ($null -ne $skuCombo.SelectedItem) {
$previousSelectedSku = $skuCombo.SelectedItem
}
WriteLog "Update-WindowsSkuCombo: Updating SKUs for Release Value '$selectedReleaseValue' (Display: '$selectedReleaseDisplayName')."
# Call Get-AvailableSkusForRelease with both Value and DisplayName
$availableSkus = Get-AvailableSkusForRelease -SelectedReleaseValue $selectedReleaseValue -SelectedReleaseDisplayName $selectedReleaseDisplayName -State $State
$skuCombo.ItemsSource = $availableSkus
WriteLog "Update-WindowsSkuCombo: Set ItemsSource with $($availableSkus.Count) SKUs."
# Attempt to re-select the previous SKU, or "Pro", or the first available
if ($null -ne $previousSelectedSku -and $availableSkus -contains $previousSelectedSku) {
$skuCombo.SelectedItem = $previousSelectedSku
WriteLog "Update-WindowsSkuCombo: Re-selected previous SKU '$previousSelectedSku'."
}
elseif ($availableSkus -contains "Pro") {
$skuCombo.SelectedItem = "Pro"
WriteLog "Update-WindowsSkuCombo: Selected default SKU 'Pro'."
}
elseif ($availableSkus.Count -gt 0) {
$skuCombo.SelectedIndex = 0
WriteLog "Update-WindowsSkuCombo: Selected first available SKU '$($skuCombo.SelectedItem)'."
}
else {
$skuCombo.SelectedIndex = -1 # No SKUs available
WriteLog "Update-WindowsSkuCombo: No SKUs available for Release '$selectedReleaseValue' (Display: '$selectedReleaseDisplayName')."
}
}
# Function to refresh the Windows Architecture ComboBox based on selected release, version, and SKU
function Update-WindowsArchCombo {
param(
[Parameter(Mandatory = $true)]
[psobject]$State
)
$archCombo = $State.Controls.cmbWindowsArch
if (-not $archCombo) {
WriteLog "Update-WindowsArchCombo: Architecture ComboBox not found."
return
}
$previousSelectedArch = $archCombo.SelectedItem
# Start with a safe, common default
$availableArchitectures = @('x64')
$releaseItem = $State.Controls.cmbWindowsRelease.SelectedItem
$versionItem = $State.Controls.cmbWindowsVersion.SelectedItem
$skuItem = $State.Controls.cmbWindowsSKU.SelectedItem
if ($null -eq $releaseItem) {
WriteLog "Update-WindowsArchCombo: No release selected. Defaulting to x64."
$archCombo.ItemsSource = $availableArchitectures
$archCombo.SelectedItem = 'x64'
return
}
$releaseDisplay = $releaseItem.Display
$versionValue = if ($null -ne $versionItem) { $versionItem } else { "" }
$skuValue = if ($null -ne $skuItem) { $skuItem } else { "" }
if ($releaseDisplay -like 'Windows Server*') {
# All servers are x64 only
$availableArchitectures = @('x64')
}
elseif ($releaseDisplay -like 'Windows 11*') {
if ($releaseDisplay -like '*LTSC*') {
# Windows 11 LTSC 2024
if ($skuValue -like 'IoT*') {
$availableArchitectures = @('x64', 'arm64')
}
else {
$availableArchitectures = @('x64')
}
}
else {
# Standard Windows 11
if ($versionValue -eq '24H2') {
$availableArchitectures = @('x64', 'arm64')
}
else {
# 22H2, 23H2
$availableArchitectures = @('x64')
}
}
}
elseif ($releaseDisplay -like 'Windows 10*') {
if ($releaseDisplay -like '*LTSB*' -or $releaseDisplay -like '*LTSC*') {
# Windows 10 LTSB 2016, LTSC 2019, LTSC 2021
if ($releaseDisplay -like '*2021*' -and $skuValue -like 'IoT*') {
$availableArchitectures = @('x64', 'arm64')
}
else {
# LTSB 2016, LTSC 2019, LTSC 2021 (non-IoT)
$availableArchitectures = @('x86', 'x64')
}
}
else {
# Standard Windows 10 (22H2)
$availableArchitectures = @('x86', 'x64')
}
}
$archCombo.ItemsSource = $availableArchitectures
if ($availableArchitectures -contains $previousSelectedArch) {
$archCombo.SelectedItem = $previousSelectedArch
}
elseif ($availableArchitectures -contains 'x64') {
$archCombo.SelectedItem = 'x64'
}
elseif ($availableArchitectures.Count -gt 0) {
$archCombo.SelectedIndex = 0
}
else {
$archCombo.SelectedIndex = -1
}
WriteLog "Update-WindowsArchCombo: Updated available architectures to ($($availableArchitectures -join ', ')). Selected: $($archCombo.SelectedItem)"
}
# Combined function to initialize the Release, Version, and SKU combos
function Get-WindowsSettingsCombos {
param(
[string]$isoPath,
[Parameter(Mandatory = $true)]
[psobject]$State
)
# Determine visibility for download-specific controls based on ISO path
$visibility = if (-not [string]::IsNullOrEmpty($isoPath) -and $isoPath.EndsWith('.iso', [System.StringComparison]::OrdinalIgnoreCase)) {
'Collapsed'
}
else {
'Visible'
}
# Set visibility for Language and Media Type controls
$State.Controls.WindowsLangStackPanel.Visibility = $visibility
$State.Controls.cmbWindowsLang.Visibility = $visibility
$State.Controls.MediaTypeStackPanel.Visibility = $visibility
$State.Controls.cmbMediaType.Visibility = $visibility
# Update Release combo first
Update-WindowsReleaseCombo -isoPath $isoPath -State $State
# Get the newly selected release value
$selectedReleaseValue = 11 # Default to 11 if selection is null
if ($null -ne $State.Controls.cmbWindowsRelease.SelectedItem) {
$selectedReleaseValue = $State.Controls.cmbWindowsRelease.SelectedItem.Value
}
# Update Version combo based on the selected release
Update-WindowsVersionCombo -selectedRelease $selectedReleaseValue -isoPath $isoPath -State $State
# Update SKU combo based on the selected release (now derives values internally)
Update-WindowsSkuCombo -State $State
# Finally, update the Architecture combo to match the initial state
Update-WindowsArchCombo -State $State
}
# Dynamic checkboxes for optional features in Windows Settings tab
function UpdateOptionalFeaturesString {
param(
[psobject]$State
)
$checkedFeatures = @()
foreach ($entry in $State.Controls.featureCheckBoxes.GetEnumerator()) {
if ($entry.Value.IsChecked) { $checkedFeatures += $entry.Key }
}
}
function BuildFeaturesGrid {
param (
[Parameter(Mandatory)]
[System.Windows.FrameworkElement]$parent,
[Parameter(Mandatory)]
[array]$allowedFeatures, # Pass the list of features explicitly
[Parameter(Mandatory = $true)]
[psobject]$State
)
$parent.Children.Clear()
$State.Controls.featureCheckBoxes.Clear() # Clear the tracking hashtable
$sortedFeatures = $allowedFeatures | Sort-Object
$rows = 10 # Define number of rows for layout
$columns = [math]::Ceiling($sortedFeatures.Count / $rows)
$featuresGrid = New-Object System.Windows.Controls.Grid
$featuresGrid.Margin = "0,5,0,5"
$featuresGrid.ShowGridLines = $false
# Define grid rows
for ($r = 0; $r -lt $rows; $r++) {
$rowDef = New-Object System.Windows.Controls.RowDefinition
$rowDef.Height = [System.Windows.GridLength]::Auto
$featuresGrid.RowDefinitions.Add($rowDef) | Out-Null
}
# Define grid columns
for ($c = 0; $c -lt $columns; $c++) {
$colDef = New-Object System.Windows.Controls.ColumnDefinition
$colDef.Width = [System.Windows.GridLength]::Auto
$featuresGrid.ColumnDefinitions.Add($colDef) | Out-Null
}
# Populate grid with checkboxes
for ($i = 0; $i -lt $sortedFeatures.Count; $i++) {
$featureName = $sortedFeatures[$i]
$colIndex = [int]([math]::Floor($i / $rows))
$rowIndex = $i % $rows
$chk = New-Object System.Windows.Controls.CheckBox
$chk.Content = $featureName
$chk.Margin = "5"
$chk.Add_Checked({
param($eventSource, $e)
$window = [System.Windows.Window]::GetWindow($eventSource)
if ($null -ne $window) {
UpdateOptionalFeaturesString -State $window.Tag
}
})
$chk.Add_Unchecked({
param($eventSource, $e)
$window = [System.Windows.Window]::GetWindow($eventSource)
if ($null -ne $window) {
UpdateOptionalFeaturesString -State $window.Tag
}
})
$State.Controls.featureCheckBoxes[$featureName] = $chk # Track the checkbox
[System.Windows.Controls.Grid]::SetRow($chk, $rowIndex)
[System.Windows.Controls.Grid]::SetColumn($chk, $colIndex)
$featuresGrid.Children.Add($chk) | Out-Null
}
$parent.Children.Add($featuresGrid) | Out-Null
}
# --------------------------------------------------------------------------
# SECTION: Module Export
# --------------------------------------------------------------------------
Export-ModuleMember -Function *
@@ -0,0 +1,810 @@
<#
.SYNOPSIS
Manages all Winget-related functionality for the 'Applications' tab in the FFU Builder UI.
.DESCRIPTION
This module provides the business logic for interacting with Winget from the FFU Builder UI. It includes functions for searching for packages, importing and exporting application lists, checking for and installing necessary Winget components (CLI and PowerShell module), and managing the parallel download of selected applications. It works in conjunction with FFU.Common.Winget for lower-level operations and FFU.Common.Parallel for managing concurrent downloads.
#>
# Function to search for Winget apps
function Search-WingetApps {
param(
[Parameter(Mandatory = $true)]
[psobject]$State
)
$searchQuery = $State.Controls.txtWingetSearch.Text
if ([string]::IsNullOrWhiteSpace($searchQuery)) { return }
$State.Controls.txtStatus.Text = "Searching Winget for apps matching query '$searchQuery'..."
$State.Window.Cursor = [System.Windows.Input.Cursors]::Wait
$State.Controls.btnWingetSearch.IsEnabled = $false
try {
# Get current items from the ListView
$currentItemsInListView = @()
if ($null -ne $State.Controls.lstWingetResults.ItemsSource) {
$currentItemsInListView = @($State.Controls.lstWingetResults.ItemsSource)
}
elseif ($State.Controls.lstWingetResults.HasItems) {
$currentItemsInListView = @($State.Controls.lstWingetResults.Items)
}
# Store selected apps from the current view
$selectedAppsFromView = @($currentItemsInListView | Where-Object { $_.IsSelected })
# Get default architecture from the UI
$defaultArch = $State.Controls.cmbWindowsArch.SelectedItem
# Search for new apps, which are streamed directly as PSCustomObjects
# with the required properties for performance.
$searchedAppResults = Search-WingetPackagesPublic -Query $searchQuery -DefaultArchitecture $defaultArch
$finalAppList = [System.Collections.Generic.List[object]]::new()
$addedAppIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
# Add previously selected apps first
foreach ($app in $selectedAppsFromView) {
$finalAppList.Add($app)
$addedAppIds.Add($app.Id) | Out-Null
}
# Add new search results, avoiding duplicates of already added (selected) apps
$newAppsAddedCount = 0
foreach ($result in $searchedAppResults) {
# HashSet.Add returns $true if the item was added, $false if it already existed.
if ($addedAppIds.Add($result.Id)) {
$finalAppList.Add($result)
$newAppsAddedCount++
}
}
# Update the ListView's ItemsSource using the passed-in State object
$State.Controls.lstWingetResults.ItemsSource = $finalAppList.ToArray()
# Update status text
$statusText = ""
if ($newAppsAddedCount -gt 0) {
$statusText = "Found $newAppsAddedCount new applications. "
}
else {
$statusText = "No new applications found. "
}
$statusText += "Displaying $($finalAppList.Count) total applications."
$State.Controls.txtStatus.Text = $statusText
}
catch {
$errorMessage = "Error searching for apps: $($_.Exception.Message)"
$State.Controls.txtStatus.Text = $errorMessage
[System.Windows.MessageBox]::Show($errorMessage, "Error", "OK", "Error")
}
finally {
$State.Window.Cursor = $null
$State.Controls.btnWingetSearch.IsEnabled = $true
}
}
# Function to save selected apps to JSON
function Save-WingetList {
param(
[Parameter(Mandatory = $true)]
[psobject]$State
)
try {
$selectedApps = $State.Controls.lstWingetResults.Items | Where-Object { $_.IsSelected }
if (-not $selectedApps) {
[System.Windows.MessageBox]::Show("No apps selected to save.", "Warning", "OK", "Warning")
return
}
$appList = @{
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 }
}
})
}
$sfd = New-Object System.Windows.Forms.SaveFileDialog
$sfd.Filter = "JSON files (*.json)|*.json"
$sfd.Title = "Save App List"
# Correctly get the path from the UI control via the State object
$sfd.InitialDirectory = $State.Controls.txtApplicationPath.Text
$sfd.FileName = "AppList.json"
if ($sfd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
$appList | ConvertTo-Json -Depth 10 | Set-Content $sfd.FileName -Encoding UTF8
[System.Windows.MessageBox]::Show("App list saved successfully.", "Success", "OK", "Information")
}
}
catch {
[System.Windows.MessageBox]::Show("Error saving app list: $_", "Error", "OK", "Error")
}
}
# Function to import app list from JSON
function Import-WingetList {
param(
[Parameter(Mandatory = $true)]
[psobject]$State
)
try {
$ofd = New-Object System.Windows.Forms.OpenFileDialog
$ofd.Filter = "JSON files (*.json)|*.json"
$ofd.Title = "Import App List"
# Correctly get the path from the UI control via the State object
$ofd.InitialDirectory = $State.Controls.txtApplicationPath.Text
if ($ofd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
$importedAppsData = Get-Content $ofd.FileName -Raw | ConvertFrom-Json
$newAppListForItemsSource = [System.Collections.Generic.List[object]]::new()
if ($null -ne $importedAppsData.apps) {
# Get default architecture from the UI for fallback
$defaultArch = $State.Controls.cmbWindowsArch.SelectedItem
foreach ($appInfo in $importedAppsData.apps) {
$arch = if ($appInfo.source -eq 'msstore') { 'NA' } else { if ($appInfo.PSObject.Properties['architecture']) { $appInfo.architecture } else { $defaultArch } }
$newAppListForItemsSource.Add([PSCustomObject]@{
IsSelected = $true # Imported apps are marked as selected
Name = $appInfo.name
Id = $appInfo.id
Version = "" # Will be populated when searching or if data exists
Source = $appInfo.source
Architecture = $arch
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()
[System.Windows.MessageBox]::Show("App list imported successfully.", "Success", "OK", "Information")
}
}
catch {
[System.Windows.MessageBox]::Show("Error importing app list: $_", "Error", "OK", "Error")
}
}
# --------------------------------------------------------------------------
# SECTION: Winget Management Functions (Moved from FFUUI.Core.psm1)
# --------------------------------------------------------------------------
function Search-WingetPackagesPublic {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Query,
[Parameter(Mandatory = $true)]
[string]$DefaultArchitecture
)
WriteLog "Searching Winget packages with query: '$Query'"
try {
# Using ForEach-Object -Parallel can speed up object creation on multi-core systems
# by distributing the work across multiple threads.
$results = Find-WinGetPackage -Query $Query -ErrorAction Stop
WriteLog "Found $($results.Count) packages matching query '$Query'."
WriteLog "Creating output objects for Winget search results, please wait..."
$output = $results | ForEach-Object -Parallel {
$arch = if ($_.Source -eq 'msstore') { 'NA' } else { $using:DefaultArchitecture }
[PSCustomObject]@{
IsSelected = [bool]$false
Name = [string]$_.Name
Id = [string]$_.Id
Version = [string]$_.Version
Source = [string]$_.Source
Architecture = [string]$arch
AdditionalExitCodes = [string]::Empty
IgnoreNonZeroExitCodes = [bool]$false
DownloadStatus = [string]::Empty
}
} -ThrottleLimit 20
WriteLog "Winget search completed. Created $($output.Count) output objects."
return $output
}
catch {
WriteLog "Error during Winget search: $($_.Exception.Message)"
# Return an empty array or throw, depending on desired UI policy
return @()
}
}
function Test-WingetCLI {
[CmdletBinding()]
param()
$minVersion = [version]"1.8.1911"
# Check Winget CLI
$wingetCmd = Get-Command -Name winget -ErrorAction SilentlyContinue
if (-not $wingetCmd) {
return @{
Version = "Not installed"
Status = "Not installed - Install from Microsoft Store"
}
}
# Get and check version
$wingetVersion = & winget.exe --version
if ($wingetVersion -match 'v?(\d+\.\d+.\d+)') {
$version = [version]$matches[1]
if ($version -lt $minVersion) {
return @{
Version = $version.ToString()
Status = "Update required - Install from Microsoft Store"
}
}
return @{
Version = $version.ToString()
Status = $version.ToString()
}
}
return @{
Version = "Unknown"
Status = "Version check failed"
}
}
function Install-WingetComponents {
[CmdletBinding()]
param(
# Add parameter to accept a script block for UI updates
[Parameter(Mandatory)]
[scriptblock]$UiUpdateCallback
)
$minVersion = [version]"1.8.1911"
$module = $null
try {
# Check and update PowerShell Module
$module = Get-InstalledModule -Name Microsoft.WinGet.Client -ErrorAction SilentlyContinue
if (-not $module -or $module.Version -lt $minVersion) {
WriteLog "Winget module needs install/update. Attempting..."
# Invoke the callback provided by the UI script to update status
# Note: We don't have the CLI version readily available here, pass a placeholder or adjust if needed.
& $UiUpdateCallback "Checking..." "Installing..."
# Store and modify PSGallery trust setting temporarily if needed
$PSGalleryTrust = (Get-PSRepository -Name 'PSGallery').InstallationPolicy
if ($PSGalleryTrust -eq 'Untrusted') {
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted
}
# Install/Update the module
Install-Module -Name Microsoft.WinGet.Client -Force -Repository 'PSGallery' -Scope AllUsers
# Restore original PSGallery trust setting
if ($PSGalleryTrust -eq 'Untrusted') {
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Untrusted
}
$module = Get-InstalledModule -Name Microsoft.WinGet.Client -ErrorAction Stop
}
return $module
}
catch {
Write-Error "Failed to install/update Winget PowerShell module: $_"
throw
}
}
# Winget Module Check Function (UI Version)
# Performs checks, triggers install if needed, and reports status back to the UI.
function Confirm-WingetInstallationUI {
[CmdletBinding()]
param(
# Callback for intermediate UI updates (e.g., "Installing...")
[Parameter(Mandatory)]
[scriptblock]$UiUpdateCallback
)
$minVersion = [version]"1.8.1911"
$result = [PSCustomObject]@{
Success = $false
Message = ""
CliVersion = "Unknown"
ModuleVersion = "Unknown"
NeedsUpdate = $false
UpdateAttempted = $false
}
try {
# Initial Check
WriteLog "Confirm-WingetInstallationUI: Starting checks..."
$cliStatus = Test-WingetCLI
$module = Get-InstalledModule -Name Microsoft.WinGet.Client -ErrorAction SilentlyContinue
$result.CliVersion = $cliStatus.Version
$result.ModuleVersion = if ($null -ne $module) { $module.Version.ToString() } else { "Not installed" }
# Use callback for initial status display
& $UiUpdateCallback $result.CliVersion $result.ModuleVersion
# Determine if install/update is needed
$needsCliUpdate = $cliStatus.Status -notmatch '^\d+\.\d+\.\d+$' -or ([version]$cliStatus.Version -lt $minVersion)
$needsModuleUpdate = ($null -eq $module) -or ([version]$module.Version -lt $minVersion)
$result.NeedsUpdate = $needsCliUpdate -or $needsModuleUpdate
if ($result.NeedsUpdate) {
WriteLog "Confirm-WingetInstallationUI: Update needed. CLI Needs Update: $needsCliUpdate, Module Needs Update: $needsModuleUpdate"
$result.UpdateAttempted = $true
# Use callback to indicate installation attempt
& $UiUpdateCallback $result.CliVersion "Installing/Updating..."
# Attempt to install/update Winget CLI and module
$installedModule = Install-WingetComponents -UiUpdateCallback $UiUpdateCallback
# Re-check status after attempt
WriteLog "Confirm-WingetInstallationUI: Re-checking status after update attempt..."
$cliStatus = Test-WingetCLI
$result.CliVersion = $cliStatus.Version
$result.ModuleVersion = if ($null -ne $installedModule) { $installedModule.Version } else { "Install Failed" }
# Use callback for final status display after update attempt
& $UiUpdateCallback $result.CliVersion $result.ModuleVersion
# Check if update was successful
$cliOk = $cliStatus.Status -match '^\d+\.\d+\.\d+$' -and ([version]$cliStatus.Version -ge $minVersion)
$moduleOk = ($null -ne $installedModule) -and ([version]$installedModule.Version -ge $minVersion)
$result.Success = $cliOk -and $moduleOk
$result.Message = if ($result.Success) { "Winget components installed/updated successfully." } else { "Winget component installation/update failed or is incomplete." }
WriteLog "Confirm-WingetInstallationUI: Update attempt finished. Success: $($result.Success). Message: $($result.Message)"
}
else {
# Already up-to-date
$result.Success = $true
$result.Message = "Winget components are up-to-date."
WriteLog "Confirm-WingetInstallationUI: Components already up-to-date."
}
}
catch {
$result.Success = $false
$result.Message = "Error during Winget check/install: $($_.Exception.Message)"
WriteLog "Confirm-WingetInstallationUI: Error - $($result.Message)"
# Use callback to show error state
& $UiUpdateCallback $result.CliVersion "Error"
}
return $result
}
# Function to handle downloading a winget application (Modified for ForEach-Object -Parallel)
function Start-WingetAppDownloadTask {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[PSCustomObject]$ApplicationItemData, # Pass data, not the UI object
[Parameter(Mandatory = $true)]
[string]$AppListJsonPath,
[Parameter(Mandatory = $true)]
[string]$AppsPath, # Pass necessary paths
[Parameter(Mandatory = $true)]
[string]$OrchestrationPath,
[Parameter(Mandatory = $true)]
[System.Collections.Concurrent.ConcurrentQueue[hashtable]]$ProgressQueue, # Add queue parameter
[string]$WindowsArch
)
$appName = $ApplicationItemData.Name
$appId = $ApplicationItemData.Id
$source = $ApplicationItemData.Source
$status = "Checking..." # Initial local status
$resultCode = -1 # Default to error/unknown
$sanitizedAppName = ConvertTo-SafeName -Name $appName
# Initial status update
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
WriteLog "Starting download task for $($appName) with ID $($appId) from source $($source)."
try {
# Define paths
$userAppListPath = Join-Path -Path $AppsPath -ChildPath "UserAppList.json"
$appFound = $false # Flag to track if the app is found locally
# WriteLog "UserAppList Path: $($userAppListPath)"
# WriteLog "Checking for existing app in UserAppList.json and content folder."
# 1. Check UserAppList.json and content
if (Test-Path -Path $userAppListPath) {
# WriteLog "UserAppList.json found at $($userAppListPath). Checking for app entry."
try {
$userAppListContent = Get-Content -Path $userAppListPath -Raw | ConvertFrom-Json
$userAppEntry = $userAppListContent | Where-Object { $_.Name -eq $appName }
if ($userAppEntry) {
$appFolder = Join-Path -Path "$AppsPath\Win32" -ChildPath $sanitizedAppName
if (Test-Path -Path $appFolder -PathType Container) {
$folderSize = (Get-ChildItem -Path $appFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($folderSize -gt 1MB) {
$appFound = $true
$status = "Not Downloaded: App in $userAppListPath and found in $appFolder"
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
WriteLog "Found '$appName' in $userAppListPath and content exists in '$appFolder'."
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 0 }
}
else {
$appFound = $true
$status = "App in '$userAppListPath' but content missing/small in '$appFolder'. Copy content or remove from UserAppList.json."
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
WriteLog $status
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
}
}
else {
$appFound = $true
$status = "App in '$userAppListPath' but content folder '$appFolder' not found. Copy content or remove from UserAppList.json."
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
WriteLog $status
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
}
}
}
catch {
WriteLog "Warning: Could not read or parse '$userAppListPath'. Error: $($_.Exception.Message)"
}
}
# 2. Check existing downloaded Win32 content (folder-based; no WinGetWin32Apps.json dependency)
if (-not $appFound -and $source -eq 'winget') {
$appFolder = Join-Path -Path "$AppsPath\Win32" -ChildPath $sanitizedAppName
if (Test-Path -Path $appFolder -PathType Container) {
$contentFound = $false
if ($ApplicationItemData.Architecture -eq 'x86 x64') {
$x86Folder = Join-Path -Path $appFolder -ChildPath "x86"
$x64Folder = Join-Path -Path $appFolder -ChildPath "x64"
if ((Test-Path -Path $x86Folder -PathType Container) -and (Test-Path -Path $x64Folder -PathType Container)) {
$x86Size = (Get-ChildItem -Path $x86Folder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
$x64Size = (Get-ChildItem -Path $x64Folder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($x86Size -gt 1MB -and $x64Size -gt 1MB) {
$contentFound = $true
}
}
}
else {
$folderSize = (Get-ChildItem -Path $appFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($folderSize -gt 1MB) {
$contentFound = $true
}
}
if ($contentFound) {
$appFound = $true
$status = "Not Downloaded: Existing content found in $appFolder"
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
WriteLog "Found existing content for '$appName' in '$appFolder'. Skipping download to prevent duplicate entry."
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 0 }
}
}
}
# Check MSStore folder
if (-not $appFound -and (Test-Path -Path "$AppsPath\MSStore" -PathType Container)) {
$appFolder = Join-Path -Path "$AppsPath\MSStore" -ChildPath $sanitizedAppName
if (Test-Path -Path $appFolder -PathType Container) {
$folderSize = (Get-ChildItem -Path $appFolder -Recurse | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
if ($folderSize -gt 1MB) {
$appFound = $true
$status = "Already downloaded (MSStore)"
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
WriteLog "Found '$appName' content in '$appFolder'."
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 0 }
}
}
}
# 3. If not found locally, add to AppList.json and download
if (-not $appFound) {
# Add to AppList.json
$appListContent = $null
$appListDir = Split-Path -Path $AppListJsonPath -Parent
if (-not (Test-Path -Path $appListDir -PathType Container)) {
New-Item -Path $appListDir -ItemType Directory -Force | Out-Null
}
if (Test-Path -Path $AppListJsonPath) {
try {
$appListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
if (-not $appListContent.PSObject.Properties['apps']) {
$appListContent = @{ apps = @() }
}
}
catch {
WriteLog "Warning: Could not read or parse '$AppListJsonPath'. Creating new structure. Error: $($_.Exception.Message)"
$appListContent = @{ apps = @() }
}
}
else {
$appListContent = @{ apps = @() }
}
$appExistsInAppList = $false
if ($appListContent.apps) {
foreach ($app in $appListContent.apps) {
if ($app.id -eq $appId) {
$appExistsInAppList = $true
break
}
}
}
if (-not $appExistsInAppList) {
$newApp = @{ name = $sanitizedAppName; id = $appId; source = $source }
if (-not ($appListContent.apps -is [array])) { $appListContent.apps = @() }
$appListContent.apps += $newApp
try {
# Use a lock to prevent race conditions when writing to the same file
$lockName = "AppListJsonLock"
$lock = New-Object System.Threading.Mutex($false, $lockName)
try {
$lock.WaitOne() | Out-Null
# Re-read content inside lock to ensure latest version
if (Test-Path -Path $AppListJsonPath) {
$currentAppListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
if (-not ($currentAppListContent.apps | Where-Object { $_.id -eq $appId })) {
$currentAppListContent.apps += $newApp
$currentAppListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
WriteLog "Added '$appName' to '$AppListJsonPath'."
}
else {
WriteLog "'$appName' already exists in '$AppListJsonPath' (checked inside lock)."
}
}
else {
# File doesn't exist, write the initial content
$appListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
WriteLog "Created '$AppListJsonPath' and added '$appName'."
}
}
finally {
$lock.ReleaseMutex()
$lock.Dispose()
}
}
catch {
WriteLog "Error saving '$AppListJsonPath'. Error: $($_.Exception.Message)"
$status = "Failed to save AppList.json: $($_.Exception.Message)"
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
return [PSCustomObject]@{ Id = $appId; Status = $status; ResultCode = 1 }
}
}
else {
WriteLog "'$appName' already exists in '$AppListJsonPath'."
}
# Proceed with download
$status = "Downloading..."
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
# Ensure variables needed by Get-Application are accessible
# (Assuming they are available via $using: scope or global scope from main script)
# $global:AppsPath = $AppsPath # Potentially redundant
# $global:WindowsArch = $ApplicationItemData.Architecture # Potentially redundant
# $global:orchestrationPath = $OrchestrationPath # Potentially redundant"
WriteLog "Orchestration Path: $($OrchestrationPath)"
if (-not (Test-Path -Path $OrchestrationPath -PathType Container)) {
New-Item -Path $OrchestrationPath -ItemType Directory -Force | Out-Null
}
$win32Folder = Join-Path -Path $AppsPath -ChildPath "Win32"
if ($source -eq "winget" -and -not (Test-Path -Path $win32Folder -PathType Container)) {
New-Item -Path $win32Folder -ItemType Directory -Force | Out-Null
}
$storeAppsFolder = Join-Path -Path $AppsPath -ChildPath "MSStore"
if ($source -eq "msstore" -and -not (Test-Path -Path $storeAppsFolder -PathType Container)) {
New-Item -Path $storeAppsFolder -ItemType Directory -Force | Out-Null
}
try {
# Call Get-Application
$resultCode = Get-Application -AppName $appName -AppId $appId -Source $source -AppsPath $AppsPath -ApplicationArch $ApplicationItemData.Architecture -WindowsArch $WindowsArch -OrchestrationPath $OrchestrationPath -SkipWin32Json -ErrorAction Stop
# Determine status based on result code
switch ($resultCode) {
0 { $status = "Downloaded successfully" }
1 { $status = "Error: No app installers were found" }
2 { $status = "Silent install switch could not be found. Did not download." }
3 { $status = "Error: Publisher does not support download" }
4 { $status = "Skipped: Use 'msstore' source instead." }
default { $status = "Downloaded with status: $resultCode" } # Should not happen with current Get-Application
}
# Remove app from AppList.json if silent install switch could not be found (resultCode 2)
if ($resultCode -eq 2) {
try {
if (Test-Path -Path $AppListJsonPath) {
$appListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
if ($appListContent.apps) {
$filteredApps = @($appListContent.apps | Where-Object { $_.id -ne $appId })
$appListContent.apps = $filteredApps
$appListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
WriteLog "Removed '$appName' ($appId) from '$AppListJsonPath' due to missing silent install switch."
}
}
}
catch {
WriteLog "Failed to remove '$appName' from '$AppListJsonPath': $($_.Exception.Message)"
}
}
}
catch {
$status = $_.Exception.Message
WriteLog "Download error for $($appName): $($_.Exception.Message)"
$resultCode = 1 # Indicate error
# Enqueue error status
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
# Remove app from AppList.json if publisher does not support download
if ($_.Exception.Message -match "does not support downloads by the publisher") {
try {
if (Test-Path -Path $AppListJsonPath) {
$appListContent = Get-Content -Path $AppListJsonPath -Raw | ConvertFrom-Json
if ($appListContent.apps) {
$filteredApps = @($appListContent.apps | Where-Object { $_.id -ne $appId })
$appListContent.apps = $filteredApps
$appListContent | ConvertTo-Json -Depth 10 | Set-Content -Path $AppListJsonPath -Encoding UTF8
WriteLog "Removed '$appName' ($appId) from '$AppListJsonPath' due to publisher download restriction."
}
}
}
catch {
WriteLog "Failed to remove '$appName' from '$AppListJsonPath': $($_.Exception.Message)"
}
}
}
} # End if (-not $appFound)
}
catch {
$status = $_.Exception.Message
WriteLog "Unexpected error in Start-WingetAppDownloadTask for $($appName): $($_.Exception.Message)"
$resultCode = 1 # Indicate error
# Enqueue error status
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
}
finally {
# Ensure status is not empty before returning
if ([string]::IsNullOrEmpty($status)) {
$status = "Unknown failure" # Provide a default error status
WriteLog "Status was empty for $appName ($appId), setting to default error."
if ($resultCode -ne 0 -and $resultCode -ne 1 -and $resultCode -ne 2) {
$resultCode = -1 # Ensure resultCode reflects an error if it was empty
}
# Enqueue the final (error) status if it was previously empty
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
}
elseif ($resultCode -ne 0) {
# Enqueue the final status if it's an error (already set in try/catch)
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
}
else {
# Enqueue the final success status
Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $appId -Status $status
}
}
# Prepare the return object as a Hashtable
$returnObject = @{ Id = $appId; Status = $status; ResultCode = $resultCode }
# Return the final status and result code as a Hashtable
return $returnObject
}
function Invoke-WingetDownload {
param(
[psobject]$State,
[object]$Button
)
try {
$selectedApps = $State.Controls.lstWingetResults.Items | Where-Object { $_.IsSelected }
if (-not $selectedApps) {
[System.Windows.MessageBox]::Show("No applications selected to download.", "Download Winget Apps", "OK", "Information")
return
}
$Button.IsEnabled = $false
$State.Controls.pbOverallProgress.Visibility = 'Visible'
$State.Controls.pbOverallProgress.Value = 0
$State.Controls.txtStatus.Text = "Starting Winget app downloads..."
# Define necessary task-specific variables locally
$localAppsPath = $State.Controls.txtApplicationPath.Text
$localAppListJsonPath = $State.Controls.txtAppListJsonPath.Text
$localWindowsArch = $State.Controls.cmbWindowsArch.SelectedItem
$localOrchestrationPath = Join-Path -Path $State.Controls.txtApplicationPath.Text -ChildPath "Orchestration"
# Create hashtable for task-specific arguments to pass to Invoke-ParallelProcessing
$taskArguments = @{
AppsPath = $localAppsPath
AppListJsonPath = $localAppListJsonPath
OrchestrationPath = $localOrchestrationPath
WindowsArch = $localWindowsArch
}
# Select only necessary properties before passing to Invoke-ParallelProcessing
$itemsToProcess = $selectedApps | Select-Object Name, Id, Source, Version, Architecture # Include Version and Architecture if needed
# Before downloading, persist the selected apps to AppList.json including exit-code fields (parity with Save-WingetList)
try {
# Determine AppList.json path; default if empty
if ([string]::IsNullOrWhiteSpace($localAppListJsonPath)) {
$localAppListJsonPath = Join-Path -Path $localAppsPath -ChildPath "AppList.json"
$taskArguments.AppListJsonPath = $localAppListJsonPath
WriteLog "AppListJsonPath was empty. Defaulting to: $localAppListJsonPath"
}
# Build apps payload from current selection, preserving AdditionalExitCodes/IgnoreNonZeroExitCodes
$appListToSave = @{
apps = @($selectedApps | ForEach-Object {
[ordered]@{
name = (ConvertTo-SafeName -Name $_.Name)
id = $_.Id
source = $_.Source.ToLower()
architecture = $_.Architecture
AdditionalExitCodes = if ($_.PSObject.Properties['AdditionalExitCodes']) { $_.AdditionalExitCodes } else { "" }
IgnoreNonZeroExitCodes = if ($_.PSObject.Properties['IgnoreNonZeroExitCodes']) { [bool]$_.IgnoreNonZeroExitCodes } else { $false }
}
})
}
# Ensure destination directory exists and write AppList.json
$destDir = Split-Path -Parent $localAppListJsonPath
if (-not (Test-Path -LiteralPath $destDir)) {
[void][System.IO.Directory]::CreateDirectory($destDir)
}
$appListToSave | ConvertTo-Json -Depth 10 | Set-Content -Path $localAppListJsonPath -Encoding UTF8
WriteLog "Persisted AppList.json with selected apps and exit-code fields to: $localAppListJsonPath"
}
catch {
WriteLog "Warning: Failed to persist AppList.json prior to download. Error: $($_.Exception.Message)"
}
# Invoke the centralized parallel processing function
# Pass task type and task-specific arguments
Invoke-ParallelProcessing -ItemsToProcess $itemsToProcess `
-ListViewControl $State.Controls.lstWingetResults `
-IdentifierProperty 'Id' `
-StatusProperty 'DownloadStatus' `
-TaskType 'WingetDownload' `
-TaskArguments $taskArguments `
-CompletedStatusText "Completed" `
-ErrorStatusPrefix "Error: " `
-WindowObject $State.Window `
-MainThreadLogPath $State.LogFilePath `
-ThrottleLimit $State.Controls.txtThreads.Text
# Final status update is handled by Invoke-ParallelProcessing, but we need to re-enable the button
$State.Controls.pbOverallProgress.Visibility = 'Collapsed'
$Button.IsEnabled = $true
}
catch {
WriteLog "FATAL Error in Invoke-WingetDownload: $($_.Exception.ToString())"
[System.Windows.MessageBox]::Show("A critical error occurred while starting the Winget download: $($_.Exception.Message)", "Error", "OK", "Error")
# Reset UI state on error
if ($Button) { $Button.IsEnabled = $true }
if ($State.Controls.pbOverallProgress) { $State.Controls.pbOverallProgress.Visibility = 'Collapsed' }
if ($State.Controls.txtStatus) { $State.Controls.txtStatus.Text = "Winget download failed to start." }
}
}
function Update-WingetVersionFields {
param(
[psobject]$State,
[string]$wingetText,
[string]$moduleText
)
$State.Window.Dispatcher.Invoke([System.Windows.Threading.DispatcherPriority]::Normal, [Action] {
$State.Controls.txtWingetVersion.Text = $wingetText
$State.Controls.txtWingetModuleVersion.Text = $moduleText
[System.Windows.Forms.Application]::DoEvents()
})
}
Export-ModuleMember -Function *
+143
View File
@@ -0,0 +1,143 @@
#
# Module manifest for module 'FFUUI.Core'
#
# Generated by: Richard Balsley
#
# Generated on: 6/11/2025
#
@{
# Script module or binary module file associated with this manifest.
RootModule = 'FFUUI.Core.psm1'
# Version number of this module.
ModuleVersion = '0.0.1'
# Supported PSEditions
# CompatiblePSEditions = @()
# ID used to uniquely identify this module
GUID = '826c5868-c452-48a9-a3d8-9ff7fea54feb'
# Author of this module
Author = 'Richard Balsley'
# Company or vendor of this module
CompanyName = 'Unknown'
# Copyright statement for this module
Copyright = '(c) Richard Balsley. All rights reserved.'
# Description of the functionality provided by this module
Description = 'Core UI logic for the FFU Builder application.'
# Minimum version of the PowerShell engine required by this module
# PowerShellVersion = ''
# Name of the PowerShell host required by this module
# PowerShellHostName = ''
# Minimum version of the PowerShell host required by this module
# PowerShellHostVersion = ''
# Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
# DotNetFrameworkVersion = ''
# Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only.
# ClrVersion = ''
# Processor architecture (None, X86, Amd64) required by this module
# ProcessorArchitecture = ''
# Modules that must be imported into the global environment prior to importing this module
RequiredModules = @('..\FFU.Common\FFU.Common.psd1')
# Assemblies that must be loaded prior to importing this module
# RequiredAssemblies = @()
# Script files (.ps1) that are run in the caller's environment prior to importing this module.
# ScriptsToProcess = @()
# Type files (.ps1xml) to be loaded when importing this module
# TypesToProcess = @()
# Format files (.ps1xml) to be loaded when importing this module
# FormatsToProcess = @()
# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess
NestedModules = @('FFUUI.Core.Applications.psm1',
'FFUUI.Core.Config.psm1',
'FFUUI.Core.Drivers.psm1',
'FFUUI.Core.Drivers.Dell.psm1',
'FFUUI.Core.Drivers.HP.psm1',
'FFUUI.Core.Drivers.Lenovo.psm1',
'FFUUI.Core.Drivers.Microsoft.psm1',
'FFUUI.Core.Handlers.psm1',
'FFUUI.Core.Initialize.psm1',
'FFUUI.Core.Shared.psm1',
'FFUUI.Core.WindowsSettings.psm1',
'FFUUI.Core.Winget.psm1')
# Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export.
FunctionsToExport = '*'
# Cmdlets 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 cmdlets to export.
CmdletsToExport = '*'
# Variables to export from this module
VariablesToExport = '*'
# Aliases 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 aliases to export.
AliasesToExport = '*'
# DSC resources to export from this module
# DscResourcesToExport = @()
# List of all modules packaged with this module
# ModuleList = @()
# List of all files packaged with this module
# FileList = @()
# Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell.
PrivateData = @{
PSData = @{
# Tags applied to this module. These help with module discovery in online galleries.
# Tags = @()
# A URL to the license for this module.
# LicenseUri = ''
# A URL to the main website for this project.
# ProjectUri = ''
# A URL to an icon representing this module.
# IconUri = ''
# ReleaseNotes of this module
# ReleaseNotes = ''
# Prerelease string of this module
# Prerelease = ''
# Flag to indicate whether the module requires explicit user acceptance for install/update/save
# RequireLicenseAcceptance = $false
# External dependent modules of this module
# ExternalModuleDependencies = @()
} # End of PSData hashtable
} # End of PrivateData hashtable
# HelpInfo URI of this module
# HelpInfoURI = ''
# Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix.
# DefaultCommandPrefix = ''
}
+436
View File
@@ -0,0 +1,436 @@
<#
.SYNOPSIS
Core logic module for the FFU Builder UI, providing helper functions, data retrieval, and UI state management.
.DESCRIPTION
This module serves as the central logic hub for the FFU Builder UI. It contains functions for retrieving system information (like Hyper-V switches and USB drives), providing default application settings, and dynamically managing the visibility and state of various UI controls across different tabs based on user selections. It orchestrates the interactions between different parts of the UI to ensure a consistent and logical user experience.
#>
# --------------------------------------------------------------------------
# SECTION: Module Variables (Static Data & State)
# --------------------------------------------------------------------------
#Microsoft sites will intermittently fail on downloads. These headers and user agent are to help with that.
$script:Headers = @{
"Accept" = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
"Accept-Encoding" = "gzip, deflate, br, zstd"
"Accept-Language" = "en-US,en;q=0.9"
"Priority" = "u=0, i"
"Sec-Ch-Ua" = "`"Not)A;Brand`";v=`"8`", `"Chromium`";v=`"138`", `"Microsoft Edge`";v=`"138`""
"Sec-Ch-Ua-Mobile" = "?0"
"Sec-Ch-Ua-Platform" = "`"Windows`""
"Sec-Fetch-Dest" = "document"
"Sec-Fetch-Mode" = "navigate"
"Sec-Fetch-Site" = "none"
"Sec-Fetch-User" = "?1"
"Upgrade-Insecure-Requests" = "1"
}
$script:UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0'
function Get-CoreStaticVariables {
[CmdletBinding()]
param()
return @{
Headers = $script:Headers
UserAgent = $script:UserAgent
}
}
# Function to get VM Switch names and associated IP addresses
function Get-VMSwitchData {
[CmdletBinding()]
param()
$switchMap = @{}
$switchNames = @()
try {
$allSwitches = Get-VMSwitch -ErrorAction SilentlyContinue
if ($null -ne $allSwitches) {
foreach ($sw in $allSwitches) {
$adapterNamePattern = "*($($sw.Name))*"
# Attempt to find the network adapter associated with the vSwitch
# Select-Object -First 1 ensures we only get one adapter if multiple match (unlikely but possible)
$netAdapter = Get-NetAdapter -Name $adapterNamePattern -ErrorAction SilentlyContinue | Select-Object -First 1
if ($netAdapter) {
# Get IPv4 addresses for the found adapter's interface index
$netIPs = Get-NetIPAddress -InterfaceIndex $netAdapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue
# Filter out Automatic Private IP Addressing (APIPA) addresses (169.254.x.x)
# and select the first valid IP found.
$validIP = $netIPs | Where-Object { $_.IPAddress -notlike '169.254.*' -and $_.IPAddress } | Select-Object -First 1
if ($validIP) {
# Store the valid IP address in the map with the switch name as the key
$switchMap[$sw.Name] = $validIP.IPAddress
# Log the found IP address for debugging/information using WriteLog
WriteLog "Found IP $($validIP.IPAddress) for vSwitch '$($sw.Name)' (Adapter: $($netAdapter.Name)). Adding to list."
# Add the switch name to the list ONLY if a valid IP was found
$switchNames += $sw.Name
}
else {
WriteLog "No valid non-APIPA IPv4 address found for vSwitch '$($sw.Name)' (Adapter: $($netAdapter.Name)). Skipping from list."
}
}
else {
WriteLog "Could not find a network adapter matching pattern '$adapterNamePattern' for vSwitch '$($sw.Name)'. Skipping from list."
}
}
}
else {
WriteLog "No Hyper-V virtual switches found on this system."
}
}
catch {
WriteLog "Error occurred while getting VM Switch data: $($_.Exception.Message)"
}
return [PSCustomObject]@{
SwitchNames = $switchNames
SwitchMap = $switchMap
}
}
# Function to return general default settings for various UI elements
function Get-GeneralDefaults {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$FFUDevelopmentPath
)
# Derive paths based on the main development path
$appsPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "Apps"
$driversPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "Drivers"
$peDriversPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "PEDrivers"
$vmLocationPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "VM"
$ffuCapturePath = Join-Path -Path $FFUDevelopmentPath -ChildPath "FFU"
$officePath = Join-Path -Path $appsPath -ChildPath "Office"
$appListJsonPath = Join-Path -Path $appsPath -ChildPath "AppList.json"
$driversJsonPath = Join-Path -Path $driversPath -ChildPath "Drivers.json"
return [PSCustomObject]@{
# Build Tab Defaults
CustomFFUNameTemplate = "{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}"
FFUCaptureLocation = $ffuCapturePath
ShareName = "FFUCaptureShare"
Username = "ffu_user"
Threads = 5
MaxUSBDrives = 5
BuildUSBDriveEnable = $false
CompactOS = $true
Optimize = $true
AllowVHDXCaching = $false
CreateCaptureMedia = $true
CreateDeploymentMedia = $true
Verbose = $false
AllowExternalHardDiskMedia = $false
PromptExternalHardDiskMedia = $true
SelectSpecificUSBDrives = $false
CopyAdditionalFFUFiles = $false
CopyAutopilot = $false
CopyUnattend = $false
CopyPPKG = $false
InjectUnattend = $false
CleanupAppsISO = $true
CleanupCaptureISO = $true
CleanupDeployISO = $true
CleanupDrivers = $false
RemoveFFU = $false
RemoveApps = $false
RemoveUpdates = $false
# Hyper-V Settings Defaults
VMHostIPAddress = ""
DiskSizeGB = 50
MemoryGB = 4
Processors = 4
VMLocation = $vmLocationPath
VMNamePrefix = "_FFU"
LogicalSectorSize = 512
# Updates Tab Defaults
UpdateLatestCU = $true
UpdateLatestNet = $true
UpdateLatestDefender = $true
UpdateEdge = $true
UpdateOneDrive = $true
UpdateLatestMSRT = $true
UpdateLatestMicrocode = $false
UpdatePreviewCU = $false
# Applications Tab Defaults
InstallApps = $false
ApplicationPath = $appsPath
AppListJsonPath = $appListJsonPath
InstallWingetApps = $false
BringYourOwnApps = $false
# M365 Apps/Office Tab Defaults
InstallOffice = $true
OfficePath = $officePath
CopyOfficeConfigXML = $false
OfficeConfigXMLFilePath = ""
# Drivers Tab Defaults
DriversFolder = $driversPath
PEDriversFolder = $peDriversPath
DriversJsonPath = $driversJsonPath
DownloadDrivers = $false
InstallDrivers = $false
CopyDrivers = $false
CopyPEDrivers = $false
UseDriversAsPEDrivers = $false
UpdateADK = $true
CompressDownloadedDriversToWim = $false
}
}
# Function to get USB Drives (Moved from BuildFFUVM_UI.ps1)
function Get-USBDrives {
Get-WmiObject Win32_DiskDrive | Where-Object {
($_.MediaType -eq 'Removable Media' -or $_.MediaType -eq 'External hard disk media')
} | ForEach-Object {
$size = [math]::Round($_.Size / 1GB, 2)
$serialNumber = if ($_.SerialNumber) { $_.SerialNumber.Trim() } else { "N/A" }
@{
IsSelected = $false
Model = $_.Model.Trim()
SerialNumber = $serialNumber
Size = $size
DriveIndex = $_.Index
}
}
}
# Returns a list of FFU files from the provided folder with selection metadata
function Get-FFUFiles {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
if (-not (Test-Path -Path $Path)) {
return @()
}
Get-ChildItem -Path $Path -Filter '*.ffu' -File -ErrorAction SilentlyContinue | ForEach-Object {
[PSCustomObject]@{
IsSelected = $false
Name = $_.Name
LastModified = $_.LastWriteTime
FullName = $_.FullName
}
}
}
# Helper: Populate Additional FFU List from the capture folder
function Update-AdditionalFFUList {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[PSCustomObject]$State
)
try {
$ffuFolder = $State.Controls.txtFFUCaptureLocation.Text
$listView = $State.Controls.lstAdditionalFFUs
if ($null -eq $listView) { return }
$listView.Items.Clear()
if ([string]::IsNullOrWhiteSpace($ffuFolder) -or -not (Test-Path -Path $ffuFolder)) {
WriteLog "Additional FFUs: Capture folder not set or not found: $ffuFolder"
}
else {
$items = Get-ChildItem -Path $ffuFolder -Filter '*.ffu' -File -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending |
ForEach-Object {
[PSCustomObject]@{
IsSelected = $false
Name = $_.Name
LastModified = $_.LastWriteTime
FullName = $_.FullName
}
}
foreach ($it in $items) { $listView.Items.Add($it) | Out-Null }
WriteLog "Additional FFUs: Found $($listView.Items.Count) FFU files in $ffuFolder."
}
$headerChk = $State.Controls.chkSelectAllAdditionalFFUs
if ($null -ne $headerChk) {
Update-SelectAllHeaderCheckBoxState -ListView $listView -HeaderCheckBox $headerChk
}
}
catch {
WriteLog "Update-AdditionalFFUList error: $($_.Exception.Message)"
}
}
# Function to manage the visibility of the application UI panels
function Update-ApplicationPanelVisibility {
param(
[PSCustomObject]$State,
[string]$TriggeringControlName # Optional: to know which control initiated the change
)
# If BYO Apps, Winget Apps, or Define Apps Script Variables is checked, force Install Apps to be checked
if ($State.Controls.chkBringYourOwnApps.IsChecked -or $State.Controls.chkInstallWingetApps.IsChecked -or $State.Controls.chkDefineAppsScriptVariables.IsChecked) {
$State.Controls.chkInstallApps.IsChecked = $true
}
$installAppsChecked = $State.Controls.chkInstallApps.IsChecked
# If the main 'Install Apps' is unchecked, everything below it gets hidden and reset.
if ($TriggeringControlName -eq 'chkInstallApps' -and -not $installAppsChecked) {
$State.Controls.chkInstallWingetApps.IsChecked = $false
$State.Controls.chkBringYourOwnApps.IsChecked = $false
$State.Controls.chkDefineAppsScriptVariables.IsChecked = $false
}
$byoAppsChecked = $State.Controls.chkBringYourOwnApps.IsChecked
$wingetAppsChecked = $State.Controls.chkInstallWingetApps.IsChecked
$defineVarsChecked = $State.Controls.chkDefineAppsScriptVariables.IsChecked
# Visibility of primary sub-options
$subOptionVisibility = if ($installAppsChecked) { 'Visible' } else { 'Collapsed' }
$State.Controls.applicationPathPanel.Visibility = $subOptionVisibility
$State.Controls.appListJsonPathPanel.Visibility = $subOptionVisibility
$State.Controls.chkInstallWingetApps.Visibility = $subOptionVisibility
$State.Controls.chkBringYourOwnApps.Visibility = $subOptionVisibility
$State.Controls.chkDefineAppsScriptVariables.Visibility = $subOptionVisibility
# Visibility of panels dependent on sub-options
$State.Controls.byoApplicationPanel.Visibility = if ($installAppsChecked -and $byoAppsChecked) { 'Visible' } else { 'Collapsed' }
$State.Controls.wingetPanel.Visibility = if ($installAppsChecked -and $wingetAppsChecked) { 'Visible' } else { 'Collapsed' }
$State.Controls.appsScriptVariablesPanel.Visibility = if ($installAppsChecked -and $defineVarsChecked) { 'Visible' } else { 'Collapsed' }
# Special handling for wingetSearchPanel, which is shown by another button.
# We only collapse it if its parent becomes invisible.
if (-not ($installAppsChecked -and $wingetAppsChecked)) {
$State.Controls.wingetSearchPanel.Visibility = 'Collapsed'
}
}
# Function to manage the state of the main "Install Apps" checkbox based on selections in Updates/Office
function Update-InstallAppsState {
param([PSCustomObject]$State)
$installAppsChk = $State.Controls.chkInstallApps
$installOfficeChk = $State.Controls.chkInstallOffice
# Determine if any checkbox that forces "Install Apps" is checked
$anyUpdateChecked = $State.Controls.chkUpdateLatestDefender.IsChecked -or `
$State.Controls.chkUpdateEdge.IsChecked -or `
$State.Controls.chkUpdateOneDrive.IsChecked -or `
$State.Controls.chkUpdateLatestMSRT.IsChecked
$isForced = $anyUpdateChecked -or $installOfficeChk.IsChecked
if ($isForced) {
# If InstallApps is not already forced (i.e., it's enabled), save its current state.
if ($installAppsChk.IsEnabled) {
$State.Flags.prevInstallAppsState = $installAppsChk.IsChecked
}
$installAppsChk.IsChecked = $true
$installAppsChk.IsEnabled = $false
}
else {
# No longer forced. Restore the previous state if it was saved.
if ($State.Flags.ContainsKey('prevInstallAppsState')) {
$installAppsChk.IsChecked = $State.Flags.prevInstallAppsState
$State.Flags.Remove('prevInstallAppsState') # Use the saved state only once
}
else {
# If no state was saved (e.g., it was never forced), ensure it's unchecked.
$installAppsChk.IsChecked = $false
}
# If BYO, Winget, or Apps Script Variables are checked, it overrides the restoration and keeps Install Apps checked.
if ($State.Controls.chkBringYourOwnApps.IsChecked -or $State.Controls.chkInstallWingetApps.IsChecked -or $State.Controls.chkDefineAppsScriptVariables.IsChecked) {
$installAppsChk.IsChecked = $true
}
$installAppsChk.IsEnabled = $true
}
}
# Function to manage the enabled state of interdependent driver-related checkboxes
function Update-DriverCheckboxStates {
param([PSCustomObject]$State)
$installDriversChk = $State.Controls.chkInstallDrivers
$copyDriversChk = $State.Controls.chkCopyDrivers
$compressWimChk = $State.Controls.chkCompressDriversToWIM
$copyPEDriversChk = $State.Controls.chkCopyPEDrivers
$useDriversAsPeChk = $State.Controls.chkUseDriversAsPEDrivers
# Default to enabled, then apply disabling rules
$installDriversChk.IsEnabled = $true
$copyDriversChk.IsEnabled = $true
$compressWimChk.IsEnabled = $true
$copyPEDriversChk.IsEnabled = $true
if ($installDriversChk.IsChecked) {
$copyDriversChk.IsEnabled = $false
$compressWimChk.IsEnabled = $false
}
if ($copyDriversChk.IsChecked) {
$installDriversChk.IsEnabled = $false
}
if ($compressWimChk.IsChecked) {
$installDriversChk.IsEnabled = $false
}
# Sub-option visibility logic: only show UseDriversAsPEDrivers when CopyPEDrivers is checked
if ($copyPEDriversChk.IsChecked) {
$useDriversAsPeChk.Visibility = 'Visible'
}
else {
# Parent unchecked: hide and clear sub-option
$useDriversAsPeChk.IsChecked = $false
$useDriversAsPeChk.Visibility = 'Collapsed'
}
}
# Function to manage the visibility of Office UI panels
function Update-OfficePanelVisibility {
param([PSCustomObject]$State)
if ($State.Controls.chkInstallOffice.IsChecked) {
$State.Controls.OfficePathStackPanel.Visibility = 'Visible'
$State.Controls.OfficePathGrid.Visibility = 'Visible'
$State.Controls.CopyOfficeConfigXMLStackPanel.Visibility = 'Visible'
# Show/hide XML file path based on checkbox state
$State.Controls.OfficeConfigurationXMLFileStackPanel.Visibility = if ($State.Controls.chkCopyOfficeConfigXML.IsChecked) { 'Visible' } else { 'Collapsed' }
$State.Controls.OfficeConfigurationXMLFileGrid.Visibility = if ($State.Controls.chkCopyOfficeConfigXML.IsChecked) { 'Visible' } else { 'Collapsed' }
}
else {
$State.Controls.OfficePathStackPanel.Visibility = 'Collapsed'
$State.Controls.OfficePathGrid.Visibility = 'Collapsed'
$State.Controls.CopyOfficeConfigXMLStackPanel.Visibility = 'Collapsed'
$State.Controls.OfficeConfigurationXMLFileStackPanel.Visibility = 'Collapsed'
$State.Controls.OfficeConfigurationXMLFileGrid.Visibility = 'Collapsed'
}
}
# Function to manage the visibility of the driver download UI panels
function Update-DriverDownloadPanelVisibility {
param([PSCustomObject]$State)
if ($State.Controls.chkDownloadDrivers.IsChecked) {
$State.Controls.spMakeSection.Visibility = 'Visible'
$State.Controls.btnGetModels.Visibility = 'Visible'
# The other panels are shown/hidden by the Get Models button click handler
}
else {
$State.Controls.spMakeSection.Visibility = 'Collapsed'
$State.Controls.btnGetModels.Visibility = 'Collapsed'
$State.Controls.spModelFilterSection.Visibility = 'Collapsed'
$State.Controls.lstDriverModels.Visibility = 'Collapsed'
$State.Controls.spDriverActionButtons.Visibility = 'Collapsed'
$State.Controls.lstDriverModels.ItemsSource = $null
$State.Data.allDriverModels.Clear()
$State.Controls.txtModelFilter.Text = ""
}
}
# --------------------------------------------------------------------------
# SECTION: Module Export
# --------------------------------------------------------------------------
# Export only the functions intended for public use by the UI script
Export-ModuleMember -Function *
+2 -1
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'){
@@ -1,31 +1,100 @@
#Modify the net use W: \\192.168.1.158\FFUCaptureShare /user:ffu_user ddb1f077-3eed-433c-b4d9-7b8cd54ce727 $VMHostIPAddress = '192.168.1.158'
net use W: \\192.168.1.158\FFUCaptureShare /user:ffu_user ddb1f077-3eed-433c-b4d9-7b8cd54ce727 $ShareName = 'FFUCaptureShare'
#Custom naming placeholder $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' $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 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 #Load Registry Hive
$Software = 'M:\Windows\System32\config\software' $Software = 'M:\Windows\System32\config\software'
reg load "HKLM\FFU" $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 #Find Windows version values
Write-Host "Retrieving Windows information from the registry..."
$SKU = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'EditionID' $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' [int]$CurrentBuild = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'CurrentBuild'
$InstallationType = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'InstallationType' Write-Host "CurrentBuild: $CurrentBuild"
if ($CurrentBuild -notin 14393, 17763 -and $InstallationType -ne "Server") { 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' $WindowsVersion = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'DisplayVersion'
Write-Host "WindowsVersion: $WindowsVersion"
} }
# For Windows 10 LTSB 2016, set WindowsVersion to 2016 $InstallationType = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'InstallationType'
if ($CurrentBuild -eq 14393 -and $InstallationType -eq "Client") { Write-Host "InstallationType: $InstallationType"
$WindowsVersion = '2016'
}
# For Windows 10 LTSC 2019, set WindowsVersion to 2019
if ($CurrentBuild -eq 17763 -and $InstallationType -eq "Client") {
$WindowsVersion = '2019'
}
$BuildDate = Get-Date -uformat %b%Y $BuildDate = Get-Date -uformat %b%Y
Write-Host "BuildDate: $BuildDate"
$SKU = switch ($SKU) { $SKU = switch ($SKU) {
Core { 'Home' } Core { 'Home' }
@@ -51,9 +120,11 @@ $SKU = switch ($SKU) {
if ($InstallationType -eq "Client") { if ($InstallationType -eq "Client") {
if ($CurrentBuild -ge 22000) { if ($CurrentBuild -ge 22000) {
$WindowsRelease = 'Win11' $WindowsRelease = 'Win11'
Write-Host "WindowsRelease: $WindowsRelease"
} }
else { else {
$WindowsRelease = 'Win10' $WindowsRelease = 'Win10'
Write-Host "WindowsRelease: $WindowsRelease"
} }
} }
else { else {
@@ -64,37 +135,49 @@ else {
14393 { '2016' } 14393 { '2016' }
Default { $WindowsVersion } Default { $WindowsVersion }
} }
Write-Host "WindowsRelease: $WindowsRelease"
if ($InstallationType -eq "Server Core") { if ($InstallationType -eq "Server Core") {
$SKU += "_Core" $SKU += "_Core"
Write-Host "InstallType is Server Core, changing SKU to: $SKU"
} }
} }
if ($CustomFFUNameTemplate) { if ($CustomFFUNameTemplate) {
$CustomFFUNameTemplate = $CustomFFUNameTemplate -replace '{WindowsRelease}', $WindowsRelease Write-Host 'Using custom FFU name template...'
$CustomFFUNameTemplate = $CustomFFUNameTemplate -replace '{WindowsVersion}', $WindowsVersion $FFUFileName = $CustomFFUNameTemplate
$CustomFFUNameTemplate = $CustomFFUNameTemplate -replace '{SKU}', $SKU $FFUFileName = $FFUFileName -replace '{WindowsRelease}', $WindowsRelease
$CustomFFUNameTemplate = $CustomFFUNameTemplate -replace '{BuildDate}', $BuildDate $FFUFileName = $FFUFileName -replace '{WindowsVersion}', $WindowsVersion
$CustomFFUNameTemplate = $CustomFFUNameTemplate -replace '{yyyy}', (Get-Date -UFormat '%Y') $FFUFileName = $FFUFileName -replace '{SKU}', $SKU
$CustomFFUNameTemplate = $CustomFFUNameTemplate -creplace '{MM}', (Get-Date -UFormat '%m') $FFUFileName = $FFUFileName -replace '{BuildDate}', $BuildDate
$CustomFFUNameTemplate = $CustomFFUNameTemplate -replace '{dd}', (Get-Date -UFormat '%d') $FFUFileName = $FFUFileName -replace '{yyyy}', (Get-Date -UFormat '%Y')
$CustomFFUNameTemplate = $CustomFFUNameTemplate -creplace '{HH}', (Get-Date -UFormat '%H') $FFUFileName = $FFUFileName -creplace '{MM}', (Get-Date -UFormat '%m')
$CustomFFUNameTemplate = $CustomFFUNameTemplate -creplace '{hh}', (Get-Date -UFormat '%I') $FFUFileName = $FFUFileName -replace '{dd}', (Get-Date -UFormat '%d')
$CustomFFUNameTemplate = $CustomFFUNameTemplate -creplace '{mm}', (Get-Date -UFormat '%M') $FFUFileName = $FFUFileName -creplace '{HH}', (Get-Date -UFormat '%H')
$CustomFFUNameTemplate = $CustomFFUNameTemplate -replace '{tt}', (Get-Date -UFormat '%p') $FFUFileName = $FFUFileName -creplace '{hh}', (Get-Date -UFormat '%I')
if($CustomFFUNameTemplate -notlike '*.ffu') { $FFUFileName = $FFUFileName -creplace '{mm}', (Get-Date -UFormat '%M')
$CustomFFUNameTemplate += '.ffu' $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:\$CustomFFUNameTemplate /capturedrive=\\.\PhysicalDrive0 /name:$WindowsRelease$WindowsVersion$SKU /Compress:Default" $dismArgs = "/capture-ffu /imagefile=W:\$FFUFileName /capturedrive=\\.\PhysicalDrive0 /name:$WindowsRelease$WindowsVersion$SKU /Compress:Default"
} else { Write-Host "DISM arguments for capture: $dismArgs"
}
else {
#If Office is installed, modify the file name of the FFU #If Office is installed, modify the file name of the FFU
#$Office = Get-childitem -Path 'M:\Program Files\Microsoft Office' -ErrorAction SilentlyContinue | Out-Null
$Office = Get-ChildItem -Path 'M:\Program Files\Microsoft Office' -ErrorAction SilentlyContinue $Office = Get-ChildItem -Path 'M:\Program Files\Microsoft Office' -ErrorAction SilentlyContinue
if ($Office) { if ($Office) {
$ffuFilePath = "W:\$WindowsRelease`_$WindowsVersion`_$SKU`_Office`_$BuildDate.ffu" $ffuFilePath = "W:\$WindowsRelease`_$WindowsVersion`_$SKU`_Office`_$BuildDate.ffu"
} else { Write-Host "Office is installed, using modified FFU file name: $ffuFilePath"
}
else {
$ffuFilePath = "W:\$WindowsRelease`_$WindowsVersion`_$SKU`_Apps`_$BuildDate.ffu" $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" $dismArgs = "/capture-ffu /imagefile=$ffuFilePath /capturedrive=\\.\PhysicalDrive0 /name:$WindowsRelease$WindowsVersion$SKU /Compress:Default"
Write-Host "DISM arguments for capture: $dismArgs"
} }
#Unload Registry #Unload Registry
@@ -107,11 +190,48 @@ if ($CurrentBuild -notin 14393, 17763) {
if ($Office) { if ($Office) {
Remove-Variable Office Remove-Variable Office
} }
reg unload "HKLM\FFU"
#This prevents Critical Process Died errors you can have during deployment of the FFU - may not happen during capture from WinPE, but adding here to be consistent with VHDX capture 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" Write-Host "Sleeping for 60 seconds to allow registry to unload prior to capture"
Start-sleep 60 Start-sleep 60
Start-Process -FilePath dism.exe -ArgumentList $dismArgs -Wait -PassThru -ErrorAction Stop | Out-Null
#Copy DISM log to Host 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 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 wpeutil Shutdown
}
catch {
Write-Error "An unexpected error occurred: $_"
}
+450 -101
View File
@@ -1,12 +1,13 @@
function Get-USBDrive(){ function Get-USBDrive() {
$USBDriveLetter = (Get-Volume | Where-Object { $_.DriveType -eq 'Removable' -and $_.FileSystemType -eq 'NTFS' }).DriveLetter $USBDriveLetter = (Get-Volume | Where-Object { $_.DriveType -eq 'Removable' -and $_.FileSystemType -eq 'NTFS' }).DriveLetter
if ($null -eq $USBDriveLetter) { if ($null -eq $USBDriveLetter) {
#Must be using a fixed USB drive - difficult to grab drive letter from win32_diskdrive. Assume user followed instructions and used Deploy as the friendly name for partition #Must be using a fixed USB drive - difficult to grab drive letter from win32_diskdrive. Assume user followed instructions and used Deploy as the friendly name for partition
$USBDriveLetter = (Get-Volume | Where-Object { $_.DriveType -eq 'Fixed' -and $_.FileSystemType -eq 'NTFS' -and $_.FileSystemLabel -eq 'Deploy' }).DriveLetter $USBDriveLetter = (Get-Volume | Where-Object { $_.DriveType -eq 'Fixed' -and $_.FileSystemType -eq 'NTFS' -and $_.FileSystemLabel -eq 'Deploy' }).DriveLetter
#If we didn't get the drive letter, stop the script. #If we didn't get the drive letter, stop the script.
if ($null -eq $USBDriveLetter) { if ($null -eq $USBDriveLetter) {
WriteLog 'Cannot find USB drive letter - most likely using a fixed USB drive. Name the 2nd partition with the FFU files as Deploy so the script can grab the drive letter. Exiting' $errorMessage = 'Cannot find USB drive letter. If using a fixed USB drive, name the deployment partition "Deploy".'
Exit WriteLog ($errorMessage + ' Exiting.')
Stop-Script -Message $errorMessage
} }
} }
@@ -15,15 +16,13 @@ function Get-USBDrive(){
} }
function Get-HardDrive() { function Get-HardDrive() {
$SystemInfo = Get-WmiObject -Class 'Win32_ComputerSystem' $systemInfo = Get-CimInstance -Class 'Win32_ComputerSystem'
$Manufacturer = $SystemInfo.Manufacturer $manufacturer = $systemInfo.Manufacturer
$Model = $SystemInfo.Model $model = $systemInfo.Model
WriteLog "Device Manufacturer: $Manufacturer"
WriteLog "Device Model: $Model"
WriteLog 'Getting Hard Drive info' WriteLog 'Getting Hard Drive info'
if ($Manufacturer -eq 'Microsoft Corporation' -and $Model -eq 'Virtual Machine'){ if ($manufacturer -eq 'Microsoft Corporation' -and $model -eq 'Virtual Machine') {
WriteLog 'Running in a Hyper-V VM. Getting virtual disk on Index 0 and SCSILogicalUnit 0' WriteLog 'Running in a Hyper-V VM. Getting virtual disk on Index 0 and SCSILogicalUnit 0'
$DiskDrive = Get-WmiObject -Class 'Win32_DiskDrive' | Where-Object {$_.MediaType -eq 'Fixed hard disk media' ` $diskDrive = Get-CimInstance -Class 'Win32_DiskDrive' | Where-Object { $_.MediaType -eq 'Fixed hard disk media' `
-and $_.Model -eq 'Microsoft Virtual Disk' ` -and $_.Model -eq 'Microsoft Virtual Disk' `
-and $_.Index -eq 0 ` -and $_.Index -eq 0 `
-and $_.SCSILogicalUnit -eq 0 -and $_.SCSILogicalUnit -eq 0
@@ -31,15 +30,17 @@ function Get-HardDrive(){
} }
else { else {
WriteLog 'Not running in a VM. Getting physical disk drive' WriteLog 'Not running in a VM. Getting physical disk drive'
$DiskDrive = Get-WmiObject -Class 'Win32_DiskDrive' | Where-Object {$_.MediaType -eq 'Fixed hard disk media' -and $_.Model -ne 'Microsoft Virtual Disk'} $diskDrive = Get-CimInstance -Class 'Win32_DiskDrive' | Where-Object { $_.MediaType -eq 'Fixed hard disk media' -and $_.Model -ne 'Microsoft Virtual Disk' }
} }
$DeviceID = $DiskDrive.DeviceID $deviceID = $diskDrive.DeviceID
$BytesPerSector = $Diskdrive.BytesPerSector $bytesPerSector = $diskDrive.BytesPerSector
$diskSize = $diskDrive.Size
# Create a custom object to return both values # Create a custom object to return values
$result = New-Object PSObject -Property @{ $result = [PSCustomObject]@{
DeviceID = $DeviceID DeviceID = $deviceID
BytesPerSector = $BytesPerSector BytesPerSector = $bytesPerSector
DiskSize = $diskSize
} }
return $result return $result
@@ -98,7 +99,7 @@ function Invoke-Process {
RedirectStandardOutput = $stdOutTempFile RedirectStandardOutput = $stdOutTempFile
Wait = $true; Wait = $true;
PassThru = $true; PassThru = $true;
NoNewWindow = $false; NoNewWindow = $true;
} }
if ($PSCmdlet.ShouldProcess("Process [$($FilePath)]", "Run with args: [$($ArgumentList)]")) { if ($PSCmdlet.ShouldProcess("Process [$($FilePath)]", "Run with args: [$($ArgumentList)]")) {
$cmd = Start-Process @startProcessParams $cmd = Start-Process @startProcessParams
@@ -111,58 +112,175 @@ function Invoke-Process {
if ($cmdOutput) { if ($cmdOutput) {
throw $cmdOutput.Trim() throw $cmdOutput.Trim()
} }
} else { }
else {
if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) { if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) {
WriteLog $cmdOutput WriteLog $cmdOutput
} }
} }
} }
} catch { }
catch {
#$PSCmdlet.ThrowTerminatingError($_) #$PSCmdlet.ThrowTerminatingError($_)
WriteLog $_ WriteLog $_
Write-Host 'Script failed - check scriptlog.txt on the USB drive for more info' Write-Host 'Script failed - check scriptlog.txt on the USB drive for more info'
throw $_ throw $_
} finally { }
finally {
Remove-Item -Path $stdOutTempFile, $stdErrTempFile -Force -ErrorAction Ignore Remove-Item -Path $stdOutTempFile, $stdErrTempFile -Force -ErrorAction Ignore
} }
} }
function Write-SectionHeader($Title) {
$width = 51
$leftPad = [math]::Floor(($width - $Title.Length) / 2)
$rightPad = $width - $Title.Length - $leftPad
$centeredTitle = (' ' * $leftPad) + $Title + (' ' * $rightPad)
Write-Host "`n" # Add a newline for spacing
Write-Host ('-' * $width) -ForegroundColor Yellow
Write-Host $centeredTitle -ForegroundColor Yellow
Write-Host ('-' * $width) -ForegroundColor Yellow
}
function Write-SystemInformation($hardDrive) {
# Gather all information first
$systemManufacturer = (Get-CimInstance -Class Win32_ComputerSystem).Manufacturer
$systemModel = if ($systemManufacturer -like '*LENOVO*') {
(Get-CimInstance -Class Win32_ComputerSystemProduct).Version
}
else {
(Get-CimInstance -Class Win32_ComputerSystem).Model
}
$biosInfo = Get-CimInstance -Class Win32_Bios
$processor = (Get-CimInstance -Class Win32_Processor).Name
$totalMemory = (Get-CimInstance -Class Win32_PhysicalMemory | Measure-Object -Property Capacity -Sum).Sum
$totalMemoryGB = [math]::Round($totalMemory / 1GB, 2)
$diskSizeGB = [math]::Round($hardDrive.DiskSize / 1GB, 2)
# Create a custom object for structured data
$sysInfoObject = [PSCustomObject]@{
"Manufacturer" = $systemManufacturer
"Model" = $systemModel
"BIOS Version" = $biosInfo.Version
"Serial Number" = $biosInfo.SerialNumber
"Processor" = $processor
"Memory" = "$($totalMemoryGB) GB"
"Disk Size" = "$($diskSizeGB) GB"
"Logical Sector Size" = "$($hardDrive.BytesPerSector) Bytes"
}
# Log information line-by-line
WriteLog "--- System Information ---"
$sysInfoObject.psobject.Properties | ForEach-Object {
WriteLog "$($_.Name): $($_.Value)"
}
WriteLog "--- End System Information ---"
# Console output
Write-SectionHeader -Title 'System Information'
# Format for console using Format-List for better readability
$consoleOutput = $sysInfoObject | Format-List | Out-String
Write-Host $consoleOutput.Trim()
Write-Host # Adds a blank line for spacing after the block
}
function Stop-Script {
param(
[string]$Message
)
Write-Host "`n"
if (-not [string]::IsNullOrWhiteSpace($Message)) {
Write-Error -Message $Message
}
WriteLog "Copying dism log to $USBDrive"
Invoke-Process xcopy "X:\Windows\logs\dism\dism.log $USBDrive /Y"
WriteLog "Copying dism log to $USBDrive succeeded"
Read-Host "Press Enter to exit"
Exit
}
function ConvertTo-ComparableModelName {
[CmdletBinding()]
param(
[string]$Text
)
# Normalize model strings with HP-specific adjustments.
# Remove inch unit variants (23.8-in, 23.8 inch, 23inch, 23-in, etc.) keeping only the numeric size.
# Canonicalize All-in-One variants (All in One, All-in-One, All-in-One PC, AiO, AIO) to 'AIO'.
# Convert any non-alphanumeric sequence to a single space, collapse whitespace, and trim.
if ($null -eq $Text) { return '' }
$original = $Text
# Remove inch unit variants while preserving the numeric size
$Text = [regex]::Replace($Text, '(?i)(\d+(?:\.\d+)?)(?:\s*[-]?\s*)(?:in|inch)\b', '$1')
# Canonicalize All-in-One variants
$Text = [regex]::Replace($Text, '(?i)\bAll[\s-]*in[\s-]*One(?:\s*PC)?\b', 'AIO')
$Text = [regex]::Replace($Text, '(?i)\bAiO\b', 'AIO')
# Generic normalization
$normalized = ($Text -replace '[^A-Za-z0-9]+', ' ')
$normalized = ($normalized -replace '\s+', ' ').Trim()
if ($normalized -ne $original) {
WriteLog "Normalized model string: Original='$original' -> Normalized='$normalized'"
}
return $normalized
}
#Get USB Drive and create log file #Get USB Drive and create log file
$LogFileName = 'ScriptLog.txt' $LogFileName = 'ScriptLog.txt'
$USBDrive = Get-USBDrive $USBDrive = Get-USBDrive
New-item -Path $USBDrive -Name $LogFileName -ItemType "file" -Force | Out-Null New-item -Path $USBDrive -Name $LogFileName -ItemType "file" -Force | Out-Null
$LogFile = $USBDrive + $LogFilename $LogFile = $USBDrive + $LogFilename
$version = '2505.2' $version = '2509.1Preview'
WriteLog 'Begin Logging' WriteLog 'Begin Logging'
WriteLog "Script version: $version" WriteLog "Script version: $version"
# Display banner and version
$banner = @"
"@
Write-Host $banner -ForegroundColor Cyan
Write-Host "Version $version" -ForegroundColor Cyan
#Find PhysicalDrive #Find PhysicalDrive
# $PhysicalDeviceID = Get-HardDrive # $PhysicalDeviceID = Get-HardDrive
$hardDrive = Get-HardDrive $hardDrive = Get-HardDrive
if ($null -eq $hardDrive) { if ($null -eq $hardDrive) {
WriteLog 'No hard drive found. Exiting' $errorMessage = 'No hard drive found. You may need to add storage drivers to the WinPE image.'
WriteLog 'Try adding storage drivers to the PE boot image (you can re-create your FFU and USB drive and add the PE drivers to the PEDrivers folder and add -CopyPEDrivers $true to the command line, or manually add them via DISM)' WriteLog ($errorMessage + ' Exiting.')
Exit WriteLog 'To add drivers, place them in the PEDrivers folder and re-run the creation script with -CopyPEDrivers $true, or add them manually via DISM.'
Stop-Script -Message $errorMessage
} }
$PhysicalDeviceID = $hardDrive.DeviceID $PhysicalDeviceID = $hardDrive.DeviceID
$BytesPerSector = $hardDrive.BytesPerSector $BytesPerSector = $hardDrive.BytesPerSector
WriteLog "Physical BytesPerSector is $BytesPerSector"
WriteLog "Physical DeviceID is $PhysicalDeviceID" WriteLog "Physical DeviceID is $PhysicalDeviceID"
#Parse DiskID Number #Parse DiskID Number
$DiskID = $PhysicalDeviceID.substring($PhysicalDeviceID.length - 1, 1) $DiskID = $PhysicalDeviceID.substring($PhysicalDeviceID.length - 1, 1)
WriteLog "DiskID is $DiskID" WriteLog "DiskID is $DiskID"
# Write System Information to console and log
Write-SystemInformation -hardDrive $hardDrive
#Find FFU Files #Find FFU Files
Write-SectionHeader 'FFU File Selection'
[array]$FFUFiles = @(Get-ChildItem -Path $USBDrive*.ffu) [array]$FFUFiles = @(Get-ChildItem -Path $USBDrive*.ffu)
$FFUCount = $FFUFiles.Count $FFUCount = $FFUFiles.Count
#If multiple FFUs found, ask which to install #If multiple FFUs found, ask which to install
If ($FFUCount -gt 1) { If ($FFUCount -gt 1) {
WriteLog "Found $FFUCount FFU Files" WriteLog "Found $FFUCount FFU Files"
Write-Host "Found $FFUCount FFU Files"
$array = @() $array = @()
for ($i = 0; $i -le $FFUCount - 1; $i++) { for ($i = 0; $i -le $FFUCount - 1; $i++) {
@@ -188,13 +306,15 @@ If ($FFUCount -gt 1) {
} }
elseif ($FFUCount -eq 1) { elseif ($FFUCount -eq 1) {
WriteLog "Found $FFUCount FFU File" WriteLog "Found $FFUCount FFU File"
Write-Host "Found $FFUCount FFU File"
$FFUFileToInstall = $FFUFiles[0].FullName $FFUFileToInstall = $FFUFiles[0].FullName
WriteLog "$FFUFileToInstall will be installed" WriteLog "$FFUFileToInstall will be installed"
Write-Host "$FFUFileToInstall will be installed"
} }
else { else {
Writelog 'No FFU files found' $errorMessage = 'No FFU files found.'
Write-Host 'No FFU files found' Writelog $errorMessage
Exit Stop-Script -Message $errorMessage
} }
#FindAP #FindAP
@@ -243,6 +363,7 @@ If (Test-Path -Path $UnattendComputerNamePath){
} }
#Ask for device name if unattend exists #Ask for device name if unattend exists
Write-SectionHeader 'Device Name Selection'
if ($Unattend -and $UnattendPrefix) { if ($Unattend -and $UnattendPrefix) {
Writelog 'Unattend file found with prefixes.txt. Getting prefixes.' Writelog 'Unattend file found with prefixes.txt. Getting prefixes.'
$UnattendPrefixes = @(Get-content $UnattendPrefixFile) $UnattendPrefixes = @(Get-content $UnattendPrefixFile)
@@ -268,11 +389,14 @@ if ($Unattend -and $UnattendPrefix){
} until (($PrefixSelected -le $UnattendPrefixCount - 1) -and $var) } until (($PrefixSelected -le $UnattendPrefixCount - 1) -and $var)
$PrefixToUse = $array[$PrefixSelected].DeviceNamePrefix $PrefixToUse = $array[$PrefixSelected].DeviceNamePrefix
WriteLog "$PrefixToUse was selected" WriteLog "$PrefixToUse was selected"
Write-Host "`n$PrefixToUse was selected as device name prefix"
} }
elseif ($UnattendPrefixCount -eq 1) { elseif ($UnattendPrefixCount -eq 1) {
WriteLog "Found $UnattendPrefixCount Prefix" WriteLog "Found $UnattendPrefixCount Prefix"
Write-Host "Found $UnattendPrefixCount Prefix"
$PrefixToUse = $UnattendPrefixes[0] $PrefixToUse = $UnattendPrefixes[0]
WriteLog "Will use $PrefixToUse as device name prefix" WriteLog "Will use $PrefixToUse as device name prefix"
Write-Host "Will use $PrefixToUse as device name prefix"
} }
#Get serial number to append. This can make names longer than 15 characters. Trim any leading or trailing whitespace #Get serial number to append. This can make names longer than 15 characters. Trim any leading or trailing whitespace
$serial = (Get-CimInstance -ClassName win32_bios).SerialNumber.Trim() $serial = (Get-CimInstance -ClassName win32_bios).SerialNumber.Trim()
@@ -283,7 +407,8 @@ if ($Unattend -and $UnattendPrefix){
$computername = $computername.substring(0, 15) $computername = $computername.substring(0, 15)
} }
$computername = Set-Computername($computername) $computername = Set-Computername($computername)
Writelog "Computer name set to $computername" Writelog "Computer name will be set to $computername"
Write-Host "Computer name will be set to $computername"
} }
elseif ($Unattend -and $UnattendComputerName) { elseif ($Unattend -and $UnattendComputerName) {
Writelog 'Unattend file found with SerialComputerNames.csv. Getting name for current computer.' Writelog 'Unattend file found with SerialComputerNames.csv. Getting name for current computer.'
@@ -295,19 +420,25 @@ elseif($Unattend -and $UnattendComputerName){
If ($SCName) { If ($SCName) {
[string]$computername = $SCName.ComputerName [string]$computername = $SCName.ComputerName
$computername = Set-Computername($computername) $computername = Set-Computername($computername)
Writelog "Computer name set to $computername" Writelog "Computer name will be set to $computername"
} else { Write-Host "Computer name will be set to $computername"
}
else {
Writelog 'No matching serial number found in SerialComputerNames.csv. Setting random computer name to complete setup.' Writelog 'No matching serial number found in SerialComputerNames.csv. Setting random computer name to complete setup.'
Write-Host 'No matching serial number found in SerialComputerNames.csv. Setting random computer name to complete setup.'
[string]$computername = ("FFU-" + ( -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 11 | ForEach-Object { [char]$_ }))) [string]$computername = ("FFU-" + ( -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 11 | ForEach-Object { [char]$_ })))
$computername = Set-Computername($computername) $computername = Set-Computername($computername)
Writelog "Computer name set to $computername" Writelog "Computer name will be set to $computername"
Write-Host "Computer name will be set to $computername"
} }
} }
elseif ($Unattend) { elseif ($Unattend) {
Writelog 'Unattend file found with no prefixes.txt, asking for name' Writelog 'Unattend file found with no prefixes.txt, asking for name'
Write-Host 'Unattend file found but no prefixes.txt. Please enter a device name.'
[string]$computername = Read-Host 'Enter device name' [string]$computername = Read-Host 'Enter device name'
Set-Computername($computername) $computername = Set-Computername($computername)
Writelog "Computer name set to $computername" Writelog "Computer name will be set to $computername"
Write-Host "Computer name will be set to $computername"
} }
else { else {
WriteLog 'No unattend folder found. Device name will be set via PPKG, AP JSON, or default OS name.' WriteLog 'No unattend folder found. Device name will be set via PPKG, AP JSON, or default OS name.'
@@ -375,6 +506,7 @@ else {
#If multiple PPKG files found, ask which to install #If multiple PPKG files found, ask which to install
If ($PPKGFilesCount -gt 1 -and $PPKG -eq $true) { If ($PPKGFilesCount -gt 1 -and $PPKG -eq $true) {
Write-SectionHeader -Title 'Provisioning Package Selection'
WriteLog "Found $PPKGFilesCount PPKG Files" WriteLog "Found $PPKGFilesCount PPKG Files"
$array = @() $array = @()
@@ -398,61 +530,195 @@ If ($PPKGFilesCount -gt 1 -and $PPKG -eq $true) {
$PPKGFileToInstall = $array[$PPKGFileSelected].PPKGFile $PPKGFileToInstall = $array[$PPKGFileSelected].PPKGFile
WriteLog "$PPKGFileToInstall was selected" WriteLog "$PPKGFileToInstall was selected"
Write-Host "`n$PPKGFileToInstall will be used"
} }
elseif ($PPKGFilesCount -eq 1 -and $PPKG -eq $true) { elseif ($PPKGFilesCount -eq 1 -and $PPKG -eq $true) {
Write-SectionHeader -Title 'Provisioning Package Selection'
WriteLog "Found $PPKGFilesCount PPKG File" WriteLog "Found $PPKGFilesCount PPKG File"
Write-Host "Found $PPKGFilesCount PPKG File"
$PPKGFileToInstall = $PPKGFiles[0].FullName $PPKGFileToInstall = $PPKGFiles[0].FullName
WriteLog "$PPKGFileToInstall will be used" WriteLog "$PPKGFileToInstall will be used"
Write-Host "`n$PPKGFileToInstall will be used"
} }
else { else {
Writelog 'No PPKG files found or PPKG not selected.' Writelog 'No PPKG files found or PPKG not selected.'
} }
#Find Drivers #Find Drivers
$Drivers = $USBDrive + "Drivers" $DriversPath = $USBDrive + "Drivers"
If (Test-Path -Path $Drivers) $DriverSourcePath = $null
{ $DriverSourceType = $null # Will be 'WIM' or 'Folder'
#Check if multiple driver folders found, if so, just select one folder to save time/space $driverMappingPath = Join-Path -Path $DriversPath -ChildPath "DriverMapping.json"
$DriverFolders = Get-ChildItem -Path $Drivers -directory
$DriverFoldersCount = $DriverFolders.count
If ($DriverFoldersCount -gt 1)
{
WriteLog "Found $DriverFoldersCount driver folders"
$array = @()
for($i=0; $i -le $DriverFoldersCount -1; $i++){ If (Test-Path -Path $DriversPath) {
$Properties = [ordered]@{Number = $i + 1; Drivers = $DriverFolders[$i].FullName} Write-SectionHeader -Title 'Drivers Selection'
$array += New-Object PSObject -Property $Properties
} }
$array | Format-Table -AutoSize -Property Number, Drivers
# --- Automatic Driver Detection using DriverMapping.json ---
if (Test-Path -Path $driverMappingPath -PathType Leaf) {
WriteLog "DriverMapping.json found at $driverMappingPath. Attempting automatic driver selection."
Write-Host "DriverMapping.json found. Attempting automatic driver selection."
try {
# Get system information
$systemManufacturer = (Get-CimInstance -Class Win32_ComputerSystem).Manufacturer
# Lenovo uses a different property for the model name
$systemModel = if ($systemManufacturer -like '*LENOVO*') {
(Get-CimInstance -Class Win32_ComputerSystemProduct).Version
}
else {
(Get-CimInstance -Class Win32_ComputerSystem).Model
}
WriteLog "Detected System: Manufacturer='$systemManufacturer', Model='$systemModel'"
# Load and parse the mapping file, ensuring it's always an array
$driverMappings = Get-Content -Path $driverMappingPath | Out-String | ConvertFrom-Json -ErrorAction SilentlyContinue
# Find all matching rules and select the most specific one
$matchingRules = @()
# Normalize system model once outside the loop
$systemModelNorm = ConvertTo-ComparableModelName -Text $systemModel
foreach ($rule in $driverMappings) {
# Use -like for wildcard matching.
# Prepare normalized rule model string
$ruleModelNorm = ConvertTo-ComparableModelName -Text $rule.Model
# This checks if the system model starts with the rule model, or vice-versa, for flexibility.
if ($systemManufacturer -like "$($rule.Manufacturer)*" -and ($systemModelNorm -like "$($ruleModelNorm)*" -or $ruleModelNorm -like "$systemModelNorm*")) {
WriteLog "Match found: Manufacturer='$($rule.Manufacturer)', Model='$($rule.Model)' (Normalized: System='$systemModelNorm', Rule='$ruleModelNorm')"
$matchingRules += $rule
}
}
# Select the best match
$matchedRule = $null
if ($matchingRules.Count -gt 0) {
WriteLog "Found $($matchingRules.Count) potential driver mapping rule(s)."
Write-Host "Found $($matchingRules.Count) potential driver mapping rule(s)."
foreach ($rule in $matchingRules) {
WriteLog " - Potential Match: Manufacturer='$($rule.Manufacturer)', Model='$($rule.Model)', Path='$($rule.DriverPath)'"
Write-Host " - Potential Match: Manufacturer='$($rule.Manufacturer)', Model='$($rule.Model)', Path='$($rule.DriverPath)'"
}
# Sort by model name length, descending, to find the most specific match
$matchedRule = $matchingRules | Sort-Object -Property @{Expression = { $_.Model.Length } } -Descending | Select-Object -First 1
}
if ($null -ne $matchedRule) {
WriteLog "Automatic match found: Manufacturer='$($matchedRule.Manufacturer)', Model='$($matchedRule.Model)'"
Write-Host "Automatic match found: Manufacturer='$($matchedRule.Manufacturer)', Model='$($matchedRule.Model)'"
$potentialDriverPath = Join-Path -Path $DriversPath -ChildPath $matchedRule.DriverPath
if (Test-Path -Path $potentialDriverPath) {
$DriverSourcePath = $potentialDriverPath
# Determine if it's a WIM or a Folder
if ($DriverSourcePath -like '*.wim') {
$DriverSourceType = 'WIM'
}
else {
$DriverSourceType = 'Folder'
}
WriteLog "Automatically selected driver source. Type: $DriverSourceType, Path: $DriverSourcePath"
Write-Host "Automatically selected driver source. Type: $DriverSourceType, Path: $DriverSourcePath"
}
else {
WriteLog "Matched driver path '$potentialDriverPath' not found. Falling back to manual selection."
Write-Host "Matched driver path '$potentialDriverPath' not found. Falling back to manual selection."
}
}
else {
WriteLog "No matching driver rule found in DriverMapping.json for this system. Falling back to manual selection."
Write-Host "No matching driver rule found in DriverMapping.json for this system. Falling back to manual selection."
}
}
catch {
WriteLog "An error occurred during automatic driver detection: $($_.Exception.Message). Falling back to manual selection."
Write-Host "An error occurred during automatic driver detection: $($_.Exception.Message). Falling back to manual selection."
}
}
else {
WriteLog "DriverMapping.json not found. Proceeding with manual driver selection."
}
# --- Manual Driver Selection (Fallback) ---
if ($null -eq $DriverSourcePath) {
If (Test-Path -Path $DriversPath) {
WriteLog "Searching for driver WIMs and folders in $DriversPath"
# Get all WIM files
$WimFiles = Get-ChildItem -Path $DriversPath -Filter *.wim -Recurse
# Get all top-level driver folders
$DriverFolders = Get-ChildItem -Path $DriversPath -Directory
# Create a combined list
$DriverSources = @()
$WimFiles | ForEach-Object {
$DriverSources += [PSCustomObject]@{
Type = 'WIM'
Path = $_.FullName
}
}
$DriverFolders | ForEach-Object {
$DriverSources += [PSCustomObject]@{
Type = 'Folder'
Path = $_.FullName
}
}
$DriverSourcesCount = $DriverSources.Count
if ($DriverSourcesCount -gt 0) {
WriteLog "Found $DriverSourcesCount total driver sources (WIMs and folders)."
if ($DriverSourcesCount -eq 1) {
$DriverSourcePath = $DriverSources[0].Path
$DriverSourceType = $DriverSources[0].Type
WriteLog "Single driver source found. Type: $DriverSourceType, Path: $DriverSourcePath"
Write-Host "Single driver source found. Type: $DriverSourceType, Path: $DriverSourcePath"
}
else {
# Multiple sources found, prompt user
WriteLog "Multiple driver sources found. Prompting for selection."
$displayArray = @()
for ($i = 0; $i -lt $DriverSourcesCount; $i++) {
$displayArray += [PSCustomObject]@{
Number = $i + 1
Type = $DriverSources[$i].Type
Path = $DriverSources[$i].Path
}
}
$displayArray | Format-Table -AutoSize
do { do {
try { try {
$var = $true $var = $true
[int]$DriversSelected = Read-Host 'Enter the set of drivers to install' [int]$DriverSelected = Read-Host 'Enter the number of the driver source to install'
$DriversSelected = $DriversSelected - 1 $DriverSelected = $DriverSelected - 1
} }
catch { catch {
Write-Host 'Input was not in correct format. Please enter a valid driver folder number' Write-Host 'Input was not in correct format. Please enter a valid number.'
$var = $false $var = $false
} }
} until (($DriversSelected -le $DriverFoldersCount -1) -and $var) } until (($DriverSelected -ge 0) -and ($DriverSelected -lt $DriverSourcesCount) -and $var)
$Drivers = $array[$DriversSelected].Drivers $DriverSourcePath = $DriverSources[$DriverSelected].Path
WriteLog "$Drivers was selected" $DriverSourceType = $DriverSources[$DriverSelected].Type
WriteLog "User selected Type: $DriverSourceType, Path: $DriverSourcePath"
Write-Host "`nUser selected Type: $DriverSourceType, Path: $DriverSourcePath"
} }
elseif ($DriverFoldersCount -eq 1) {
WriteLog "Found $DriverFoldersCount driver folder"
$Drivers = $DriverFolders.FullName
WriteLog "$Drivers will be installed"
} }
else { else {
Writelog 'No driver folders found' WriteLog "No driver WIMs or folders found in Drivers directory."
Write-Host "No driver WIMs or folders found in Drivers directory."
}
}
else {
WriteLog "Drivers folder not found at $DriversPath. Skipping driver installation."
} }
} }
#Partition drive #Partition drive
Writelog 'Clean Disk' Writelog 'Clean Disk'
$originalProgressPreference = $ProgressPreference
try { try {
$ProgressPreference = 'SilentlyContinue'
$Disk = Get-Disk -Number $DiskID $Disk = Get-Disk -Number $DiskID
if ($Disk.PartitionStyle -ne "RAW") { if ($Disk.PartitionStyle -ne "RAW") {
$Disk | clear-disk -RemoveData -RemoveOEM -Confirm:$false $Disk | clear-disk -RemoveData -RemoveOEM -Confirm:$false
@@ -462,15 +728,64 @@ catch {
WriteLog 'Cleaning disk failed. Exiting' WriteLog 'Cleaning disk failed. Exiting'
throw $_ throw $_
} }
finally {
$ProgressPreference = $originalProgressPreference
}
Writelog 'Cleaning Disk succeeded' Writelog 'Cleaning Disk succeeded'
#Apply FFU #Apply FFU
Write-SectionHeader -Title 'Applying FFU'
WriteLog "Applying FFU to $PhysicalDeviceID" WriteLog "Applying FFU to $PhysicalDeviceID"
WriteLog "Running command dism /apply-ffu /ImageFile:$FFUFileToInstall /ApplyDrive:$PhysicalDeviceID" WriteLog "Running command dism /apply-ffu /ImageFile:$FFUFileToInstall /ApplyDrive:$PhysicalDeviceID"
#In order for Applying Image progress bar to show up, need to call dism directly. Might be a better way to handle, but must have progress bar show up on screen. #In order for Applying Image progress bar to show up, need to call dism directly. Might be a better way to handle, but must have progress bar show up on screen.
dism /apply-ffu /ImageFile:$FFUFileToInstall /ApplyDrive:$PhysicalDeviceID dism /apply-ffu /ImageFile:$FFUFileToInstall /ApplyDrive:$PhysicalDeviceID
$recoveryPartition = Get-Partition -Disk $Disk | Where-Object PartitionNumber -eq 4 $dismExitCode = $LASTEXITCODE
if ($dismExitCode -ne 0) {
$errorMessage = "Failed to apply FFU. LastExitCode = $dismExitCode."
if ($dismExitCode -eq 1393) {
WriteLog "Failed to apply FFU - LastExitCode = $dismExitCode"
WriteLog "This is likely due to a mismatched LogicalSectorSizeBytes"
WriteLog "BytesPerSector value from Win32_Diskdrive is $BytesPerSector"
if ($BytesPerSector -eq 4096) {
WriteLog "The FFU build process by default uses a 512 LogicalSectorSizeBytes. Rebuild the FFU by adding -LogicalSectorSizeBytes 4096 to the command line"
}
elseif ($BytesPerSector -eq 512) {
WriteLog "This FFU was likely built with a LogicalSectorSizeBytes of 4096. Rebuild the FFU by adding -LogicalSectorSizeBytes 512 to the command line"
}
$errorMessage += " This is likely due to a mismatched logical sector size. Check logs for details."
}
else {
Writelog "Failed to apply FFU - LastExitCode = $dismExitCode also check dism.log on the USB drive for more info"
$errorMessage += " Check dism.log on the USB drive for more info."
}
Stop-Script -Message $errorMessage
}
WriteLog 'Successfully applied FFU'
# Verify Windows partition exists and assign drive letter
$windowsPartition = Get-Partition -DiskNumber $DiskID | Where-Object { $_.PartitionNumber -eq 3 }
if ($null -eq $windowsPartition) {
$errorMessage = "Windows partition (Partition 3) not found after applying FFU, even though DISM reported success."
WriteLog $errorMessage
Stop-Script -Message $errorMessage
}
WriteLog "Assigning drive letter 'W' to Windows partition."
Set-Partition -InputObject $windowsPartition -NewDriveLetter W
# Verify the drive letter was set
$windowsVolume = Get-Volume -DriveLetter W -ErrorAction SilentlyContinue
if ($null -eq $windowsVolume) {
$errorMessage = "Failed to assign drive letter 'W' to the Windows partition after applying FFU."
WriteLog $errorMessage
Stop-Script -Message $errorMessage
}
WriteLog "Successfully assigned drive letter 'W'."
$recoveryPartition = Get-Partition -DiskNumber $DiskID | Where-Object PartitionNumber -eq 4
if ($recoveryPartition) { if ($recoveryPartition) {
WriteLog 'Setting recovery partition attributes' WriteLog 'Setting recovery partition attributes'
$diskpartScript = @( $diskpartScript = @(
@@ -482,35 +797,10 @@ if ($recoveryPartition) {
$diskpartScript | diskpart.exe | Out-Null $diskpartScript | diskpart.exe | Out-Null
WriteLog 'Setting recovery partition attributes complete' WriteLog 'Setting recovery partition attributes complete'
} }
if($LASTEXITCODE -eq 0){
WriteLog 'Successfully applied FFU'
}
elseif($LASTEXITCODE -eq 1393){
WriteLog "Failed to apply FFU - LastExitCode = $LastExitCode"
WriteLog "This is likely due to a mismatched LogicalSectorByteSize"
WriteLog "BytesPerSector value from Win32_Diskdrive is $BytesPerSector"
if ($BytesPerSector -eq 4096){
WriteLog "The FFU build process by default uses a 512 LogicalSectorByteSize. Rebuild the FFU by adding -LogicalSectorByteSize 4096 to the command line"
}
elseif($BytesPerSector -eq 512){
WriteLog "This FFU was likely built with a LogicalSectorByteSize of 4096. Rebuild the FFU by adding -LogicalSectorByteSize 512 to the command line"
}
#Copy DISM log to USBDrive
invoke-process xcopy.exe "X:\Windows\logs\dism\dism.log $USBDrive /Y"
exit
}
else{
Writelog "Failed to apply FFU - LastExitCode = $LASTEXITCODE also check dism.log on the USB drive for more info"
#Copy DISM log to USBDrive
invoke-process xcopy.exe "X:\Windows\logs\dism\dism.log $USBDrive /Y"
exit
}
Get-Disk | Where-Object Number -eq $DiskID | Get-Partition | Where-Object PartitionNumber -eq 3 | Set-Partition -NewDriveLetter W
#Copy modified WinRE if folder exists, else copy inbox WinRE #Copy modified WinRE if folder exists, else copy inbox WinRE
$WinRE = $USBDrive + "WinRE\winre.wim" $WinRE = $USBDrive + "WinRE\winre.wim"
If (Test-Path -Path $WinRE) If (Test-Path -Path $WinRE) {
{
WriteLog 'Copying modified WinRE to Recovery directory' WriteLog 'Copying modified WinRE to Recovery directory'
Get-Disk | Where-Object Number -eq $DiskID | Get-Partition | Where-Object Type -eq Recovery | Set-Partition -NewDriveLetter R Get-Disk | Where-Object Number -eq $DiskID | Get-Partition | Where-Object Type -eq Recovery | Set-Partition -NewDriveLetter R
Invoke-Process xcopy.exe "/h $WinRE R:\Recovery\WindowsRE\ /Y" Invoke-Process xcopy.exe "/h $WinRE R:\Recovery\WindowsRE\ /Y"
@@ -522,6 +812,7 @@ If (Test-Path -Path $WinRE)
} }
#Autopilot JSON #Autopilot JSON
If ($APFileToInstall) { If ($APFileToInstall) {
Write-SectionHeader -Title 'Applying Autopilot Configuration'
WriteLog "Copying $APFileToInstall to W:\windows\provisioning\autopilot" WriteLog "Copying $APFileToInstall to W:\windows\provisioning\autopilot"
Invoke-process xcopy.exe "$APFileToInstall W:\Windows\provisioning\autopilot\" Invoke-process xcopy.exe "$APFileToInstall W:\Windows\provisioning\autopilot\"
WriteLog "Copying $APFileToInstall to W:\windows\provisioning\autopilot succeeded" WriteLog "Copying $APFileToInstall to W:\windows\provisioning\autopilot succeeded"
@@ -538,60 +829,118 @@ If ($APFileToInstall){
} }
#Apply PPKG #Apply PPKG
If ($PPKGFileToInstall) { If ($PPKGFileToInstall) {
Write-SectionHeader -Title 'Applying Provisioning Package'
try { try {
#Make sure to delete any existing PPKG on the USB drive #Make sure to delete any existing PPKG on the USB drive
Get-Childitem -Path $USBDrive\*.ppkg | ForEach-Object { Get-Childitem -Path $USBDrive\*.ppkg | ForEach-Object {
Remove-item -Path $_.FullName Remove-item -Path $_.FullName
} }
WriteLog "Copying $PPKGFileToInstall to $USBDrive" WriteLog "Copying $PPKGFileToInstall to $USBDrive"
Write-Host "Copying $PPKGFileToInstall to $USBDrive"
Invoke-process xcopy.exe "$PPKGFileToInstall $USBDrive" Invoke-process xcopy.exe "$PPKGFileToInstall $USBDrive"
WriteLog "Copying $PPKGFileToInstall to $USBDrive succeeded" WriteLog "Copying $PPKGFileToInstall to $USBDrive succeeded"
Write-Host "Copying $PPKGFileToInstall to $USBDrive succeeded"
} }
catch { catch {
Writelog "Copying $PPKGFileToInstall to $USBDrive failed with error: $_" Writelog "Copying $PPKGFileToInstall to $USBDrive failed with error: $_"
Write-Host "Copying $PPKGFileToInstall to $USBDrive failed with error: $_"
throw $_ throw $_
} }
} }
#Set DeviceName #Set DeviceName
If ($computername) { If ($computername) {
Write-SectionHeader -Title 'Applying Computer Name and Unattend Configuration'
try { try {
$PantherDir = 'w:\windows\panther' $PantherDir = 'w:\windows\panther'
If (Test-Path -Path $PantherDir) { If (Test-Path -Path $PantherDir) {
Writelog "Copying $UnattendFile to $PantherDir" Writelog "Copying $UnattendFile to $PantherDir"
Write-Host "Copying $UnattendFile to $PantherDir"
Invoke-process xcopy "$UnattendFile $PantherDir /Y" Invoke-process xcopy "$UnattendFile $PantherDir /Y"
WriteLog "Copying $UnattendFile to $PantherDir succeeded" WriteLog "Copying $UnattendFile to $PantherDir succeeded"
Write-Host "Copying $UnattendFile to $PantherDir succeeded"
} }
else { else {
Writelog "$PantherDir doesn't exist, creating it" Writelog "$PantherDir doesn't exist, creating it"
New-Item -Path $PantherDir -ItemType Directory -Force New-Item -Path $PantherDir -ItemType Directory -Force
Writelog "Copying $UnattendFile to $PantherDir" Writelog "Copying $UnattendFile to $PantherDir"
Write-Host "Copying $UnattendFile to $PantherDir"
Invoke-Process xcopy.exe "$UnattendFile $PantherDir" Invoke-Process xcopy.exe "$UnattendFile $PantherDir"
WriteLog "Copying $UnattendFile to $PantherDir succeeded" WriteLog "Copying $UnattendFile to $PantherDir succeeded"
Write-Host "Copying $UnattendFile to $PantherDir succeeded"
} }
} }
catch { catch {
WriteLog "Copying Unattend.xml to name device failed" WriteLog "Copying Unattend.xml to name device failed"
throw $_ Stop-Script -Message "Copying Unattend.xml to name device failed with error: $_"
} }
} }
# Add Drivers # Add Drivers
#Some drivers can sometimes fail to copy and dism ends up with a non-zero error code. Invoke-process will throw and terminate in these instances. if ($null -ne $DriverSourcePath) {
If (Test-Path -Path $Drivers) Write-SectionHeader -Title 'Installing Drivers'
{ if ($DriverSourceType -eq 'WIM') {
WriteLog 'Copying drivers' WriteLog "Installing drivers from WIM: $DriverSourcePath"
Write-Warning 'Copying Drivers - dism will pop a window with no progress. This can take a few minutes to complete. This is done so drivers are logged to the scriptlog.txt file. Please be patient.' Write-Host "Installing drivers from WIM: $DriverSourcePath"
Invoke-process dism.exe "/image:W:\ /Add-Driver /Driver:""$Drivers"" /Recurse" $TempDriverDir = "W:\TempDrivers"
WriteLog 'Copying drivers succeeded' try {
} WriteLog "Creating temporary directory for drivers at $TempDriverDir"
New-Item -Path $TempDriverDir -ItemType Directory -Force | Out-Null
WriteLog "Setting Windows Boot Manager to be first in the display order." WriteLog "Mounting WIM contents to $TempDriverDir"
Write-Host "Mounting WIM contents to $TempDriverDir"
# For some reason can't use /mount-image with invoke-process, so using dism.exe directly
dism.exe /Mount-Image /ImageFile:$DriverSourcePath /Index:1 /MountDir:$TempDriverDir /ReadOnly /optimize
WriteLog "WIM mount successful."
WriteLog "Injecting drivers from $TempDriverDir"
Write-Host "Injecting drivers from $TempDriverDir"
Write-Host "This may take a while, please be patient."
Invoke-Process dism.exe "/image:W:\ /Add-Driver /Driver:""$TempDriverDir"" /Recurse"
WriteLog "Driver injection from WIM succeeded."
Write-Host "Driver injection from WIM succeeded."
}
catch {
WriteLog "An error occurred during WIM driver installation: $_"
# Copy DISM log to USBDrive for debugging
invoke-process xcopy.exe "X:\Windows\logs\dism\dism.log $USBDrive /Y"
throw $_
}
finally {
if (Test-Path -Path $TempDriverDir) {
WriteLog "Unmounting WIM from $TempDriverDir"
Write-Host "Unmounting WIM from $TempDriverDir"
Invoke-Process dism.exe "/Unmount-Image /MountDir:""$TempDriverDir"" /Discard"
WriteLog "Unmount successful."
Write-Host "Unmount successful."
WriteLog "Cleaning up temporary driver directory: $TempDriverDir"
Write-Host "Cleaning up temporary driver directory: $TempDriverDir"
Remove-Item -Path $TempDriverDir -Recurse -Force
WriteLog "Cleanup successful."
Write-Host "Cleanup successful."
}
}
}
elseif ($DriverSourceType -eq 'Folder') {
WriteLog "Injecting drivers from folder: $DriverSourcePath"
Write-Host "Injecting drivers from folder: $DriverSourcePath"
Write-Host "This may take a while, please be patient."
Invoke-Process dism.exe "/image:W:\ /Add-Driver /Driver:""$DriverSourcePath"" /Recurse"
WriteLog "Driver injection from folder succeeded."
Write-Host "Driver injection from folder succeeded."
}
}
else {
WriteLog "No drivers to install."
}
Write-SectionHeader -Title 'Setting Boot Configuration'
WriteLog "Setting Windows Boot Manager to be first in the firmware display order."
Write-Host "Setting Windows Boot Manager to be first in the firmware display order."
Invoke-Process bcdedit.exe "/set {fwbootmgr} displayorder {bootmgr} /addfirst" Invoke-Process bcdedit.exe "/set {fwbootmgr} displayorder {bootmgr} /addfirst"
WriteLog "Windows Boot Manager has been set to be first in the display order." WriteLog "Setting Windows Boot Manager to be first in the default display order."
WriteLog "Setting default Windows boot loader to be first in the display order." Write-Host "Setting Windows Boot Manager to be first in the default display order."
Invoke-Process bcdedit.exe "/set {bootmgr} displayorder {default} /addfirst" Invoke-Process bcdedit.exe "/set {bootmgr} displayorder {default} /addfirst"
WriteLog "The default Windows boot loader has been set to be first in the display order."
#Copy DISM log to USBDrive #Copy DISM log to USBDrive
WriteLog "Copying dism log to $USBDrive" WriteLog "Copying dism log to $USBDrive"
invoke-process xcopy "X:\Windows\logs\dism\dism.log $USBDrive /Y" invoke-process xcopy "X:\Windows\logs\dism\dism.log $USBDrive /Y"
@@ -1,5 +1,6 @@
wpeinit @echo off
powercfg /s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c wpeinit > NUL
powercfg /s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c > NUL
powershell -Noprofile -ExecutionPolicy Bypass -File x:\ApplyFFU.ps1 powershell -Noprofile -ExecutionPolicy Bypass -File x:\ApplyFFU.ps1
exit exit
Binary file not shown.
+44 -50
View File
@@ -1,8 +1,6 @@
**Update 2025-08-01:** [2507.2 UI Preview is now available](https://github.com/rbalsleyMSFT/FFU/releases) - click the link to get the build and check out the Youtube walk-through.
# 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 or Server) that has: What if you could have a Windows image (Windows 10/11/Server/LTSC) that has:
- The latest Windows cumulative update - The latest Windows cumulative update
- The latest .NET cumulative update - The latest .NET cumulative update
@@ -16,96 +14,92 @@ What if you could have a Windows image (Windows 10/11 or Server) that has:
- The ability to bring your own drivers and apps if necessary - The ability to bring your own drivers and apps if necessary
- Custom WinRE support - Custom WinRE support
And the best part: it takes less than two minutes to apply the image, even with all of these updates added to the media. After setting Windows up and going through Autopilot or a provisioning package, total elapsed time ~10 minutes (depending on what Intune or your device management tool is deploying). And the best part: **it takes less than two minutes** to apply the image, even with all of these updates added to the media. After setting Windows up and going through Autopilot or a provisioning package, total elapsed time ~10 minutes (depending on what Intune or your device management tool is deploying).
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
2509.1 has been released to preview! This is a major update that brings a new user interface to preview.
Docs are coming, but will take a bit to write them. The youtube video is a must watch for a complete demo on how to use the UI and the changes made to apps (InstallAppsAndSysprep.cmd is gone) and drivers. I'll be recording a more formalized deep dive with slides that go a bit deeper into how things work, but the UI walkthrough should get most people going.
# Getting Started # Getting Started
- Download the latest [release](https://github.com/rbalsleyMSFT/FFU/releases) - Download the latest [release](https://github.com/rbalsleyMSFT/FFU/releases)
- Extract the FFUDevelopment folder from the ZIP file (recommend to C:\FFUDevelopment) - Extract the FFUDevelopment folder from the ZIP file (recommend to C:\FFUDevelopment)
- Follow the doc: C:\FFUDevelopment\Docs\BuildDeployFFU.docx - Watch the Youtube video (updated docs for the UI coming soon)
## YouTube Detailed Walkthrough ## YouTube Detailed Walkthrough
The first 15 minutes of the following video includes a quick start demo to get started. Below the video are a list of chapters. This video was taken with the 2407.2 build. Features released after that will not be demonstrated in the video. Here's a detailed overview of the new UI process.
[![Reimage Windows Fast with Full-Flash Update (FFU))](https://img.youtube.com/vi/rqXRbgeeKSQ/maxresdefault.jpg)](https://www.youtube.com/watch?v=rqXRbgeeKSQ "Reimage Windows Fast with Full-Flash Update (FFU))") [![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")
Chapters: Chapters:
[00:00](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=0s) Begin [00:00](https://www.youtube.com/watch?v=oozG1aVcg9M&t=0s) Begin
[03:21](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=201s) Quick Start Prereqs [01:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=67s) Prereqs
[07:19](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=439s) Quick Start Demo [06:32](https://www.youtube.com/watch?v=oozG1aVcg9M&t=392s) Demo Begins
[14:12](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=852s) Script Parameters [07:16](https://www.youtube.com/watch?v=oozG1aVcg9M&t=436s) Running the BuildFFUVM_UI.ps1 script
[17:22](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=1042s) Obtaining Windows Media [08:15](https://www.youtube.com/watch?v=oozG1aVcg9M&t=495s) UI Overview
[25:55](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=1555s) Adding Applications [10:13](https://www.youtube.com/watch?v=oozG1aVcg9M&t=613s) Hyper-V Settings
[26:59](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=1619s) Adding M365 Apps/Office [16:04](https://www.youtube.com/watch?v=oozG1aVcg9M&t=964s) Windows Settings
[29:21](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=1761s) Adding Applications via Winget [22:35](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1355s) Updates
[34:59](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=2099s) Bring your own Applications [24:49](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1489s) Applications
[36:01](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=2161s) Customizing InstallAppsAndSysprep.cmd [29:39](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1779s) Install Winget Applications
[38:34](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=2314s) Demo - Application Configuration [45:29](https://www.youtube.com/watch?v=oozG1aVcg9M&t=2729s) Bring Your Own Applications
[49:43](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=2983s) Drivers [54:14](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3254s) Apps Script Variables
[55:39](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=3339s) Automatically downloading drivers [57:43](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3463s) M365 Apps/Office
[57:28](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=3448s) Microsoft Surface drivers [59:01](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3541s) Drivers
[58:55](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=3535s) Dell drivers [01:01:22](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3682s) Drivers.json example
[01:01:45](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=3705s) Lenovo drivers [01:02:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3727s) DriverMapping.json explanation
[01:03:16](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=3796s) HP drivers [01:06:08](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3968s) Driver WIM Compression
[01:05:25](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=3925s) Bring your own drivers [01:10:50](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4250s) Build
[01:06:24](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=3984s) Demo - Drivers [01:12:41](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4361s) Build USB Drive
[01:11:55](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=4315s) Multi-model driver support [01:20:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4807s) Monitor
[01:13:21](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=4401s) Device naming [01:20:32](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4832s) Setting up the Demo Build
[01:18:30](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=4710s) Device enrollment [01:24:10](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5050s) Save/Load Config Files
[01:21:43](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=4903s) Autopilot [01:25:11](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5111s) Kicking off the Demo Build/Going over the monitor tab
[01:24:57](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=5097s) Provisioning packages [01:32:26](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5546s) Demoing the new FFU Builder Orchestrator
[01:26:54](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=5214s) Custom WinRE [01:35:25](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5725s) New captureffu.ps1 console output
[01:29:59](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=5399s) Demo - Putting it all together (Deep dive) [01:42:29](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6149s) Demo Build Complete
[01:32:06](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=5526s) Downloading Lenovo 500w drivers [01:42:42](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6162s) How to configure a VM to test your newly built FFU
[01:33:28](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=5608s) Downloading apps via Winget [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:36:54](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=5814s) Downloading Office, Defender, Edge, OneDrive [01:53:13](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6793s) How to bypass OOBE using a provisioning package
[01:38:15](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=5895s) Building the Apps.iso [01:55:49](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6949s) Preview Focus Areas
[01:39:08](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=5948s) Applying Windows to the VHDX [02:04:04](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7444s) Known Issues/Things to fix before GA
[01:40:16](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=6016s) Downloading and applying cumulative updates [02:05:38](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7538s) Providing Feedback
[01:41:44](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=6104s) Building the VM [02:06:43](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7603s) Thank you
[01:48:13](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=6493s) Capturing the FFU
[01:53:38](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=6818s) Creating USB drive
[01:58:41](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=7121s) Deploying FFU
[02:11:48](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=7908s) Troubleshooting
[02:14:30](https://www.youtube.com/watch?v=rqXRbgeeKSQ&t=8070s) EDU Endpoint Office Hours