Compare commits

..

113 Commits

Author SHA1 Message Date
rbalsleyMSFT 0153176278 Update winget installation command for PowerShell
Clarify installer type for PowerShell installation using winget.
2026-05-04 15:24:48 -07:00
rbalsleyMSFT 8774fb4ef0 Updates quickstart video to 2604.1 release
Replaces the previous preview video link with the newly updated tutorial for the 2604.1 release. Relocates the older video into the archived section.
2026-04-24 11:29:07 -07:00
rbalsleyMSFT 10a8887281 Merge pull request #473 from rbalsleyMSFT/UI
Removes previous release notes from documentation
2026-04-17 12:06:00 -07:00
rbalsleyMSFT f29319efc4 Removes previous release notes from documentation
Cleans up the introductory section by removing specific patch notes and release announcements. This streamlines the content to maintain focus on general usage and instructions rather than transient update news.
2026-04-17 12:04:41 -07:00
rbalsleyMSFT 37a9572c97 Merge pull request #472 from rbalsleyMSFT/UI
2604.1
2026-04-16 17:04:19 -07:00
rbalsleyMSFT b21d20d414 Updates changelog for version 2604.1 release
Documents the new features, improvements, and fixes included in the 2604.1 release. Highlights include the transition to Fluent UI styling alongside PowerShell 7.6, direct VHDX capturing for improved build times, and new UI controls for device naming and unattend file paths. Also notes critical fixes for Surface and Lenovo driver downloads, as well as image servicing reliability following recent Windows updates.
2026-04-16 16:55:04 -07:00
rbalsleyMSFT fedb45a419 Merge pull request #470 from rbalsleyMSFT/Fluent
Fluent
2026-04-16 16:37:08 -07:00
rbalsleyMSFT 15ca246abd Updates documentation screenshots and UI descriptions
Refreshes numerous screenshot image references across the documentation to reflect the latest UI changes. Updates text descriptions in several documents (Build, Prerequisites, Quickstart, Windows Settings) to align with recent features, such as the new Windows Media Source options, explicitly mentioning PowerShell 7.6+ requirements, and clarifying the Device Naming and Copy Unattend.xml behavior.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Prefers System SKU-based rule selection for Microsoft devices, falling back to legacy model-string matching when SKU data is unavailable.
2026-01-22 17:06:28 -08:00
rbalsleyMSFT cf9c605c34 Update release date and version in README 2026-01-13 11:26:28 -08:00
rbalsleyMSFT d6e7fd314f Update release date in README.md 2026-01-05 14:25:38 -08:00
rbalsleyMSFT a193c283f3 Revise release notes and download instructions
Updated release information and recommendations in the README.
2025-12-09 17:57:11 -08:00
rbalsleyMSFT 477d51fbbb Update release date in README.md 2025-10-01 17:42:35 -07:00
rbalsleyMSFT 3e3492bbab Update latest release information in README 2025-08-27 15:48:02 -07:00
rbalsleyMSFT 97e1ec2be4 Update UI Preview release information in README 2025-08-26 17:30:36 -07:00
rbalsleyMSFT 6e95ff92b1 fix: update script version to 2505.2 in BuildFFUVM.ps1 and ApplyFFU.ps1 for consistency across files 2025-08-12 17:47:25 -07:00
rbalsleyMSFT d4274d54d2 fix: Microsoft Update Catalog now includes the windows version information in the KB article title. This caused an issue where parsing the KB article was failing. Fixed the regex to accomdate this. 2025-08-12 17:33:26 -07:00
rbalsleyMSFT aee33a6a4b Update README.md 2025-08-01 20:30:35 -07:00
rbalsleyMSFT 25a0928195 Update README.md 2025-07-31 18:17:43 -07:00
115 changed files with 11351 additions and 2666 deletions
+180
View File
@@ -1,5 +1,185 @@
# Change Log
# 2604.1
## What's Changed
### Fluent style
With the release of PowerShell 7.6 finally going to GA, I was able to release the Fluent UI styling refresh. This will bring significant improvement to the look and feel of FFU Builder. Note that you will want to make sure you're running **PowerShell 7.6**, otherwise the listviews for Drivers and Applications will be missing the column headers.
### Build tab reorganization
The build tab sections now have expanders for the settings within. This should help with organization of each setting.
### Home page build and release status
The Home page of FFU Builder will now tell you what build you're on and if there's a new build along with the release notes for the new build. You can also see disk space and hyper-v status, as well as the latest Github repo discussions and a list of resources.
### Fixed an issue with Surface and Lenovo driver downloads
Microsoft changed the Surface driver download support page. FFU Builder now uses the [Microsoft Learn page for Surface driver downloads ](https://learn.microsoft.com/en-us/surface/manage-surface-driver-and-firmware-updates) that's designed for IT Admins. It's an easier table to parse rather than trying to parse the updated support page that FFU Builder used to use.
Fixed an issue where retrieving Lenovo models was failing.
### Removed Capture ISO
FFU Builder no longer relies on booting to WinPE to capture builds done via the VM. Instead, FFU Builder will now just capture the VHDX directly. This improves FFU build times tremendously and reduces the need for the VM Switch. The switch is still necessary for those that want to add internet connectivity during the FFU build process.
### Refresh Windows SKU after fallback SKU selection
Fixed an issue grabbing the correct Windows SKU when the user incorrectly chose a SKU that wasn't in the media and had to later be prompted for an available SKU. In this situation the SKU that was provided earlier was chosen, which caused a variety of issues.
### Removed registry-based FFU file naming
Removed registry-based FFU file naming and now rely on parameters provided at build time and the custom FFU naming template. This will remove the hard coded wait times that had to do with loading/unloading of the registry.
### Added a checkbox to enable network connectivity during VM build
Add a checkbox to enable network connectivity during the VM build. I'm fairly confident that the build process should be able to withstand any sysprep-related issues being connected to the internet. The checkbox is flagged as experimental. Give it a try and let me know if you notice any issues.
### Added UI controls for Device Naming
Device naming now has an expander in the Build tab that will expose a number of new options available. Rather than writing up a whole thing here in the release notes, the UI should be intuitive enough to explain how it works. The docs have also been updated. I spent a lot of time testing the changes with both legacy naming scenarios and if you make changes in the UI. If you see something that doesn't work, open a discussion or issue.
### Fixed Office installation issues on ARM64 VMs
I actually didn't fix anything, but rather removed a restriction that was put in place due to Office requiring internet access to install on ARM64. It seems the PG has fixed the issue requiring internet access and office will now install. However there's a caveat that it will prompt with a compatibility assistant popup. I think we can disable the compatibility assistant service to prevent the pop up from happening in the orchestrator. Will look into this in a future release.
### Auto-generate ComputerName in Unattend.xml
Now you can provide your own Unattend.xml without a ComputerName element and FFU Builder will add it if you've chosen to include a computer name. If there's a ComputerName element already in the file, ApplyFFU.ps1 will find it and modify it as per your naming choices.
### Add custom unattend.xml paths
There's a new expander for Unattend.xml options in the Build tab which includes paths for the x64 and arm64 unattend.xml files. This means that you can have your unattend files in any location instead of in the FFUDevelopment\unattend folder. This should make upgrades easier for those that have custom unattend.xml files and copying new releases would overwrite your customized unattend files.
### Fixed an issue where CUs wouldn't service after the March 31, 2026 OOB update (KB5086672) was installed on your host machine
The KB5086672 CU which is rolled into the April 14, 2026 update (KB5083769) caused an issue with Add-WindowsPackage. Add-WindowsPackage uses the DISM API to service a Windows image. The native dism.exe doesn't have this issue. To keep things consistent, FFU Builder will now use the dism.exe from the installed Windows ADK. While this version of dism might be older than what's on your machine, it should be consistent and not be impacted by future CUs.
# 2603.2
## What's Changed
Highly recommended to upgrade to this release due to potential SecureBoot issues
### Fixes SecureBoot-related boot issues on newly deployed FFUs
Fixes an issue where some devices may not boot after an FFU was applied if the FFU was built from a machine that had been updated to the Windows UEFI CA 2023 SecureBoot certificates.
Sometime at the start of this calendar year (Either January or February), a change was made in Windows to how BCDBoot functioned. If you took a CU from either of these months, BCDBoot will now check to see if the device has the 2011 or 2023 CA certificates. If 2023, the local BCDBoot will use the 2023 signed boot files when creating the System partition and these boot files will be deployed to the target system when the FFU is deployed. If the target machine hasn't updated to the 2023 certificates, boot will fail.
To fix this, FFU Builder now uses the version of BCDBoot from the ADK instead of the locally installed version. The version of BCDBoot from the 10.1.26100.2454 ADK December 2024 version (which is what FFU Builder considers the latest), will provide the boot files signed with the 2011 certs.
The version of [BCDBoot from the 10.1.28000.1 ADK](https://learn.microsoft.com/en-us/windows-hardware/get-started/what-s-new-in-kits-and-tools#bcd-boot) from November 2025 will default to using the 2023 certs as long as the machine supports the 2023 CA. This has been documented since this ADK was released. The behavior of this version of BCDBoot is what we're seeing now in devices that have been recently updated.
I suspect that when 26H2 is released, there will be a new ADK around that time and at that point we'll move to using that version of the ADK, which that version of BCDBoot will default to using the 2023 signed boot files and I suspect WinPE will probably default to doing the same. By then, hopefully, most in-market devices should have the 2023 certificates in UEFI and those that don't will need to get the certs or downgrade their ADK version to use the 2011 signed boot files.
### Fixes working directory handling
Creation and deletion of the dirty.txt marker file now use an explicit path based on $FFUDevelopmentPath, avoiding ambiguity and potential issues with relative paths.
**Full Changelog**: https://github.com/rbalsleyMSFT/FFU/compare/v2603.1...v2603.2
# 2603.1
## What's Changed
### UI Out of Preview
The UI is finally out of preview and the code from the UI branch has been pushed to main.
### Remove old MSU files before servicing
Fixes an issue where older cumulative update MSU files were mixed with newer files. In Windows 11 24H2, dism treats all MSU files in the same folder as possible update sources, causing servicing issues.
This will primarily be an issue for those of you who use ISO files that don't have the latest updates, or those of you who create your FFUs with the ESD media shortly after patch Tuesday.
### Adds OS-scoped update KB folders
The KB folder where the CU and .NET updates downloads to will now have sub-folders for each Windows version. You may notice that if selecting 25H2, that the KB sub-folder for Windows 11 will show 24H2, this is to keep consistent with Windows 11 LTSC 2024 and Windows Server 2025, which all use the same 24H2 source OS (25H2 is just an eKB, not a full OS Swap Windows release like 24H2 was).
### .NET Updates stored under their own dedicated KB folder
This is more so to keep things clean, rather than fixing any sort of technical issue
### Reworked Windows 10 LTSB\LTSC Cumulative Update Installation
Since Windows 10 is out of support, offline servicing Windows 10 LTSC builds that are still supported fails due to extended security update requirements. The workaround to this is to install the updates in audit mode. FFU Builder now creates an Apps\LTSCUpdate folder and copies the LCU for Windows 10 LTSC builds still in support to the folder and installs the update in audit mode.
### Fixes an issue with Update ADK failing for non-English languages
Fixed a bug where updating the ADK would fail on non-English installations of Windows due to an assumption that the add/remove programs display information would be in English. Update ADK should correctly identify if the latest release of the ADK is installed and work as expected.
### Added dependency validation when selecting Build USB drive and Copy Drivers
Fixed an issue where a build would begin even though Copy Drivers to USB was set to true but no USB was inserted. FFU Builder will now check before the build gets started and inform the user if no USB drive is inserted.
### Normalizes Windows LTSC release versions to handle driver downloads
Driver downloads would fail if you were building certain LTSC releases due to FFU Builder incorrectly using the LTSC release year instead of the base Windows client version information. When build LTSC FFUs, the drivers should now download as expected.
### Scopes select-all to visible filtered list items in drivers listview
When filtering the drivers listview and selecting all driver models using the select all header checkbox, the select all behavior was selecting everything, even the hidden models in the list. If then selecting Download Selected, FFU Builder would download all models, even hidden ones. This now fixes that issue to only select all visible models and download those selected models.
### Retain downloaded ESD files
FFU Builder will now allow you to retain a downloaded ESD file. There's a new option on the Build tab to Remove Downloaded ESD File(s) which is checked by default to keep with the previous behavior. The intent here is to prevent from having to re-download the ESD file every time you're doing a build. This gives you another option along with Allow VHDX Caching to reduce the need of redownloading media.
### Reduce the size of cached VHDX files
Added some code to reduce the size of the cached VHDX files.
### Include disk size in VHDX cache validation
Prevents reusing cached images when the requested disk size changes. Ensures the disk size property is properly saved and verified against existing cache items to maintain configuration accuracy. This makes it so that if you create a new build with a larger disk size and have Allow VHDX caching selected, it won't use a cached VHDX with a smaller size.
### Enhances file backup and cleanup for cancelled builds
Improves the current-run cleanup mechanism by tracking file downloads explicitly. This ensures that files downloaded via BITS or preserving older timestamps are correctly identified and removed during cleanup. Extends the run manifest schema to support file backups, allowing for safe restoration of pre-existing scripts and configuration files modified during a run. Additional cleanup logic now correctly prunes residual empty directories after tracked files are removed.
### Fixed an issue with arm64 ESD downloads
With the change to how ESD downloads work with 25H2 and the Media Creation Tool, arm64 was broken. This was fixed.
# 2602.1 UI Preview
## What's Changed
### Improved Automatic Matching for Surface devices
To keep inline with HP, Dell, and Lenovo, added support for Surface devices to leverage the SystemSKU values from WMI when doing automatic driver matching during deployment. Check https://github.com/rbalsleyMSFT/FFU/pull/394 for more information. Long story short, there's a new `SurfaceDriverIndex.json` file that is created when getting the models which gathers the WMI information per model as well as the download links for each model. This info is used to generate the DriverMapping.json file for Surface to allow for better matching.
There'll be deeper documentation on the new [docs site](https://rbalsleymsft.github.io/FFU/)
### Improved driver injection error handling when deploying drivers via USB
When drivers failed to be added from the USB drive during deployment, ApplyFFU.ps1 would fail with an error message and the deployment wouldn't complete. ApplyFFU.ps1 will now continue on failure and log the error and capture the setupapi.offline.log to the USB drive for troubleshooting if needed.
### Fixed an issue with Windows image index for non-English media
In some cases non-English media would cause the end-user to have to select which Windows SKU to select due to parsing the image name output and assuming the output was in English. BuildFFUVM.ps1 will now parse the edition metadata for each index. This should improve the experience for those that are creating FFUs from non-English media.
### Run builds in separate pwsh process instead of background jobs
In https://github.com/rbalsleyMSFT/FFU/pull/393, by changing the deprecated Get-WmiObject calls to Get-CimInstance, this actually broke console output. Still don't fully understand why GWMI was allowing background jobs to output console output to the calling pwsh Window but get-ciminstance wouldn't (WinRM, PowerShell Remoting, etc), but this required changing to running the build in a separate pwsh process. Between this and https://github.com/rbalsleyMSFT/FFU/pull/393, this should fix those that might build their FFUs on Servers and still expect to see console output.
### Fixed an issue with USB drive selection for same-model USB drives
When using the UI and selecting specific USB drives to create, the UI would allow you to select multiple of the same name, but would only create one of the drives. You should now be able to multi-select multiple USB drives with the same name and they should build as expected.
### Created new docs site
[FFU Builder docs](https://rbalsleymsft.github.io/FFU/) are now available! I'm still working on adding more documentation, but the layout of the site, the prereqs, quick start, and UI overview are done. I still have some stuff to migrate from the old docx file and some deep dive stuff to write up (Drivers, Apps, FAQs, Troubleshooting, etc). It should work well on both mobile and desktop. It also has built-in search capabilities to make it easy to find what you're interested in.
## New Contributors
* @JGehl99 made their first contribution in https://github.com/rbalsleyMSFT/FFU/pull/393
**Full Changelog**: https://github.com/rbalsleyMSFT/FFU/compare/v2601.1Preview...v2602.1Preview
# 2601.1 UI Preview
## What's Changed
@@ -1,3 +1,11 @@
# Allow Orchestrator.ps1 to override the app list file paths while preserving legacy defaults.
param(
[Parameter()]
[string]$wingetAppsJsonFile = (Join-Path -Path $PSScriptRoot -ChildPath "WinGetWin32Apps.json"),
[Parameter()]
[string]$userAppsJsonFile = (Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath "UserAppList.json")
)
function Invoke-Process {
[CmdletBinding(SupportsShouldProcess)]
param
@@ -247,11 +255,6 @@ function Install-Applications {
}
}
# Define paths for the JSON files
$wingetAppsJsonFile = "$PSScriptRoot\WinGetWin32Apps.json"
# Look for UserAppList.json one directory level up from the script's location. This keeps the user specific json files (AppList.json and UserAppList.json in the Apps dir)
$userAppsJsonFile = Join-Path -Path (Split-Path -Parent $PSScriptRoot) -ChildPath "UserAppList.json"
# Initialize empty arrays for apps from each source
$wingetApps = @()
$userApps = @()
@@ -286,9 +289,9 @@ if ($wingetApps.Count -gt 0) {
Install-Applications -apps $wingetApps
}
# Read the UserAppList.json file if it exists
# Read the configured BYO app list file if it exists
if (Test-Path -Path $userAppsJsonFile) {
Write-Host "Processing UserAppList.json..."
Write-Host "Processing $(Split-Path -Path $userAppsJsonFile -Leaf)..."
try {
$userContent = Get-Content -Path $userAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json
if ($userContent -is [array]) {
@@ -296,19 +299,19 @@ if (Test-Path -Path $userAppsJsonFile) {
Write-Host "Found $(($userApps | Measure-Object).Count) user-defined apps."
}
elseif ($userContent) {
$userApps = @($userContent) # Ensure it's an array
$userApps = @($userContent)
Write-Host "Found 1 user-defined app."
}
else {
Write-Host "UserAppList.json is empty or invalid."
Write-Host "$(Split-Path -Path $userAppsJsonFile -Leaf) is empty or invalid."
}
}
catch {
Write-Error "Failed to read or parse UserAppList.json file: $_"
Write-Error "Failed to read or parse BYO app list file '$userAppsJsonFile': $_"
}
}
else {
Write-Host "UserAppList.json file not found. Skipping."
Write-Host "BYO app list file not found at $userAppsJsonFile. Skipping."
}
# Install User apps if any were found
@@ -28,8 +28,26 @@ Write-Host "---------------------------------------------------" -ForegroundColo
# 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
# Resolve the configured BYO app list path for runtime orchestration.
$appInstallConfigPath = Join-Path -Path $scriptPath -ChildPath "AppInstallConfig.json"
$userAppsJsonFile = Join-Path -Path (Split-Path -Parent $scriptPath) -ChildPath "UserAppList.json"
if (Test-Path -Path $appInstallConfigPath) {
try {
$appInstallConfig = Get-Content -Path $appInstallConfigPath -Raw | ConvertFrom-Json
if ($null -ne $appInstallConfig -and $appInstallConfig.PSObject.Properties.Match('UserAppListPath').Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($appInstallConfig.UserAppListPath)) {
$userAppsJsonFile = $appInstallConfig.UserAppListPath
Write-Host "Using BYO app list path from AppInstallConfig.json: $userAppsJsonFile"
}
}
catch {
Write-Host "Failed to parse AppInstallConfig.json. Falling back to default BYO app list path."
}
}
# Define the list of scripts to run
$scriptList = @(
"Install-LTSCUpdate.ps1",
"Update-Defender.ps1",
"Install-Office.ps1",
"Update-MSRT.ps1",
@@ -50,7 +68,6 @@ foreach ($script in $scriptList) {
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
}
@@ -68,9 +85,14 @@ foreach ($script in $scriptList) {
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
Write-Host " Running script: $script " -ForegroundColor Yellow
Write-Host "---------------------------------------------------" -ForegroundColor Yellow
# Run script and wait for it to finish
# Run script and wait for it to finish.
if ($script -eq "Install-Win32Apps.ps1") {
& $scriptFile -UserAppsJsonFile $userAppsJsonFile
}
else {
& $scriptFile
}
}
}
# Invoke-AppsScript.ps1 if it exists and AppsScriptVariables.json is present
File diff suppressed because it is too large Load Diff
+248 -106
View File
@@ -45,7 +45,9 @@ $script:uiState = [PSCustomObject]@{
logData = $null;
logStreamReader = $null;
pollTimer = $null;
lastConfigFilePath = $null
currentBuildProcess = $null;
lastConfigFilePath = $null;
loadedDeviceNamingMode = $null
};
Flags = @{
installAppsForcedByUpdates = $false;
@@ -54,7 +56,10 @@ $script:uiState = [PSCustomObject]@{
lastSortProperty = $null;
lastSortAscending = $true;
isBuilding = $false;
isCleanupRunning = $false
isCleanupRunning = $false;
isFluentSupported = $false;
deviceNamingModeWasExplicitlyChanged = $false;
suppressDeviceNamingChangeTracking = $false
};
Defaults = @{};
LogFilePath = "$FFUDevelopmentPath\FFUDevelopment_UI.log"
@@ -119,6 +124,9 @@ $reader = New-Object System.IO.StringReader($xamlString)
$xmlReader = [System.Xml.XmlReader]::Create($reader)
$window = [Windows.Markup.XamlReader]::Load($xmlReader)
# Apply Fluent theme before the window renders (requires PowerShell 7.5+ / .NET 9+)
Initialize-FluentTheme -Window $window -ThemeMode "System" -State $script:uiState
$window.Add_Loaded({
# Pass the state object to all initialization functions
$script:uiState.Window = $window
@@ -128,6 +136,9 @@ $window.Add_Loaded({
Initialize-DynamicUIElements -State $script:uiState
Register-EventHandlers -State $script:uiState
# Populate the Home page build and release status after the window initializes
Start-HomeStatusRefresh -State $script:uiState
# Attempt automatic load of previous environment (silent)
try {
Invoke-AutoLoadPreviousEnvironment -State $script:uiState
@@ -148,7 +159,7 @@ $script:uiState.Controls.btnRun.Add_Click({
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."
WriteLog "Cancel requested by user. Stopping background build process."
# Stop the timer
if ($null -ne $script:uiState.Data.pollTimer) {
@@ -163,27 +174,12 @@ $script:uiState.Controls.btnRun.Add_Click({
$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)"
}
# Stop the running build process
$processToStop = $script:uiState.Data.currentBuildProcess
$script:uiState.Data.currentBuildProcess = $null
# 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
if ($null -ne $processToStop) {
# Recursively terminate the build process and any children (DISM, setup tools, etc.)
function Stop-ProcessTree {
param([int]$parentPid)
$children = Get-CimInstance Win32_Process -Filter "ParentProcessId=$parentPid" -ErrorAction SilentlyContinue
@@ -192,11 +188,14 @@ $script:uiState.Controls.btnRun.Add_Click({
}
try { Stop-Process -Id $parentPid -Force -ErrorAction SilentlyContinue } catch {}
}
Stop-ProcessTree -parentPid $jobProcId
}
try {
Stop-ProcessTree -parentPid $processToStop.Id
WriteLog "Background build process stopped (PID: $($processToStop.Id))."
}
catch {
WriteLog "Error terminating job process tree: $($_.Exception.Message)"
WriteLog "Error terminating build process tree: $($_.Exception.Message)"
}
}
# Safety net: kill any active DISM capture still running
@@ -242,15 +241,6 @@ $script:uiState.Controls.btnRun.Add_Click({
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)) {
@@ -289,13 +279,40 @@ $script:uiState.Controls.btnRun.Add_Click({
CleanupCurrentRunDownloads = $removeCurrentRunToo
}
$cleanupScriptBlock = {
param($buildParams, $PSScriptRoot)
& "$PSScriptRoot\BuildFFUVM.ps1" @buildParams
# Start cleanup in a separate pwsh process so the UI stays responsive
$pwshPath = Join-Path -Path $PSHOME -ChildPath 'pwsh.exe'
if (-not (Test-Path -Path $pwshPath)) {
$pwshPath = 'pwsh'
}
# Start cleanup job
$script:uiState.Data.currentBuildJob = Start-Job -ScriptBlock $cleanupScriptBlock -ArgumentList @($cleanupParams, $PSScriptRoot)
$cleanupScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'BuildFFUVM.ps1'
# Build argument list for cleanup.
# -Cleanup is a [switch] in BuildFFUVM.ps1, so do not pass a value after it.
# Use -Param:$true/$false syntax for boolean parameters to avoid argument transformation errors.
$cleanupArgs = @(
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-File', $cleanupScriptPath,
'-ConfigFile', $cleanupParams.ConfigFile,
'-Cleanup',
"-RemoveApps:$($cleanupParams.RemoveApps)",
"-RemoveUpdates:$($cleanupParams.RemoveUpdates)",
"-CleanupDrivers:$($cleanupParams.CleanupDrivers)",
"-CleanupCurrentRunDownloads:$($cleanupParams.CleanupCurrentRunDownloads)"
)
$startCleanupParams = @{
FilePath = $pwshPath
ArgumentList = $cleanupArgs
WorkingDirectory = $ffuDevPath
PassThru = $true
}
if ($Host.Name -eq 'ConsoleHost') {
$startCleanupParams['NoNewWindow'] = $true
}
$script:uiState.Data.currentBuildProcess = Start-Process @startCleanupParams
# Wait for log file to appear (or open immediately if it exists)
$logWaitTimeout = 60
@@ -315,14 +332,14 @@ $script:uiState.Controls.btnRun.Add_Click({
WriteLog "Warning: Main log file not found at $mainLogPath after waiting. Monitor tab will not update during cleanup."
}
# Create a timer to poll the cleanup job
# Create a timer to poll the cleanup process
$script:uiState.Data.pollTimer = New-Object System.Windows.Threading.DispatcherTimer
$script:uiState.Data.pollTimer.Interval = [TimeSpan]::FromSeconds(1)
$script:uiState.Flags.isCleanupRunning = $true
$script:uiState.Data.pollTimer.Add_Tick({
param($sender, $e)
$currentJob = $script:uiState.Data.currentBuildJob
$currentProcess = $script:uiState.Data.currentBuildProcess
# Read new lines from log
if ($null -ne $script:uiState.Data.logStreamReader) {
@@ -335,13 +352,13 @@ $script:uiState.Controls.btnRun.Add_Click({
}
}
if ($null -eq $currentJob -or $null -eq $script:uiState.Data.pollTimer) {
if ($null -eq $currentProcess -or $null -eq $script:uiState.Data.pollTimer) {
if ($null -ne $sender) { $sender.Stop() }
$script:uiState.Data.pollTimer = $null
return
}
if ($currentJob.State -in 'Completed', 'Failed', 'Stopped') {
if ($currentProcess.HasExited) {
if ($null -ne $sender) { $sender.Stop() }
$script:uiState.Data.pollTimer = $null
@@ -364,10 +381,8 @@ $script:uiState.Controls.btnRun.Add_Click({
$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
# Clear cleanup process state
$script:uiState.Data.currentBuildProcess = $null
# Reset flags and button
$script:uiState.Flags.isCleanupRunning = $false
@@ -385,8 +400,11 @@ $script:uiState.Controls.btnRun.Add_Click({
# Not currently building: start a new build
$btnRun.IsEnabled = $false
# Switch to Monitor Tab
$script:uiState.Controls.MainTabControl.SelectedItem = $script:uiState.Controls.MonitorTab
# Switch to Monitor page via navigation
$monitorIndex = 8 # Monitor is the 9th item (index 8) in the navigation list
if ($null -ne $script:uiState.Controls.lstNavigation) {
$script:uiState.Controls.lstNavigation.SelectedIndex = $monitorIndex
}
# Clear previous log data and reset autoscroll
if ($null -ne $script:uiState.Data.logData) {
@@ -410,6 +428,123 @@ $script:uiState.Controls.btnRun.Add_Click({
return
}
if ($config.EnableVMNetworking -and $config.InstallApps -and [string]::IsNullOrWhiteSpace([string]$config.VMSwitchName)) {
[System.Windows.MessageBox]::Show("Select or enter a VM Switch Name before enabling VM networking.", "VM Switch Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: VM switch required for experimental networking."
return
}
if ($config.CopyUnattend -and $config.InjectUnattend) {
[System.Windows.MessageBox]::Show("Copy Unattend.xml and Inject Unattend.xml cannot both be selected. Choose only one unattend delivery method.", "Unattend Selection Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: choose only one unattend delivery method."
return
}
if ($config.CopyUnattend -or $config.InjectUnattend) {
$selectedUnattendArch = if ($config.WindowsArch -ieq 'arm64') { 'arm64' } else { 'x64' }
$selectedUnattendSourcePath = if ($selectedUnattendArch -eq 'arm64') {
[string]$config.UnattendArm64FilePath
}
else {
[string]$config.UnattendX64FilePath
}
if ([string]::IsNullOrWhiteSpace($selectedUnattendSourcePath)) {
[System.Windows.MessageBox]::Show("Select a valid $selectedUnattendArch unattend XML file before using Copy Unattend.xml or Inject Unattend.xml.", "Unattend File Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: unattend file path required."
return
}
if (-not (Test-Path -Path $selectedUnattendSourcePath -PathType Leaf)) {
[System.Windows.MessageBox]::Show("The selected $selectedUnattendArch unattend XML file was not found:`n$selectedUnattendSourcePath", "Unattend File Missing", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: unattend file missing."
return
}
$selectedUnattendFileInfo = Get-Item -Path $selectedUnattendSourcePath -ErrorAction SilentlyContinue
if (($null -eq $selectedUnattendFileInfo) -or ($selectedUnattendFileInfo.Length -le 0)) {
[System.Windows.MessageBox]::Show("The selected $selectedUnattendArch unattend XML file is empty:`n$selectedUnattendSourcePath", "Unattend File Empty", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: unattend file empty."
return
}
}
if ($config.DeviceNamingMode -eq 'Prompt') {
if (-not $config.CopyUnattend) {
[System.Windows.MessageBox]::Show("Select Copy Unattend.xml before using 'Prompt for Device Name'.", "Copy Unattend Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: prompt naming requires Copy Unattend.xml."
return
}
}
elseif ($config.DeviceNamingMode -eq 'Template') {
if ([string]::IsNullOrWhiteSpace([string]$config.DeviceNameTemplate)) {
[System.Windows.MessageBox]::Show("Specify a device name before using 'Specify Device Name'.", "Device Name Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: device name required."
return
}
if (-not ($config.CopyUnattend -or $config.InjectUnattend)) {
[System.Windows.MessageBox]::Show("Select Copy Unattend.xml or Inject Unattend.xml before using 'Specify Device Name'.", "Unattend Selection Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: unattend delivery method required for device naming."
return
}
$templateWithoutSupportedVariables = ([string]$config.DeviceNameTemplate) -replace '(?i)%serial%', ''
if ($templateWithoutSupportedVariables -match '%') {
[System.Windows.MessageBox]::Show("Only the %serial% device name variable is supported.", "Unsupported Device Name Variable", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: unsupported device name variable."
return
}
if ($config.InjectUnattend -and (-not $config.CopyUnattend) -and ([string]$config.DeviceNameTemplate -match '(?i)%serial%')) {
[System.Windows.MessageBox]::Show("The %serial% device name variable is only supported when Copy Unattend.xml is selected.", "Unsupported Inject Unattend Setting", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: %serial% requires Copy Unattend.xml."
return
}
}
elseif ($config.DeviceNamingMode -eq 'Prefixes') {
if (-not $config.CopyUnattend) {
[System.Windows.MessageBox]::Show("Select Copy Unattend.xml before using 'Specify a list of Prefixes'.", "Copy Unattend Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: prefixes require Copy Unattend.xml."
return
}
$hasSavedPrefixesPath = -not [string]::IsNullOrWhiteSpace([string]$config.DeviceNamePrefixesPath) -and (Test-Path -Path $config.DeviceNamePrefixesPath -PathType Leaf)
if ((($null -eq $config.DeviceNamePrefixes) -or ($config.DeviceNamePrefixes.Count -eq 0)) -and -not $hasSavedPrefixesPath) {
[System.Windows.MessageBox]::Show("Enter at least one prefix or choose a valid prefixes file before using 'Specify a list of Prefixes'.", "Prefixes Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: prefixes required."
return
}
}
elseif ($config.DeviceNamingMode -eq 'SerialComputerNames') {
if (-not $config.CopyUnattend) {
[System.Windows.MessageBox]::Show("Select Copy Unattend.xml before using 'Specify Serial to Device Name Mapping'.", "Copy Unattend Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: serial computer-name mapping requires Copy Unattend.xml."
return
}
$hasSavedSerialComputerNamesPath = -not [string]::IsNullOrWhiteSpace([string]$config.DeviceNameSerialComputerNamesPath) -and (Test-Path -Path $config.DeviceNameSerialComputerNamesPath -PathType Leaf)
if ((($null -eq $config.DeviceNameSerialComputerNames) -or ($config.DeviceNameSerialComputerNames.Count -eq 0)) -and -not $hasSavedSerialComputerNamesPath) {
[System.Windows.MessageBox]::Show("Enter CSV content or choose a valid Serial Computer Names CSV Mapping File Path before using 'Specify Serial to Device Name Mapping'.", "Serial Mapping Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: serial computer-name mapping required."
return
}
}
$configFilePath = Join-Path $config.FFUDevelopmentPath "\config\FFUConfig.json"
# Sort top-level keys alphabetically for consistent output
$sortedConfig = [ordered]@{}
@@ -425,33 +560,45 @@ $script:uiState.Controls.btnRun.Add_Click({
$txtStatus.Text = "Executing BuildFFUVM.ps1 in the background..."
WriteLog "Executing BuildFFUVM.ps1 in the background..."
# Prepare parameters for splatting
$buildParams = @{
ConfigFile = $configFilePath
# Start BuildFFUVM.ps1 in a separate pwsh process.
# This keeps the UI responsive and restores console interaction (Write-Host / Read-Host) when available.
$pwshPath = Join-Path -Path $PSHOME -ChildPath 'pwsh.exe'
if (-not (Test-Path -Path $pwshPath)) {
$pwshPath = 'pwsh'
}
$buildScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'BuildFFUVM.ps1'
$pwshArgs = @(
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-File', $buildScriptPath,
'-ConfigFile', $configFilePath
)
if ($config.Verbose) {
$buildParams['Verbose'] = $true
$pwshArgs += '-Verbose'
}
# 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.
# Delete the old log file before starting the build process 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)
$startBuildParams = @{
FilePath = $pwshPath
ArgumentList = $pwshArgs
WorkingDirectory = $config.FFUDevelopmentPath
PassThru = $true
}
if ($Host.Name -eq 'ConsoleHost') {
$startBuildParams['NoNewWindow'] = $true
}
# Wait for the new log file to be created by the background job.
# Start the build process and store it in the shared state object
$script:uiState.Data.currentBuildProcess = Start-Process @startBuildParams
# Wait for the new log file to be created by the background process.
$logWaitTimeout = 15 # seconds
$watch = [System.Diagnostics.Stopwatch]::StartNew()
while (-not (Test-Path $mainLogPath) -and $watch.Elapsed.TotalSeconds -lt $logWaitTimeout) {
@@ -476,7 +623,7 @@ $script:uiState.Controls.btnRun.Add_Click({
$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
$currentProcess = $script:uiState.Data.currentBuildProcess
# Read from log stream
if ($null -ne $script:uiState.Data.logStreamReader) {
@@ -500,8 +647,8 @@ $script:uiState.Controls.btnRun.Add_Click({
}
}
# If job is somehow null or the timer has been nulled out, stop the timer
if ($null -eq $currentJob -or $null -eq $script:uiState.Data.pollTimer) {
# If process is somehow null or the timer has been nulled out, stop the timer
if ($null -eq $currentProcess -or $null -eq $script:uiState.Data.pollTimer) {
if ($null -ne $sender) {
$sender.Stop()
}
@@ -509,8 +656,8 @@ $script:uiState.Controls.btnRun.Add_Click({
return
}
# Check if the job has reached a terminal state
if ($currentJob.State -in 'Completed', 'Failed', 'Stopped') {
# Check if the build process has exited
if ($currentProcess.HasExited) {
# Stop the timer, we're done polling
if ($null -ne $sender) {
$sender.Stop()
@@ -546,42 +693,26 @@ $script:uiState.Controls.btnRun.Add_Click({
$script:uiState.Data.logStreamReader = $null
}
# Determine final status based on job result and whether cleanup was running (should be false here)
$exitCode = $currentProcess.ExitCode
# Determine final status based on process exit code
$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."
}
if ($exitCode -ne 0) {
$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
WriteLog "BuildFFUVM.ps1 process failed with exit code: $exitCode"
[System.Windows.MessageBox]::Show("The build process failed. Please check the $FFUDevelopmentPath\FFUDevelopment.log file for details.`n`nExit code: $exitCode", "Build Error", "OK", "Error") | Out-Null
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
}
else {
WriteLog "BuildFFUVM.ps1 job completed successfully."
WriteLog "BuildFFUVM.ps1 process 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
# Clear process state
$script:uiState.Data.currentBuildProcess = $null
# Reset button and flags for next run
$script:uiState.Flags.isBuilding = $false
@@ -640,9 +771,9 @@ $window.Add_SourceInitialized({
# 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 any running build process if the window is closed
if ($null -ne $script:uiState.Data.currentBuildProcess) {
WriteLog "UI closing, stopping background build process."
# Stop the timer
if ($null -ne $script:uiState.Data.pollTimer) {
@@ -657,17 +788,28 @@ $window.Add_Closed({
$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
$processToStop = $script:uiState.Data.currentBuildProcess
$script:uiState.Data.currentBuildProcess = $null
try {
Stop-Job -Job $jobToStop
Remove-Job -Job $jobToStop
WriteLog "Background job stopped and removed."
# Terminate the build 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 {}
}
if ($null -ne $processToStop -and -not $processToStop.HasExited) {
Stop-ProcessTree -parentPid $processToStop.Id
}
WriteLog "Background process stopped."
}
catch {
WriteLog "Error stopping or removing background job: $($_.Exception.Message)"
WriteLog "Error stopping background build process: $($_.Exception.Message)"
}
}
File diff suppressed because it is too large Load Diff
+3 -42
View File
@@ -3,11 +3,8 @@ param (
[string]$adkPath = 'C:\Program Files (x86)\Windows Kits\10\',
[string]$WindowsArch = 'x64',
[bool]$CopyPEDrivers = $false,
[string]$CaptureISO = "$PSScriptRoot\WinPE_FFU_Capture_x64.iso",
[string]$DeployISO = "$PSScriptRoot\WinPE_FFU_Deploy_x64.iso",
[string]$LogFile = "$PSScriptRoot\Create-PEMedia.log",
[bool]$Capture,
[bool]$Deploy = $true
[string]$LogFile = "$PSScriptRoot\Create-PEMedia.log"
)
function WriteLog($LogText) {
@@ -77,12 +74,7 @@ function Invoke-Process {
}
function New-PEMedia {
param (
[Parameter()]
[bool]$Capture,
[Parameter()]
[bool]$Deploy
)
param ()
#Need to use the Demployment and Imaging tools environment to create winPE media
$DandIEnv = "$adkPath`Assessment and Deployment Kit\Deployment Tools\DandISetEnv.bat"
$WinPEFFUPath = "$FFUDevelopmentPath\WinPE"
@@ -135,17 +127,6 @@ function New-PEMedia {
Add-WindowsPackage -Path "$WinPEFFUPath\mount" -PackagePath $PackagePath | Out-Null
WriteLog "Adding package complete"
}
If ($Capture) {
WriteLog "Copying $FFUDevelopmentPath\WinPECaptureFFUFiles\* to WinPE capture media"
Copy-Item -Path "$FFUDevelopmentPath\WinPECaptureFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | out-null
WriteLog "Copy complete"
#Remove Bootfix.bin - for BIOS systems, shouldn't be needed, but doesn't hurt to remove for our purposes
#Remove-Item -Path "$WinPEFFUPath\media\boot\bootfix.bin" -Force | Out-null
# $WinPEISOName = 'WinPE_FFU_Capture.iso'
$WinPEISOFile = $CaptureISO
# $Capture = $false
}
If ($Deploy) {
WriteLog "Copying $FFUDevelopmentPath\WinPEDeployFFUFiles\* to WinPE deploy media"
Copy-Item -Path "$FFUDevelopmentPath\WinPEDeployFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | Out-Null
WriteLog 'Copy complete'
@@ -160,11 +141,7 @@ function New-PEMedia {
}
WriteLog "Adding drivers complete"
}
# $WinPEISOName = 'WinPE_FFU_Deploy.iso'
$WinPEISOFile = $DeployISO
# $Deploy = $false
}
WriteLog 'Dismounting WinPE media'
Dismount-WindowsImage -Path "$WinPEFFUPath\mount" -Save | Out-Null
WriteLog 'Dismount complete'
@@ -179,31 +156,15 @@ function New-PEMedia {
WriteLog "Creating WinPE ISO at $WinPEISOFile"
# & "$OSCDIMG" -m -o -u2 -udfver102 -bootdata:2`#p0,e,b$OSCDIMGPath\etfsboot.com`#pEF,e,b$OSCDIMGPath\Efisys_noprompt.bin $WinPEFFUPath\media $FFUDevelopmentPath\$WinPEISOName | Out-null
if($WindowsArch -eq 'x64'){
if($Capture){
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys_noprompt.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
}
if($Deploy){
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
}
}
elseif($WindowsArch -eq 'arm64'){
if($Capture){
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys_noprompt.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
}
if($Deploy){
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
}
}
Invoke-Process $OSCDIMG $OSCDIMGArgs
WriteLog "ISO created successfully"
WriteLog "Cleaning up $WinPEFFUPath"
Remove-Item -Path "$WinPEFFUPath" -Recurse -Force
WriteLog 'Cleanup complete'
}
if($Capture){
New-PEMedia -Capture $Capture
}
if($Deploy){
New-PEMedia -Deploy $Deploy
}
New-PEMedia
+3 -5
View File
@@ -85,12 +85,10 @@ graph TD
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"];
BB --> 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"];
BF --> BG[Host optimizes and remounts VHDX];
BG --> BH["DISM captures FFU directly from host-mounted VHDX"];
end
subgraph "Direct VHDX Capture"
@@ -6,28 +6,23 @@ function Invoke-FFUPostBuildCleanup {
[string]$AppsPath,
[string]$DriversPath,
[string]$FFUCapturePath,
[string]$CaptureISOPath,
[string]$DeployISOPath,
[string]$AppsISOPath,
[string]$KBPath,
[bool]$RemoveCaptureISO = $false,
[bool]$RemoveDeployISO = $false,
[bool]$RemoveAppsISO = $false,
[bool]$RemoveDrivers = $false,
[bool]$RemoveFFU = $false,
[bool]$RemoveApps = $false,
[bool]$RemoveUpdates = $false
[bool]$RemoveUpdates = $false,
[bool]$RemoveDownloadedESD = $false
)
$originalProgressPreference = $ProgressPreference
$ProgressPreference = 'SilentlyContinue'
try {
WriteLog "CommonCleanup: Starting cleanup (CaptureISO=$RemoveCaptureISO DeployISO=$RemoveDeployISO AppsISO=$RemoveAppsISO Drivers=$RemoveDrivers FFU=$RemoveFFU Apps=$RemoveApps Updates=$RemoveUpdates KBPath=$KBPath)."
WriteLog "CommonCleanup: Starting cleanup (DeployISO=$RemoveDeployISO AppsISO=$RemoveAppsISO Drivers=$RemoveDrivers FFU=$RemoveFFU Apps=$RemoveApps Updates=$RemoveUpdates RemoveDownloadedESD=$RemoveDownloadedESD KBPath=$KBPath)."
# Primary ISO paths (new naming/location)
if ($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)" }
@@ -38,11 +33,6 @@ function Invoke-FFUPostBuildCleanup {
}
# 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)" }
@@ -95,6 +85,15 @@ function Invoke-FFUPostBuildCleanup {
}
}
# Always remove LTSC update staging folder (out-of-band cleanup exception)
if (-not [string]::IsNullOrWhiteSpace($AppsPath) -and (Test-Path -LiteralPath $AppsPath -PathType Container)) {
$ltscUpdateFolder = Join-Path $AppsPath 'LTSCUpdate'
if (Test-Path -LiteralPath $ltscUpdateFolder) {
WriteLog "CommonCleanup: Removing LTSC update staging folder $ltscUpdateFolder"
try { Remove-Item -LiteralPath $ltscUpdateFolder -Recurse -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $ltscUpdateFolder : $($_.Exception.Message)" }
}
}
if ($RemoveUpdates) {
if (-not [string]::IsNullOrWhiteSpace($AppsPath) -and (Test-Path -LiteralPath $AppsPath)) {
# Remove per-run app update payloads stored under Apps
@@ -114,6 +113,20 @@ function Invoke-FFUPostBuildCleanup {
}
}
# Remove downloaded ESD files from the root path when requested
if ($RemoveDownloadedESD -and -not [string]::IsNullOrWhiteSpace($RootPath) -and (Test-Path -LiteralPath $RootPath -PathType Container)) {
WriteLog "CommonCleanup: Removing downloaded ESD files in $RootPath"
Get-ChildItem -LiteralPath $RootPath -Filter *.esd -File -ErrorAction SilentlyContinue | ForEach-Object {
try {
WriteLog "CommonCleanup: Removing ESD $($_.FullName)"
Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop
}
catch {
WriteLog "CommonCleanup: Failed removing ESD $($_.FullName) : $($_.Exception.Message)"
}
}
}
WriteLog "CommonCleanup: Completed."
}
catch {
@@ -157,6 +157,79 @@ function Invoke-Process {
return $cmd
}
function Get-RunManifestPathForDownloadTarget {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Destination
)
try {
$currentPath = Split-Path -Path $Destination -Parent
if ([string]::IsNullOrWhiteSpace($currentPath)) { return $null }
while ($currentPath) {
$manifestPath = Join-Path -Path $currentPath -ChildPath '.session\currentRun.json'
if (Test-Path -LiteralPath $manifestPath -PathType Leaf) {
return $manifestPath
}
$parentPath = Split-Path -Path $currentPath -Parent
if ([string]::IsNullOrWhiteSpace($parentPath) -or $parentPath -eq $currentPath) {
break
}
$currentPath = $parentPath
}
}
catch {
WriteLog "Get-RunManifestPathForDownloadTarget failed for '$Destination': $($_.Exception.Message)"
}
return $null
}
function Register-CurrentRunDownloadTarget {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Destination
)
if ([string]::IsNullOrWhiteSpace($Destination)) { return }
$manifestPath = Get-RunManifestPathForDownloadTarget -Destination $Destination
if ([string]::IsNullOrWhiteSpace($manifestPath)) { return }
$mutexName = 'Global\FFUCurrentRunDownloadTargetsMutex'
$mutex = New-Object System.Threading.Mutex($false, $mutexName)
try {
$null = $mutex.WaitOne()
$manifest = Get-Content -LiteralPath $manifestPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
if ($null -eq $manifest) { return }
if ($null -eq $manifest.PSObject.Properties['DownloadTargets']) {
Add-Member -InputObject $manifest -MemberType NoteProperty -Name DownloadTargets -Value @()
}
$downloadTargets = @($manifest.DownloadTargets)
if ($Destination -notin $downloadTargets) {
$downloadTargets += $Destination
$manifest.DownloadTargets = $downloadTargets
$manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $manifestPath -Encoding UTF8
WriteLog "Registered current-run download target: $Destination"
}
}
catch {
WriteLog "Register-CurrentRunDownloadTarget failed for '$Destination': $($_.Exception.Message)"
}
finally {
try { $mutex.ReleaseMutex() | Out-Null } catch {}
$mutex.Dispose()
}
}
# Function to download a file using BITS with retry and error handling
function Start-BitsTransferWithRetry {
param (
@@ -181,6 +254,10 @@ function Start-BitsTransferWithRetry {
}
}
# Register destination so cancel cleanup can remove this run's downloaded files
# even when file timestamps are inherited from the source.
Register-CurrentRunDownloadTarget -Destination $Destination
$attempt = 0
$lastError = $null
$notLoggedOnHResult = [int]0x800704dd
@@ -0,0 +1,763 @@
<#
.SYNOPSIS
Common Microsoft/Surface driver helpers (cache index, SKU mapping).
.DESCRIPTION
This module contains Microsoft/Surface-specific functions used by the UI and scripts
to map Surface driver packs to System SKU values using:
- Source A: Surface System SKU reference (Learn)
- Source B: Support page model list
- Source C: Download Center details (window.__DLCDetails__)
#>
# --------------------------------------------------------------------------
# SECTION: Microsoft Surface Driver Index Cache (Sources A/B/C)
# --------------------------------------------------------------------------
function Get-SurfaceDriverIndexCachePath {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder
)
# Store the cache under Drivers\Microsoft so it travels with the driver content
$microsoftDriversFolder = Join-Path -Path $DriversFolder -ChildPath 'Microsoft'
if (-not (Test-Path -Path $microsoftDriversFolder -PathType Container)) {
New-Item -Path $microsoftDriversFolder -ItemType Directory -Force | Out-Null
}
return (Join-Path -Path $microsoftDriversFolder -ChildPath 'SurfaceDriverIndex.json')
}
function Import-SurfaceDriverIndexCache {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder
)
$cachePath = Get-SurfaceDriverIndexCachePath -DriversFolder $DriversFolder
# Surface cache TTL (7 days): treat stale caches as missing so we re-download Sources A/B/C as needed.
$cacheTtlDays = 7
if (-not (Test-Path -Path $cachePath -PathType Leaf)) {
return [pscustomobject]@{
ModelIndex = @()
SkuIndex = @()
DownloadCenterDetails = @()
}
}
try {
$cacheAgeDays = ((Get-Date) - (Get-Item -Path $cachePath -ErrorAction Stop).LastWriteTime).TotalDays
if ($cacheAgeDays -ge $cacheTtlDays) {
WriteLog "Surface cache: Cache file '$cachePath' is older than $cacheTtlDays days ($([math]::Round($cacheAgeDays, 1)) days). Refreshing."
return [pscustomobject]@{
ModelIndex = @()
SkuIndex = @()
DownloadCenterDetails = @()
}
}
WriteLog "Surface cache: Loading cached SurfaceDriverIndex.json from '$cachePath' (age: $([math]::Round($cacheAgeDays, 1)) days)."
}
catch {
WriteLog "Surface cache: Failed to read cache timestamp for '$cachePath'. Refreshing. Error: $($_.Exception.Message)"
return [pscustomobject]@{
ModelIndex = @()
SkuIndex = @()
DownloadCenterDetails = @()
}
}
try {
$cache = Get-Content -Path $cachePath -Raw | ConvertFrom-Json -ErrorAction Stop
}
catch {
WriteLog "Warning: Could not read Surface driver cache '$cachePath'. Creating a new cache. Error: $($_.Exception.Message)"
return [pscustomobject]@{
ModelIndex = @()
SkuIndex = @()
DownloadCenterDetails = @()
}
}
if ($null -eq $cache) {
return [pscustomobject]@{
ModelIndex = @()
SkuIndex = @()
DownloadCenterDetails = @()
}
}
# Ensure expected properties exist (backward compatible with earlier cache shapes)
if (-not $cache.PSObject.Properties['ModelIndex']) {
$cache | Add-Member -NotePropertyName ModelIndex -NotePropertyValue @()
}
if (-not $cache.PSObject.Properties['SkuIndex']) {
$cache | Add-Member -NotePropertyName SkuIndex -NotePropertyValue @()
}
if (-not $cache.PSObject.Properties['DownloadCenterDetails']) {
$cache | Add-Member -NotePropertyName DownloadCenterDetails -NotePropertyValue @()
}
return $cache
}
function Save-SurfaceDriverIndexCache {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[psobject]$Cache,
[Parameter(Mandatory = $true)]
[string]$DriversFolder
)
$cachePath = Get-SurfaceDriverIndexCachePath -DriversFolder $DriversFolder
$Cache | ConvertTo-Json -Depth 10 | Set-Content -Path $cachePath -Encoding UTF8
}
function ConvertTo-SurfaceComparableName {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Text
)
# Normalize Surface marketing strings into a comparable family key.
# This intentionally strips consumer/commercial/processor qualifiers so we can join Sources A/B/C.
$value = [System.Net.WebUtility]::HtmlDecode($Text)
if ([string]::IsNullOrWhiteSpace($value)) {
return $null
}
$value = $value.Trim()
$value = $value -replace '\(', ' '
$value = $value -replace '\)', ' '
$value = $value -replace ',', ' '
# Normalize punctuation that frequently differs between Support/Learn pages
# (e.g. WiFi unicode hyphen, AT&T, Y!mobile)
$value = $value -replace '[-\u2010\u2011\u2012\u2013\u2014\u2212]', ' '
$value = $value -replace '&', ' '
$value = $value -replace '!', ' '
$value = $value -replace '™', ' '
$value = $value -replace '(?i)\bMicrosoft\b', ''
$value = $value -replace '(?i)\bfor\s+Business\b', ''
$value = $value -replace '(?i)\bConsumer\b', ''
$value = $value -replace '(?i)\bCommercial\b', ''
# Strip processor/connection qualifiers that cause mismatches between WMI, Learn, and Support naming.
$value = $value -replace '(?i)\bwith\s+Intel\b', ''
$value = $value -replace '(?i)\bIntel\s+processor\b', ''
$value = $value -replace '(?i)\bIntel\b', ''
$value = $value -replace '(?i)\bSnapdragon\s+processor\b', ''
$value = $value -replace '(?i)\bSnapdragon\b', ''
$value = $value -replace '(?i)\bwith\s+5G\b', ''
$value = $value -replace '(?i)\bLTE\b', ''
$value = $value -replace '(?i)\b4G\b', ''
$value = $value -replace '(?i)\bprocessor\b', ''
# Cleanup: remove orphaned "with" left behind by earlier removals (e.g., "Surface Pro 9 with Intel Processor")
$value = $value -replace '(?i)\bwith\b', ''
$value = $value -replace '\s+', ' '
return $value.Trim().ToUpperInvariant()
}
function ConvertTo-SurfaceHtmlText {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$HtmlFragment
)
# Normalize HTML fragments from the Learn table into plain text values.
$textValue = $HtmlFragment -replace '<br\s*/?>', ' '
$textValue = $textValue -replace '<[^>]+>', ' '
$textValue = [System.Net.WebUtility]::HtmlDecode($textValue)
$textValue = $textValue -replace '\s+', ' '
return $textValue.Trim()
}
function ConvertTo-SurfaceDownloadCenterLink {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$LinkValue
)
# Normalize Learn links down to the canonical Download Center details URL.
$decodedLink = [System.Net.WebUtility]::HtmlDecode($LinkValue).Trim()
if ([string]::IsNullOrWhiteSpace($decodedLink)) {
return $null
}
if ($decodedLink.StartsWith('/')) {
$decodedLink = "https://www.microsoft.com$decodedLink"
}
$downloadCenterMatch = [regex]::Match(
$decodedLink,
'https://www\.microsoft\.com(?:/en-us)?/download/details\.aspx\?id=\d+',
[System.Text.RegularExpressions.RegexOptions]::IgnoreCase
)
if (-not $downloadCenterMatch.Success) {
return $null
}
return ($downloadCenterMatch.Value -replace '/en-us/', '/')
}
function Get-SurfaceDriverModelIndex {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder
)
$url = 'https://learn.microsoft.com/en-us/surface/manage-surface-driver-and-firmware-updates'
$minimumExpectedModelCount = 10
# Load the cached model list first to keep Microsoft model discovery fast.
try {
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
if (@($cache.ModelIndex).Count -gt 0) {
WriteLog "Surface cache: Using cached Microsoft model list ($(@($cache.ModelIndex).Count) models)."
return @($cache.ModelIndex)
}
}
catch {
WriteLog "Surface cache: Failed to load cached Microsoft model list. Falling back to the Learn source. Error: $($_.Exception.Message)"
}
try {
# Download the Learn article that now contains the authoritative Surface package table.
WriteLog "Surface cache: Downloading Microsoft model index from $url"
$headers = @{
'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
}
$webContent = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $headers
$html = $webContent.Content
# Parse each table row and keep only Download Center package links.
$rowMatches = [regex]::Matches($html, '<tr[^>]*>(.*?)</tr>', [System.Text.RegularExpressions.RegexOptions]::Singleline)
$models = [System.Collections.Generic.List[pscustomobject]]::new()
$seenModelKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($rowMatch in $rowMatches) {
$rowContent = $rowMatch.Groups[1].Value
$cellMatches = [regex]::Matches($rowContent, '<td[^>]*>\s*(.*?)\s*</td>', [System.Text.RegularExpressions.RegexOptions]::Singleline)
if ($cellMatches.Count -lt 2) {
continue
}
$rowLabel = ConvertTo-SurfaceHtmlText -HtmlFragment $cellMatches[0].Groups[1].Value
if ([string]::IsNullOrWhiteSpace($rowLabel) -or $rowLabel -notmatch '(?i)^Surface') {
continue
}
$downloadCellContent = $cellMatches[1].Groups[1].Value
$linkMatches = [regex]::Matches(
$downloadCellContent,
'<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>',
[System.Text.RegularExpressions.RegexOptions]::Singleline -bor [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
)
foreach ($linkMatch in $linkMatches) {
$modelName = ConvertTo-SurfaceHtmlText -HtmlFragment $linkMatch.Groups[2].Value
$modelLink = ConvertTo-SurfaceDownloadCenterLink -LinkValue $linkMatch.Groups[1].Value
if ([string]::IsNullOrWhiteSpace($modelName) -or [string]::IsNullOrWhiteSpace($modelLink)) {
continue
}
$modelKey = "$modelName`n$modelLink"
if (-not $seenModelKeys.Add($modelKey)) {
continue
}
$models.Add([pscustomobject]@{
Make = 'Microsoft'
Model = $modelName
Link = $modelLink
})
}
}
if ($models.Count -eq 0) {
throw "No Microsoft driver models were found in the Learn table."
}
if ($models.Count -lt $minimumExpectedModelCount) {
WriteLog "Surface cache: Warning - Learn parsing returned only $($models.Count) Microsoft model entries."
}
else {
WriteLog "Surface cache: Parsed $($models.Count) Microsoft model entries from Learn."
}
# Save the refreshed model list into the shared cache for both UI and CLI use.
try {
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
$cache.ModelIndex = @($models)
Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder
WriteLog "Surface cache: Saved Microsoft model list to cache."
}
catch {
WriteLog "Surface cache: Failed to save Microsoft model list. Error: $($_.Exception.Message)"
}
return @($models)
}
catch {
WriteLog "Surface cache: Failed to build Microsoft model list from Learn. Error: $($_.Exception.Message)"
# Fall back to the last cached model list even if it is stale when the live request fails.
try {
$cachePath = Get-SurfaceDriverIndexCachePath -DriversFolder $DriversFolder
if (Test-Path -Path $cachePath -PathType Leaf) {
$staleCache = Get-Content -Path $cachePath -Raw | ConvertFrom-Json -ErrorAction Stop
if (@($staleCache.ModelIndex).Count -gt 0) {
WriteLog "Surface cache: Using stale Microsoft model list ($(@($staleCache.ModelIndex).Count) models) because the live Learn request failed."
return @($staleCache.ModelIndex)
}
}
}
catch {
WriteLog "Surface cache: Failed to load stale Microsoft model list fallback. Error: $($_.Exception.Message)"
}
throw "Failed to retrieve Microsoft Surface models."
}
}
function Get-SurfaceSystemSkuReferenceIndex {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder
)
# Source A: Learn page with authoritative Device / System Model / System SKU table
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
if ($cache.SkuIndex -and $cache.SkuIndex.Count -gt 0) {
return @($cache.SkuIndex)
}
$url = 'https://learn.microsoft.com/en-us/surface/surface-system-sku-reference'
WriteLog "Surface cache: Downloading System SKU reference table from $url"
$headers = @{ 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }
$webContent = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $headers
$html = $webContent.Content
$skuRows = [System.Collections.Generic.List[pscustomobject]]::new()
$rowMatches = [regex]::Matches($html, '<tr[^>]*>(.*?)</tr>', [System.Text.RegularExpressions.RegexOptions]::Singleline)
foreach ($rowMatch in $rowMatches) {
$rowContent = $rowMatch.Groups[1].Value
$cellMatches = [regex]::Matches($rowContent, '<td[^>]*>\s*(?:<p[^>]*>)?(.*?)(?:</p>)?\s*</td>', [System.Text.RegularExpressions.RegexOptions]::Singleline)
if ($cellMatches.Count -lt 3) { continue }
$device = ([System.Net.WebUtility]::HtmlDecode(($cellMatches[0].Groups[1].Value).Trim()))
$systemModel = ([System.Net.WebUtility]::HtmlDecode(($cellMatches[1].Groups[1].Value).Trim()))
$systemSkuRaw = ([System.Net.WebUtility]::HtmlDecode(($cellMatches[2].Groups[1].Value).Trim()))
if ([string]::IsNullOrWhiteSpace($device) -or [string]::IsNullOrWhiteSpace($systemSkuRaw)) { continue }
$skuList = @($systemSkuRaw)
foreach ($sku in $skuList) {
if ([string]::IsNullOrWhiteSpace($sku)) { continue }
$skuRows.Add([pscustomobject]@{
Device = $device
SystemModel = $systemModel
SystemSku = $sku.Trim().ToUpperInvariant()
})
}
}
$cache.SkuIndex = @($skuRows)
Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder
WriteLog "Surface cache: Stored $($skuRows.Count) SKU entries."
return @($skuRows)
}
function Get-SurfaceDownloadCenterDetails {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder,
[Parameter(Mandatory = $true)]
[string]$ModelLink,
[Parameter()]
[string]$ModelName = $null
)
# Source C: Download Center details page (window.__DLCDetails__) containing file names + direct URLs
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
$existing = @($cache.DownloadCenterDetails | Where-Object { $_.Link -eq $ModelLink } | Select-Object -First 1)
if ($existing.Count -gt 0 -and $existing[0].Files -and $existing[0].Files.Count -gt 0) {
# Backfill Model into cache when available
if (-not [string]::IsNullOrWhiteSpace($ModelName)) {
if (-not $existing[0].PSObject.Properties['Model'] -or [string]::IsNullOrWhiteSpace($existing[0].Model)) {
try {
$existing[0] | Add-Member -NotePropertyName Model -NotePropertyValue $ModelName -Force
$newDetails = [System.Collections.Generic.List[pscustomobject]]::new()
foreach ($item in @($cache.DownloadCenterDetails)) {
if ($null -ne $item -and $item.PSObject.Properties['Link'] -and $item.Link -ne $ModelLink) {
$newDetails.Add($item)
}
}
$newDetails.Add($existing[0])
$cache.DownloadCenterDetails = @($newDetails)
Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder
}
catch {
WriteLog "Surface cache: Failed to backfill Model for DownloadCenterDetails entry '$ModelLink'. Error: $($_.Exception.Message)"
}
}
}
return @($existing[0].Files)
}
WriteLog "Surface cache: Downloading Download Center details from $ModelLink"
$headers = @{ 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }
$downloadPageContent = Invoke-WebRequest -Uri $ModelLink -UseBasicParsing -Headers $headers
$scriptPattern = '<script>window.__DLCDetails__={(.*?)}<\/script>'
$scriptMatch = [regex]::Match($downloadPageContent.Content, $scriptPattern)
if (-not $scriptMatch.Success) {
WriteLog "Surface cache: Could not find window.__DLCDetails__ on $ModelLink"
return @()
}
$scriptContent = $scriptMatch.Groups[1].Value
$downloadFilePattern = '"name":"([^"]+\.(?:msi|zip))",[^}]*?"url":"(.*?)"'
$downloadFileMatches = [regex]::Matches($scriptContent, $downloadFilePattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
$files = [System.Collections.Generic.List[pscustomobject]]::new()
foreach ($downloadFile in $downloadFileMatches) {
$currentFileName = $downloadFile.Groups[1].Value
$fileUrl = $downloadFile.Groups[2].Value
if ([string]::IsNullOrWhiteSpace($currentFileName) -or [string]::IsNullOrWhiteSpace($fileUrl)) { continue }
$files.Add([pscustomobject]@{
Name = $currentFileName
Url = $fileUrl
})
}
# Persist into cache
if ($files.Count -gt 0) {
$detailsEntry = [pscustomobject][ordered]@{
Model = $ModelName
Link = $ModelLink
Files = @($files)
}
$newDetails = [System.Collections.Generic.List[pscustomobject]]::new()
foreach ($item in @($cache.DownloadCenterDetails)) {
if ($null -ne $item -and $item.PSObject.Properties['Link'] -and $item.Link -ne $ModelLink) {
$newDetails.Add($item)
}
}
$newDetails.Add($detailsEntry)
$cache.DownloadCenterDetails = @($newDetails)
Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder
}
return @($files)
}
function Get-SurfaceSystemSkuListForMicrosoftDriver {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder,
[Parameter(Mandatory = $true)]
[string]$ModelName,
[Parameter(Mandatory = $true)]
[string]$ModelLink
)
$skuIndex = Get-SurfaceSystemSkuReferenceIndex -DriversFolder $DriversFolder
if ($null -eq $skuIndex -or $skuIndex.Count -eq 0) {
return @()
}
$files = Get-SurfaceDownloadCenterDetails -DriversFolder $DriversFolder -ModelLink $ModelLink -ModelName $ModelName
$fileNames = @($files | ForEach-Object { $_.Name })
# Infer architecture hints from the MSI naming convention (best-effort)
$archHint = $null
if ($fileNames -match '(?i)_ARM_') {
$archHint = 'ARM64'
}
elseif ($fileNames -match '(?i)withIntel|_Intel_|Intel') {
$archHint = 'x64'
}
elseif ($ModelName -match '(?i)\bSQ3\b|\bSnapdragon\b') {
$archHint = 'ARM64'
}
elseif ($ModelName -match '(?i)with Intel') {
$archHint = 'x64'
}
# Surface Pro (generic) is ambiguous in the SKU table because Surface Pro (5th Gen) and
# Surface Pro with LTE Advanced (5th Gen) both reuse SystemModel="Surface Pro".
# The "Surface Pro" driver pack does not have a unique SystemSKU value on the Learn page.
if ($ModelName.Trim() -match '(?i)^Surface\s+Pro$') {
return @()
}
# Build multiple candidate keys for models that contain multiple variants in one string
# Example: "Surface Pro 7+ and Surface Pro 7+ LTE"
$familyKeyCandidates = [System.Collections.Generic.List[string]]::new()
$familyKeySet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$primaryKey = ConvertTo-SurfaceComparableName -Text $ModelName
if (-not [string]::IsNullOrWhiteSpace($primaryKey) -and $familyKeySet.Add($primaryKey)) {
$familyKeyCandidates.Add($primaryKey) | Out-Null
}
$parts = [regex]::Split($ModelName, '(?i)\s+and\s+')
# Track when the model text contains both LTE and non-LTE variants (e.g. "Surface Go 2 and Surface Go 2 LTE")
$hasLtePart = (@($parts | Where-Object { $_ -match '(?i)\bLTE\b' }).Count -gt 0)
$hasNonLtePart = (@($parts | Where-Object { $_ -notmatch '(?i)\bLTE\b' }).Count -gt 0)
foreach ($part in @($parts)) {
if ([string]::IsNullOrWhiteSpace($part)) { continue }
$candidate = ConvertTo-SurfaceComparableName -Text $part
if (-not [string]::IsNullOrWhiteSpace($candidate) -and $familyKeySet.Add($candidate)) {
$familyKeyCandidates.Add($candidate) | Out-Null
}
}
if ($familyKeyCandidates.Count -eq 0) {
return @()
}
# Surface 3 has multiple carrier/region variants that share the same SystemModel ("Surface 3").
# Add a base key so we can match all Surface 3 SKU rows, then refine down to the correct variant.
if ($ModelName -match '(?i)^Surface\s+3\b') {
$surface3BaseKey = 'SURFACE 3'
if ($familyKeySet.Add($surface3BaseKey)) {
$familyKeyCandidates.Add($surface3BaseKey) | Out-Null
}
}
# Surface Go variants share the same SystemModel ("Surface Go") in the SKU table.
# Use a generation-aware base key so we don't cross-match Go vs Go 2/3/4 SKU rows.
if ($ModelName -match '(?i)^Surface\s+Go\s+2\b') {
$surfaceGoBaseKey = 'SURFACE GO 2'
if ($familyKeySet.Add($surfaceGoBaseKey)) {
$familyKeyCandidates.Add($surfaceGoBaseKey) | Out-Null
}
}
elseif ($ModelName -match '(?i)^Surface\s+Go\s+3\b') {
$surfaceGoBaseKey = 'SURFACE GO 3'
if ($familyKeySet.Add($surfaceGoBaseKey)) {
$familyKeyCandidates.Add($surfaceGoBaseKey) | Out-Null
}
}
elseif ($ModelName -match '(?i)^Surface\s+Go\s+4\b') {
$surfaceGoBaseKey = 'SURFACE GO 4'
if ($familyKeySet.Add($surfaceGoBaseKey)) {
$familyKeyCandidates.Add($surfaceGoBaseKey) | Out-Null
}
}
elseif ($ModelName -match '(?i)^Surface\s+Go\b') {
$surfaceGoBaseKey = 'SURFACE GO'
if ($familyKeySet.Add($surfaceGoBaseKey)) {
$familyKeyCandidates.Add($surfaceGoBaseKey) | Out-Null
}
}
# Surface Pro 9 with 5G: the SKU table rows use SystemModel "Surface Pro 9".
# Add a base key so we can match the Pro 9 SKU rows, then refine down to the 5G rows.
if (($ModelName -match '(?i)^Surface\s+Pro\s+9\b') -and ($ModelName -match '(?i)\b5G\b')) {
$surfacePro9BaseKey = 'SURFACE PRO 9'
if ($familyKeySet.Add($surfacePro9BaseKey)) {
$familyKeyCandidates.Add($surfacePro9BaseKey) | Out-Null
}
}
# Surface Pro with LTE Advanced maps to the "Surface Pro with LTE Advanced (5th Gen)" SKU table row.
# Add a base key so we can match Surface Pro rows, then refine to the LTE Advanced SKU.
if ($ModelName -match '(?i)^Surface\s+Pro\s+with\s+LTE\s+Advanced\b') {
$surfaceProBaseKey = 'SURFACE PRO'
if ($familyKeySet.Add($surfaceProBaseKey)) {
$familyKeyCandidates.Add($surfaceProBaseKey) | Out-Null
}
}
# Surface Laptop (1st Gen) maps to the base "Surface Laptop" SKU table row.
if (($ModelName -match '(?i)^Surface\s+Laptop\b') -and ($ModelName -match '(?i)\bGen\b')) {
$surfaceLaptopBaseKey = 'SURFACE LAPTOP'
if ($familyKeySet.Add($surfaceLaptopBaseKey)) {
$familyKeyCandidates.Add($surfaceLaptopBaseKey) | Out-Null
}
}
# Surface Studio (1st Gen) maps to the base "Surface Studio" SKU table row.
if (($ModelName -match '(?i)^Surface\s+Studio\b') -and ($ModelName -match '(?i)\bGen\b')) {
$surfaceStudioBaseKey = 'SURFACE STUDIO'
if ($familyKeySet.Add($surfaceStudioBaseKey)) {
$familyKeyCandidates.Add($surfaceStudioBaseKey) | Out-Null
}
}
# Surface Laptop 3/4 AMD/Intel packs map to the "Surface Laptop 3/4" SystemModel rows in the SKU table.
if ($ModelName -match '(?i)^Surface\s+Laptop\s+(3|4)\b' -and $ModelName -match '(?i)\b(AMD|Intel)\b') {
$generationMatch = [regex]::Match($ModelName, '(?i)^Surface\s+Laptop\s+(3|4)\b')
if ($generationMatch.Success) {
$surfaceLaptopGenBaseKey = "SURFACE LAPTOP $($generationMatch.Groups[1].Value)"
if ($familyKeySet.Add($surfaceLaptopGenBaseKey)) {
$familyKeyCandidates.Add($surfaceLaptopGenBaseKey) | Out-Null
}
}
}
# Match by any candidate key against the SKU table
$skuMatches = @($skuIndex | Where-Object {
$deviceKey = ConvertTo-SurfaceComparableName -Text $_.Device
$modelKey = ConvertTo-SurfaceComparableName -Text $_.SystemModel
foreach ($candidateKey in $familyKeyCandidates) {
if ($deviceKey -eq $candidateKey -or $modelKey -eq $candidateKey) {
return $true
}
}
return $false
})
# Surface Hub 2 driver packs cover Surface Hub 2S + Surface Hub 3 devices.
# The System SKU table does not have a "Surface Hub 2" row, so map Hub 2 to all Hub SKUs.
if ($ModelName -match '(?i)^Surface\s+Hub\s+2\b') {
$hubSkuRows = @($skuIndex | Where-Object { $_.Device -match '(?i)^Surface\s+Hub' })
if ($hubSkuRows.Count -gt 0) {
$skuMatches = @($hubSkuRows)
}
}
# Surface 3: refine down to the correct SKU row based on the model variant text
# Use normalized text so punctuation/Unicode differences don't drop matches to zero.
if ($ModelName -match '(?i)^Surface\s+3\b') {
$modelNorm = ConvertTo-SurfaceComparableName -Text $ModelName
if ($modelNorm -match '(?i)\bWI\s+FI\b') {
$skuMatches = @($skuMatches | Where-Object { (ConvertTo-SurfaceComparableName -Text $_.Device) -match '(?i)\bWI\s+FI\b' })
}
elseif ($modelNorm -match '(?i)\bVERIZON\b') {
$skuMatches = @($skuMatches | Where-Object { (ConvertTo-SurfaceComparableName -Text $_.Device) -match '(?i)\bVERIZON\b' })
}
elseif ($modelNorm -match '(?i)\bOUTSIDE\s+OF\s+NORTH\s+AMERICA\b|\bY\s+MOBILE\b') {
$skuMatches = @($skuMatches | Where-Object { (ConvertTo-SurfaceComparableName -Text $_.Device) -match '(?i)\bOUTSIDE\s+OF\s+NORTH\s+AMERICA\b|\bY\s+MOBILE\b' })
}
elseif ($modelNorm -match '(?i)\bNORTH\s+AMERICA\b') {
# "North America (non-AT&T)" should map to the North America row (not AT&T/Verizon/outside-of-North-America)
$skuMatches = @($skuMatches | Where-Object {
$deviceNorm = ConvertTo-SurfaceComparableName -Text $_.Device
($deviceNorm -match '(?i)\bNORTH\s+AMERICA\b') -and
($deviceNorm -notmatch '(?i)\bOUTSIDE\b|\bY\s+MOBILE\b') -and
($deviceNorm -notmatch '(?i)\bAT\s+T\b|\bVERIZON\b')
})
}
elseif (($modelNorm -match '(?i)\bAT\s+T\b') -and ($modelNorm -notmatch '(?i)\bNON\s+AT\s+T\b')) {
$skuMatches = @($skuMatches | Where-Object { (ConvertTo-SurfaceComparableName -Text $_.Device) -match '(?i)\bAT\s+T\b' })
}
}
# Surface Go: keep LTE SKU only for LTE-only models; exclude LTE SKU for non-LTE-only models.
# If the model name includes BOTH LTE and non-LTE variants (joined with "and"), do not filter.
# Surface Go 3 driver packs are treated as covering LTE + non-LTE unless explicitly labeled otherwise.
if ($ModelName -match '(?i)^Surface\s+Go\b') {
$isSurfaceGo3Base = ($ModelName -match '(?i)^Surface\s+Go\s+3\b') -and ($ModelName -notmatch '(?i)\bLTE\b')
if (-not $isSurfaceGo3Base) {
if (-not ($hasLtePart -and $hasNonLtePart)) {
if ($ModelName -match '(?i)\bLTE\b') {
$skuMatches = @($skuMatches | Where-Object { $_.Device -match '(?i)\bLTE\b' })
}
else {
$skuMatches = @($skuMatches | Where-Object { $_.Device -notmatch '(?i)\bLTE\b' })
}
}
}
}
# Surface Pro 9 with 5G (SQ3): keep only the 5G SKU rows (U.S. + outside of U.S.).
if (($ModelName -match '(?i)^Surface\s+Pro\s+9\b') -and ($ModelName -match '(?i)\b5G\b')) {
$skuMatches = @($skuMatches | Where-Object { $_.Device -match '(?i)\b5G\b' })
}
# Surface Pro 10: split non-5G vs 5G SKU rows so the two driver packs don't share the same SystemSKUs.
if ($ModelName -match '(?i)^Surface\s+Pro\s+10\b') {
if ($ModelName -match '(?i)\b5G\b') {
$skuMatches = @($skuMatches | Where-Object {
($_.SystemSku -match '^SURFACE_PRO_10_WITH_5G_FOR_BUSINESS_') -or
($_.Device -match '(?i)\bwith\s+5G\b')
})
}
else {
$skuMatches = @($skuMatches | Where-Object { $_.SystemSku -eq 'SURFACE_PRO_10_FOR_BUSINESS_2079' })
}
}
# Surface Pro with LTE Advanced: restrict to the LTE Advanced (5th Gen) SKU.
if ($ModelName -match '(?i)^Surface\s+Pro\s+with\s+LTE\s+Advanced\b') {
$skuMatches = @($skuMatches | Where-Object { $_.SystemSku -eq 'SURFACE_PRO_1807' })
}
# Surface Laptop 3/4: filter to AMD vs Intel rows (prevents AMD packs from inheriting Intel SKUs and vice-versa).
if ($ModelName -match '(?i)^Surface\s+Laptop\s+(3|4)\b') {
if ($ModelName -match '(?i)\bAMD\b') {
$skuMatches = @($skuMatches | Where-Object { $_.Device -match '(?i)\bAMD\b' })
}
elseif ($ModelName -match '(?i)\bIntel\b') {
$skuMatches = @($skuMatches | Where-Object { $_.Device -match '(?i)\bIntel\b' })
}
}
# Apply architecture filtering when we can infer it
if ($archHint -eq 'ARM64') {
# ARM variants are typically called out as Snapdragon / SQ3 / 5G in the Learn table
$skuMatches = @($skuMatches | Where-Object {
($_.Device -match '(?i)Snapdragon|SQ3|with 5G') -or
($_.SystemModel -match '(?i)Snapdragon|SQ3|with 5G')
})
}
elseif ($archHint -eq 'x64') {
# x64 variants are often NOT labeled "Intel" in the Learn table (e.g. Surface Pro 9).
# Treat "not Snapdragon/SQ3/5G" as the x64 bucket.
$skuMatches = @($skuMatches | Where-Object {
($_.Device -notmatch '(?i)Snapdragon|SQ3|with 5G') -and
($_.SystemModel -notmatch '(?i)Snapdragon|SQ3|with 5G')
})
}
$skus = @($skuMatches | ForEach-Object { $_.SystemSku } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique)
return $skus
}
Export-ModuleMember -Function `
Get-SurfaceDriverIndexCachePath, `
Import-SurfaceDriverIndexCache, `
Save-SurfaceDriverIndexCache, `
ConvertTo-SurfaceComparableName, `
ConvertTo-SurfaceHtmlText, `
ConvertTo-SurfaceDownloadCenterLink, `
Get-SurfaceDriverModelIndex, `
Get-SurfaceSystemSkuReferenceIndex, `
Get-SurfaceDownloadCenterDetails, `
Get-SurfaceSystemSkuListForMicrosoftDriver
@@ -276,6 +276,20 @@ function Update-DriverMappingJson {
}
}
# Microsoft Surface: resolve System SKU list (best-effort) using Sources A + C and cached results
$surfaceSystemSkuList = @()
if ($driver.Make -eq 'Microsoft') {
if ($driver.PSObject.Properties['Link'] -and -not [string]::IsNullOrWhiteSpace($driver.Link)) {
try {
$surfaceSystemSkuList = Get-SurfaceSystemSkuListForMicrosoftDriver -DriversFolder $DriversFolder -ModelName $driver.Model -ModelLink $driver.Link
}
catch {
WriteLog "Warning: Failed to resolve Surface SystemSku list for '$($driver.Model)'. Error: $($_.Exception.Message)"
$surfaceSystemSkuList = @()
}
}
}
$existingEntry = $mappingList | Where-Object { $_.Manufacturer -eq $driver.Make -and $_.Model -eq $driver.Model } | Select-Object -First 1
if ($null -ne $existingEntry) {
@@ -316,6 +330,26 @@ function Update-DriverMappingJson {
}
}
if ($driver.Make -eq 'Microsoft' -and $surfaceSystemSkuList -and $surfaceSystemSkuList.Count -gt 0) {
$desiredSkus = @($surfaceSystemSkuList | Sort-Object -Unique)
if ($existingEntry.PSObject.Properties['SystemSku']) {
$currentSkus = @($existingEntry.SystemSku)
$currentNormalized = @($currentSkus | ForEach-Object { if ($null -ne $_) { $_.ToString().Trim().ToUpperInvariant() } }) | Sort-Object -Unique
$desiredNormalized = @($desiredSkus | ForEach-Object { if ($null -ne $_) { $_.ToString().Trim().ToUpperInvariant() } }) | Sort-Object -Unique
if (($currentNormalized -join '|') -ne ($desiredNormalized -join '|')) {
WriteLog "Updating SystemSku list for 'Microsoft - $($driver.Model)'."
$existingEntry.SystemSku = $desiredSkus
$entryUpdated = $true
}
}
else {
WriteLog "Adding SystemSku list for 'Microsoft - $($driver.Model)'."
$existingEntry | Add-Member -NotePropertyName SystemSku -NotePropertyValue $desiredSkus
$entryUpdated = $true
}
}
if ($entryUpdated) {
$updatedCount++
}
@@ -333,6 +367,9 @@ function Update-DriverMappingJson {
if ($driver.Make -eq 'Lenovo' -and -not [string]::IsNullOrWhiteSpace($machineTypeValue)) {
$newEntry | Add-Member -NotePropertyName MachineType -NotePropertyValue $machineTypeValue
}
if ($driver.Make -eq 'Microsoft' -and $surfaceSystemSkuList -and $surfaceSystemSkuList.Count -gt 0) {
$newEntry | Add-Member -NotePropertyName SystemSku -NotePropertyValue @($surfaceSystemSkuList | Sort-Object -Unique)
}
$mappingList.Add($newEntry)
WriteLog "Adding new mapping for '$($driver.Make) - $($driver.Model)' with path '$($driver.DriverPath)'."
@@ -778,4 +815,8 @@ function Get-LenovoPSREFToken {
# SECTION: Module Export
# --------------------------------------------------------------------------
Export-ModuleMember -Function Compress-DriverFolderToWim, Update-DriverMappingJson, Test-ExistingDriver, Get-LenovoPSREFToken
Export-ModuleMember -Function `
Compress-DriverFolderToWim, `
Update-DriverMappingJson, `
Test-ExistingDriver, `
Get-LenovoPSREFToken
@@ -161,6 +161,7 @@ function Invoke-ParallelProcessing {
ApplicationItemData = $currentItem
AppListJsonPath = $localJobArgs['AppListJsonPath']
AppsPath = $localJobArgs['AppsPath']
UserAppListPath = $localJobArgs['UserAppListPath']
OrchestrationPath = $localJobArgs['OrchestrationPath']
ProgressQueue = $localProgressQueue
WindowsArch = $localJobArgs['WindowsArch']
@@ -358,6 +358,8 @@ function Start-WingetAppDownloadTask {
[string]$AppListJsonPath,
[Parameter(Mandatory = $true)]
[string]$AppsPath,
[Parameter()]
[string]$UserAppListPath,
[Parameter(Mandatory = $true)]
[string]$OrchestrationPath,
[Parameter(Mandatory = $true)]
@@ -379,11 +381,11 @@ function Start-WingetAppDownloadTask {
WriteLog "Starting download task for $($appName) with ID $($appId) from source $($source)."
try {
# Define paths
$userAppListPath = Join-Path -Path $AppsPath -ChildPath "UserAppList.json"
# Resolve the BYO app list path so duplicate checks honor custom file names.
$userAppListPath = if (-not [string]::IsNullOrWhiteSpace($UserAppListPath)) { $UserAppListPath } else { Join-Path -Path $AppsPath -ChildPath "UserAppList.json" }
$appFound = $false
# 1. Check UserAppList.json and content
# 1. Check the configured BYO app list and content
if (Test-Path -Path $userAppListPath) {
try {
$userAppListContent = Get-Content -Path $userAppListPath -Raw | ConvertFrom-Json
@@ -724,6 +726,8 @@ function Get-Apps {
[string]$AppList,
[Parameter(Mandatory = $true)]
[string]$AppsPath,
[Parameter()]
[string]$UserAppListPath,
[Parameter(Mandatory = $true)]
[string]$WindowsArch,
[Parameter(Mandatory = $true)]
@@ -787,6 +791,7 @@ function Get-Apps {
# CLI builds should create WinGetWin32Apps.json, so SkipWin32Json is false
$taskArguments = @{
AppsPath = $AppsPath
UserAppListPath = $UserAppListPath
AppListJsonPath = $AppList
OrchestrationPath = $OrchestrationPath
WindowsArch = $WindowsArch
@@ -67,6 +67,7 @@ Description = 'Common functions shared between FFU Builder UI and the BuildFFUVM
# Modules to import as nested modules of the module specified in RootModule/ModuleToProcess
NestedModules = @('FFU.Common.Drivers.psm1',
'FFU.Common.Drivers.Microsoft.psm1',
'FFU.Common.Drivers.Dell.psm1',
'FFU.Common.Winget.psm1',
'FFU.Common.Parallel.psm1',
@@ -27,6 +27,22 @@ function Update-BYOAppsActionButtonsState {
}
}
# Function to resolve the configured BYO app list path
function Get-BYOApplicationListPath {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[psobject]$State
)
# Fall back to the legacy default path when the textbox is empty.
if (-not [string]::IsNullOrWhiteSpace($State.Controls.txtUserAppListPath.Text)) {
return $State.Controls.txtUserAppListPath.Text
}
return (Join-Path -Path $State.Controls.txtApplicationPath.Text -ChildPath 'UserAppList.json')
}
# Function to remove all selected BYO applications
function Remove-SelectedBYOApplications {
[CmdletBinding()]
@@ -76,10 +92,10 @@ function Remove-SelectedBYOApplications {
}
# Ask user if they want to save the changes
$result = [System.Windows.MessageBox]::Show("The selected applications have been removed from the list. Do you want to save these changes to UserAppList.json now?", "Save Changes", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question)
$result = [System.Windows.MessageBox]::Show("The selected applications have been removed from the list. Do you want to save these changes to the configured BYO app list now?", "Save Changes", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question)
if ($result -eq 'Yes') {
$userAppListPath = Join-Path -Path $State.Controls.txtApplicationPath.Text -ChildPath 'UserAppList.json'
$userAppListPath = Get-BYOApplicationListPath -State $State
Save-BYOApplicationList -Path $userAppListPath -State $State
}
}
@@ -166,6 +182,7 @@ function Add-BYOApplication {
# Refresh the ListView to show the changes
$listView.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $listView
# Reset state
$State.Data.editingBYOApplication = $null
@@ -196,6 +213,7 @@ function Add-BYOApplication {
CopyStatus = ""
}
$listView.Items.Add($application)
Request-ListViewColumnAutoResize -ListView $listView
}
# Clear form and update button states for both add and update operations
@@ -269,6 +287,7 @@ function Add-AppsScriptVariable {
}
$State.Data.appsScriptVariablesDataList.Add($newItem)
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables
$State.Controls.txtAppsScriptKey.Clear()
$State.Controls.txtAppsScriptValue.Clear()
# Update the header checkbox state
@@ -295,6 +314,7 @@ function Remove-SelectedAppsScriptVariable {
$State.Data.appsScriptVariablesDataList.Remove($itemToRemove)
}
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables
# Update the header checkbox state
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
@@ -391,18 +411,24 @@ function Invoke-CopyBYOApps {
)
$localAppsPath = $State.Controls.txtApplicationPath.Text
$userAppListPath = Join-Path -Path $localAppsPath -ChildPath 'UserAppList.json'
$userAppListPath = Get-BYOApplicationListPath -State $State
$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
# Ensure the configured BYO app list folder exists before writing the manifest.
$userAppListDirectory = Split-Path -Path $userAppListPath -Parent
if (-not [string]::IsNullOrWhiteSpace($userAppListDirectory) -and -not (Test-Path -Path $userAppListDirectory -PathType Container)) {
New-Item -Path $userAppListDirectory -ItemType Directory -Force | Out-Null
}
# Ensure items are sorted by current priority before saving.
# Exclude CopyStatus when saving and ensure Priority is an integer; include AdditionalExitCodes and IgnoreNonZeroExitCodes for parity with Save-BYOApplicationList.
$applications = $listView.Items | Sort-Object Priority | Select-Object @{N = 'Priority'; E = { [int]$_.Priority } }, Name, CommandLine, Arguments, Source, AdditionalExitCodes, IgnoreNonZeroExitCodes
$applications | ConvertTo-Json -Depth 5 | Set-Content -Path $userAppListPath -Force -Encoding UTF8
WriteLog "Successfully updated UserAppList.json with all applications from the UI."
WriteLog "Successfully updated BYO app list at $userAppListPath with all applications from the UI."
}
catch {
$errorMessage = "Failed to update UserAppList.json: $_"
$errorMessage = "Failed to update BYO app list at $($userAppListPath): $_"
WriteLog $errorMessage
[System.Windows.MessageBox]::Show($errorMessage, "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error)
return
+123 -22
View File
@@ -25,7 +25,6 @@ function Get-UIConfig {
else { $null }
BuildUSBDrive = $State.Controls.chkBuildUSBDriveEnable.IsChecked
CleanupAppsISO = $State.Controls.chkCleanupAppsISO.IsChecked
CleanupCaptureISO = $State.Controls.chkCleanupCaptureISO.IsChecked
CleanupDeployISO = $State.Controls.chkCleanupDeployISO.IsChecked
CleanupDrivers = $State.Controls.chkCleanupDrivers.IsChecked
CompactOS = $State.Controls.chkCompactOS.IsChecked
@@ -37,15 +36,23 @@ function Get-UIConfig {
UseDriversAsPEDrivers = $State.Controls.chkUseDriversAsPEDrivers.IsChecked
CopyPPKG = $State.Controls.chkCopyPPKG.IsChecked
CopyUnattend = $State.Controls.chkCopyUnattend.IsChecked
DeviceNamingMode = Get-ConfiguredDeviceNamingMode -State $State
DeviceNameTemplate = $State.Controls.txtDeviceNameTemplate.Text
DeviceNamePrefixesPath = $State.Controls.txtDeviceNamePrefixesPath.Text
DeviceNamePrefixes = @(Get-DeviceNamePrefixes -State $State)
DeviceNameSerialComputerNamesPath = $State.Controls.txtDeviceNameSerialComputerNamesPath.Text
DeviceNameSerialComputerNames = @(Get-SerialComputerNamesLines -State $State)
CopyAdditionalFFUFiles = $State.Controls.chkCopyAdditionalFFUFiles.IsChecked
CreateCaptureMedia = $State.Controls.chkCreateCaptureMedia.IsChecked
CreateDeploymentMedia = $State.Controls.chkCreateDeploymentMedia.IsChecked
InjectUnattend = $State.Controls.chkInjectUnattend.IsChecked
UnattendX64FilePath = $State.Controls.txtUnattendX64FilePath.Text
UnattendArm64FilePath = $State.Controls.txtUnattendArm64FilePath.Text
CustomFFUNameTemplate = $State.Controls.txtCustomFFUNameTemplate.Text
Disksize = [int64]$State.Controls.txtDiskSize.Text * 1GB
DownloadDrivers = $State.Controls.chkDownloadDrivers.IsChecked
DriversFolder = $State.Controls.txtDriversFolder.Text
DriversJsonPath = $State.Controls.txtDriversJsonPath.Text
EnableVMNetworking = $State.Controls.chkEnableVMNetworking.IsChecked
FFUCaptureLocation = $State.Controls.txtFFUCaptureLocation.Text
FFUDevelopmentPath = $State.Controls.txtFFUDevPath.Text
FFUPrefix = $State.Controls.txtVMNamePrefix.Text
@@ -54,6 +61,7 @@ function Get-UIConfig {
InstallOffice = $State.Controls.chkInstallOffice.IsChecked
InstallWingetApps = $State.Controls.chkInstallWingetApps.IsChecked
ISOPath = $State.Controls.txtISOPath.Text
WindowsMediaSource = if ($null -ne $State.Controls.rbProvideISO -and $State.Controls.rbProvideISO.IsChecked) { "Provide Windows ISO" } else { "Download Windows ESD" }
LogicalSectorSizeBytes = [int]$State.Controls.cmbLogicalSectorSize.SelectedItem.Content
# Make = $null
MediaType = $State.Controls.cmbMediaType.SelectedItem
@@ -82,7 +90,7 @@ function Get-UIConfig {
RemoveApps = $State.Controls.chkRemoveApps.IsChecked
RemoveFFU = $State.Controls.chkRemoveFFU.IsChecked
RemoveUpdates = $State.Controls.chkRemoveUpdates.IsChecked
ShareName = $State.Controls.txtShareName.Text
RemoveDownloadedESD = $State.Controls.chkRemoveDownloadedESD.IsChecked
UpdateADK = $State.Controls.chkUpdateADK.IsChecked
UpdateEdge = $State.Controls.chkUpdateEdge.IsChecked
UpdateLatestCU = $State.Controls.chkUpdateLatestCU.IsChecked
@@ -92,14 +100,13 @@ function Get-UIConfig {
UpdateLatestNet = $State.Controls.chkUpdateLatestNet.IsChecked
UpdateOneDrive = $State.Controls.chkUpdateOneDrive.IsChecked
UpdatePreviewCU = $State.Controls.chkUpdatePreviewCU.IsChecked
UserAppListPath = "$($State.Controls.txtApplicationPath.Text)\UserAppList.json"
UserAppListPath = $State.Controls.txtUserAppListPath.Text
USBDriveList = @{}
Username = $State.Controls.txtUsername.Text
Threads = [int]$State.Controls.txtThreads.Text
BitsPriority = $State.Controls.cmbBitsPriority.SelectedItem
MaxUSBDrives = [int]$State.Controls.txtMaxUSBDrives.Text
ThemeMode = if ($null -ne $State.Controls.cmbThemeMode -and $null -ne $State.Controls.cmbThemeMode.SelectedItem) { $State.Controls.cmbThemeMode.SelectedItem } else { "System" }
Verbose = $State.Controls.chkVerbose.IsChecked
VMHostIPAddress = $State.Controls.txtVMHostIPAddress.Text
VMLocation = $State.Controls.txtVMLocation.Text
VMSwitchName = if ($State.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') {
$State.Controls.txtCustomVMSwitchName.Text
@@ -115,8 +122,27 @@ function Get-UIConfig {
}
# Save selected USB drives using UniqueId for reliable identification
# Multiple physical drives can share the same Model, so store an array of UniqueIds per Model.
$State.Controls.lstUSBDrives.Items | Where-Object { $_.IsSelected } | ForEach-Object {
$config.USBDriveList[$_.Model] = $_.UniqueId
$modelName = $_.Model
$uniqueId = $_.UniqueId
if ([string]::IsNullOrWhiteSpace($modelName) -or [string]::IsNullOrWhiteSpace($uniqueId)) {
return
}
# Ensure the hashtable value is always an array so multiple same-model drives are preserved
$existingUniqueIds = $config.USBDriveList[$modelName]
if ($null -eq $existingUniqueIds) {
$config.USBDriveList[$modelName] = @($uniqueId)
return
}
$existingUniqueIds = @($existingUniqueIds)
if (-not ($existingUniqueIds -contains $uniqueId)) {
$existingUniqueIds += $uniqueId
}
$config.USBDriveList[$modelName] = $existingUniqueIds
}
# Additional FFU file selections
@@ -392,7 +418,6 @@ function Select-VMSwitchFromConfig {
$State.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
$State.Controls.txtCustomVMSwitchName.Text = $configSwitch
$State.Data.customVMSwitchName = $configSwitch
$State.Data.customVMHostIP = $ConfigContent.VMHostIPAddress
WriteLog "LoadConfig: VMSwitchName '$configSwitch' not found. Selected 'Other' and populated custom VM Switch Name textbox."
}
}
@@ -407,12 +432,19 @@ function Update-UIFromConfig {
WriteLog "Applying loaded configuration to the UI."
# Apply theme mode from config (must be done before other controls load for proper styling)
if ($null -ne $ConfigContent.PSObject.Properties.Item('ThemeMode') -and $State.Flags.isFluentSupported) {
$configTheme = $ConfigContent.ThemeMode
if ($configTheme -in @("Light", "Dark", "System")) {
Initialize-FluentTheme -Window $State.Window -ThemeMode $configTheme -State $State
Set-UIValue -ControlName 'cmbThemeMode' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'ThemeMode' -State $State
}
}
# Update Build tab values
Set-UIValue -ControlName 'txtFFUDevPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUDevelopmentPath' -State $State
Set-UIValue -ControlName 'txtCustomFFUNameTemplate' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'CustomFFUNameTemplate' -State $State
Set-UIValue -ControlName 'txtFFUCaptureLocation' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUCaptureLocation' -State $State
Set-UIValue -ControlName 'txtShareName' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ShareName' -State $State
Set-UIValue -ControlName 'txtUsername' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Username' -State $State
Set-UIValue -ControlName 'txtThreads' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Threads' -State $State
Set-UIValue -ControlName 'cmbBitsPriority' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'BitsPriority' -State $State
Set-UIValue -ControlName 'txtMaxUSBDrives' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'MaxUSBDrives' -State $State
@@ -424,37 +456,90 @@ function Update-UIFromConfig {
Set-UIValue -ControlName 'chkAllowExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'AllowExternalHardDiskMedia' -State $State
Set-UIValue -ControlName 'chkPromptExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'PromptExternalHardDiskMedia' -State $State
Set-UIValue -ControlName 'chkCopyAdditionalFFUFiles' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyAdditionalFFUFiles' -State $State
Set-UIValue -ControlName 'chkCreateCaptureMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CreateCaptureMedia' -State $State
Set-UIValue -ControlName 'chkCreateDeploymentMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CreateDeploymentMedia' -State $State
Set-UIValue -ControlName 'chkInjectUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'InjectUnattend' -State $State
Set-UIValue -ControlName 'txtUnattendX64FilePath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'UnattendX64FilePath' -State $State
Set-UIValue -ControlName 'txtUnattendArm64FilePath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'UnattendArm64FilePath' -State $State
Set-UIValue -ControlName 'chkVerbose' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'Verbose' -State $State
if ([string]::IsNullOrWhiteSpace($State.Controls.txtUnattendX64FilePath.Text)) {
$State.Controls.txtUnattendX64FilePath.Text = Get-DefaultUnattendFilePath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text -WindowsArch 'x64'
}
if ([string]::IsNullOrWhiteSpace($State.Controls.txtUnattendArm64FilePath.Text)) {
$State.Controls.txtUnattendArm64FilePath.Text = Get-DefaultUnattendFilePath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text -WindowsArch 'arm64'
}
# USB Drive Modification group (Build Tab)
Set-UIValue -ControlName 'chkCopyAutopilot' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyAutopilot' -State $State
Set-UIValue -ControlName 'chkCopyUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyUnattend' -State $State
Set-UIValue -ControlName 'chkCopyPPKG' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyPPKG' -State $State
Set-UIValue -ControlName 'txtDeviceNamePrefixesPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNamePrefixesPath' -State $State
Set-UIValue -ControlName 'txtDeviceNameSerialComputerNamesPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNameSerialComputerNamesPath' -State $State
Set-UIValue -ControlName 'txtDeviceNameTemplate' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNameTemplate' -State $State
Set-UIValue -ControlName 'txtDeviceNamePrefixes' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNamePrefixes' -TransformValue { param($val) if ($val -is [System.Array]) { $val -join [System.Environment]::NewLine } else { [string]$val } } -State $State
Set-UIValue -ControlName 'txtDeviceNameSerialComputerNames' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNameSerialComputerNames' -TransformValue { param($val) if ($val -is [System.Array]) { $val -join [System.Environment]::NewLine } else { [string]$val } } -State $State
if ([string]::IsNullOrWhiteSpace($State.Controls.txtDeviceNamePrefixesPath.Text)) {
$State.Controls.txtDeviceNamePrefixesPath.Text = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text
}
if ([string]::IsNullOrWhiteSpace($State.Controls.txtDeviceNameSerialComputerNamesPath.Text)) {
$State.Controls.txtDeviceNameSerialComputerNamesPath.Text = Get-DefaultSerialComputerNamesPath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text
}
$loadedDeviceNamingMode = $null
if ($ConfigContent.PSObject.Properties.Name -contains 'DeviceNamingMode') {
$candidateDeviceNamingMode = [string]$ConfigContent.DeviceNamingMode
if ($candidateDeviceNamingMode -in @('Legacy', 'None', 'Prompt', 'Template', 'Prefixes', 'SerialComputerNames')) {
$loadedDeviceNamingMode = $candidateDeviceNamingMode
}
}
$displayDeviceNamingMode = if ($loadedDeviceNamingMode -in @('Prompt', 'Template', 'Prefixes', 'SerialComputerNames')) {
$loadedDeviceNamingMode
}
else {
'None'
}
Set-DeviceNamingModeState -State $State -DisplayMode $displayDeviceNamingMode -LoadedMode $loadedDeviceNamingMode
Import-DeviceNamePrefixesFromConfiguredPath -State $State
Import-SerialComputerNamesFromConfiguredPath -State $State
Update-DeviceNamingControls -State $State
# Post Build Cleanup group (Build Tab)
Set-UIValue -ControlName 'chkCleanupAppsISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupAppsISO' -State $State
Set-UIValue -ControlName 'chkCleanupCaptureISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupCaptureISO' -State $State
Set-UIValue -ControlName 'chkCleanupDeployISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupDeployISO' -State $State
Set-UIValue -ControlName 'chkCleanupDrivers' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupDrivers' -State $State
Set-UIValue -ControlName 'chkRemoveFFU' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveFFU' -State $State
Set-UIValue -ControlName 'chkRemoveApps' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveApps' -State $State
Set-UIValue -ControlName 'chkRemoveUpdates' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveUpdates' -State $State
Set-UIValue -ControlName 'chkRemoveDownloadedESD' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveDownloadedESD' -State $State
# Hyper-V Settings
Set-UIValue -ControlName 'chkEnableVMNetworking' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'EnableVMNetworking' -State $State
Select-VMSwitchFromConfig -State $State -ConfigContent $ConfigContent
Set-UIValue -ControlName 'txtVMHostIPAddress' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'VMHostIPAddress' -State $State
Set-UIValue -ControlName 'txtDiskSize' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Disksize' -TransformValue { param($val) $val / 1GB } -State $State
Set-UIValue -ControlName 'txtMemory' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Memory' -TransformValue { param($val) $val / 1GB } -State $State
Set-UIValue -ControlName 'txtProcessors' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Processors' -State $State
Set-UIValue -ControlName 'txtVMLocation' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'VMLocation' -State $State
Set-UIValue -ControlName 'txtVMNamePrefix' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUPrefix' -State $State
Set-UIValue -ControlName 'cmbLogicalSectorSize' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'LogicalSectorSizeBytes' -TransformValue { param($val) $val.ToString() } -State $State
$State.Controls.spVMNetworkingSettings.IsEnabled = $true -eq $State.Controls.chkEnableVMNetworking.IsChecked
if (-not ($true -eq $State.Controls.chkEnableVMNetworking.IsChecked)) {
$State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
}
# Windows Settings
Set-UIValue -ControlName 'txtISOPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ISOPath' -State $State
# Load Windows Media Source setting
if ($null -ne $ConfigContent.PSObject.Properties.Item('WindowsMediaSource')) {
if ($ConfigContent.WindowsMediaSource -eq 'Provide Windows ISO') {
$State.Controls.rbProvideISO.IsChecked = $true
}
else {
$State.Controls.rbDownloadESD.IsChecked = $true
}
}
# Special handling for Windows Release and SKU due to value collision (e.g., 2019 for Server and LTSC)
if (($null -ne $ConfigContent.PSObject.Properties.Item('WindowsRelease')) -and ($null -ne $ConfigContent.PSObject.Properties.Item('WindowsSKU'))) {
@@ -564,6 +649,7 @@ function Update-UIFromConfig {
Set-UIValue -ControlName 'chkBringYourOwnApps' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'BringYourOwnApps' -State $State
Set-UIValue -ControlName 'txtApplicationPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'AppsPath' -State $State
Set-UIValue -ControlName 'txtAppListJsonPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'AppListPath' -State $State
Set-UIValue -ControlName 'txtUserAppListPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'UserAppListPath' -State $State
# Handle AppsScriptVariables
$appsScriptVarsKeyExists = $false
@@ -627,6 +713,7 @@ function Update-UIFromConfig {
}
# Update the ListView's ItemsSource after populating the data list
$lstAppsScriptVars.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
Request-ListViewColumnAutoResize -ListView $lstAppsScriptVars
# Update the header checkbox state
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
Update-SelectAllHeaderCheckBoxState -ListView $lstAppsScriptVars -HeaderCheckBox $State.Controls.chkSelectAllAppsScriptVariables
@@ -671,7 +758,19 @@ function Update-UIFromConfig {
}
# Match USB drives by UniqueId instead of SerialNumber
if ($propertyExists -and ($propertyValue -eq $item.UniqueId)) {
# USBDriveList values can be a single UniqueId (string) or an array of UniqueIds (multiple same-model drives)
$isMatch = $false
if ($propertyExists) {
if ($propertyValue -is [string]) {
$isMatch = ($propertyValue -eq $item.UniqueId)
}
else {
$propertyValueArray = @($propertyValue)
$isMatch = ($propertyValueArray -contains $item.UniqueId)
}
}
if ($isMatch) {
WriteLog "LoadConfig: Selecting USB Drive Model '$($item.Model)' with UniqueId '$($item.UniqueId)'."
$item.IsSelected = $true
}
@@ -683,6 +782,7 @@ function Update-UIFromConfig {
}
}
$State.Controls.lstUSBDrives.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstUSBDrives
# Update the Select All header checkbox state
$headerChk = $State.Controls.chkSelectAllUSBDrivesHeader
@@ -743,6 +843,7 @@ function Update-UIFromConfig {
}
}
$State.Controls.lstAdditionalFFUs.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAdditionalFFUs
$headerChk = $State.Controls.chkSelectAllAdditionalFFUs
if ($null -ne $headerChk) {
Update-SelectAllHeaderCheckBoxState -ListView $State.Controls.lstAdditionalFFUs -HeaderCheckBox $headerChk
@@ -802,7 +903,7 @@ function Invoke-RestoreDefaults {
$rootPath = $State.FFUDevelopmentPath
# Normalize potential array values to single strings
function Normalize-PathScalar {
function Get-PathScalar {
param([object]$value)
if ($null -eq $value) { return $null }
if ($value -is [System.Array]) {
@@ -817,21 +918,20 @@ function Invoke-RestoreDefaults {
}
$appsPath = Join-Path $rootPath 'Apps'
$driversRaw = Normalize-PathScalar -value $State.Controls.txtDriversFolder.Text
$driversRaw = Get-PathScalar -value $State.Controls.txtDriversFolder.Text
if ([string]::IsNullOrWhiteSpace($driversRaw)) {
$driversPath = Join-Path $rootPath 'Drivers'
}
else {
$driversPath = $driversRaw
}
$ffuCaptureRaw = Normalize-PathScalar -value $State.Controls.txtFFUCaptureLocation.Text
$ffuCaptureRaw = Get-PathScalar -value $State.Controls.txtFFUCaptureLocation.Text
$ffuCapturePath = if ([string]::IsNullOrWhiteSpace($ffuCaptureRaw)) { Join-Path $rootPath 'FFU' } else { $ffuCaptureRaw }
$captureISOPath = Join-Path $rootPath 'WinPECaptureFFUFiles\WinPE-Capture.iso'
$deployISOPath = Join-Path $rootPath 'WinPEDeployFFUFiles\WinPE-Deploy.iso'
$appsISOPath = Join-Path $rootPath 'Apps.iso'
$msg = "Restore Defaults will:`n`n- Delete generated config and app/driver list JSON files`n- Remove ISO files (Capture, Deploy, Apps) if present`n- Remove Apps/Update/downloaded artifacts`n- Remove driver folder contents (not the folder)`n- Remove FFU files in the capture folder`n`nSample/template files and VM/VHDX cache are NOT removed.`n`nProceed?"
$msg = "Restore Defaults will:`n`n- Delete generated config and app/driver list JSON files`n- Remove ISO files (Deploy, Apps) if present`n- Remove Apps/Update/downloaded artifacts`n- Remove driver folder contents (not the folder)`n- Remove FFU files in the capture folder`n`nSample/template files and VM/VHDX cache are NOT removed.`n`nProceed?"
$result = [System.Windows.MessageBox]::Show($msg, "Confirm Restore Defaults", "YesNo", "Warning")
if ($result -ne [System.Windows.MessageBoxResult]::Yes) {
WriteLog "RestoreDefaults: User cancelled."
@@ -867,17 +967,16 @@ function Invoke-RestoreDefaults {
-AppsPath $appsPath `
-DriversPath $driversPath `
-FFUCapturePath $ffuCapturePath `
-CaptureISOPath $captureISOPath `
-DeployISOPath $deployISOPath `
-AppsISOPath $appsISOPath `
-KBPath (Join-Path $rootPath 'KB') `
-RemoveCaptureISO:$true `
-RemoveDeployISO:$true `
-RemoveAppsISO:$true `
-RemoveDrivers:$true `
-RemoveFFU:$true `
-RemoveApps:$true `
-RemoveUpdates:$true
-RemoveUpdates:$true `
-RemoveDownloadedESD:$true
# Clear UI lists / state
if ($null -ne $State.Data.allDriverModels) { $State.Data.allDriverModels.Clear() }
@@ -1005,6 +1104,7 @@ function Import-ConfigSupplementalAssets {
})
}
$State.Controls.lstWingetResults.ItemsSource = $appsBuffer.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
$loadedWinget = $true
if ($null -ne $State.Controls.wingetSearchPanel) {
$State.Controls.wingetSearchPanel.Visibility = 'Visible'
@@ -1139,6 +1239,7 @@ function Import-ConfigSupplementalAssets {
}
}
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
if (Get-Command -Name Update-SelectAllHeaderCheckBoxState -ErrorAction SilentlyContinue) {
$headerChk = $State.Controls.chkSelectAllDriverModels
if ($null -ne $headerChk) {
@@ -212,6 +212,14 @@ function Save-DellDriversTask {
Remove-Item $modelCabPath -Force -ErrorAction SilentlyContinue
if (-not (Test-Path $modelXmlPath)) { throw "Model XML not found after extraction: $modelXmlPath" }
# Track extracted model XML so cancel cleanup can remove it even if file timestamps are preserved from source metadata.
try {
Register-CurrentRunDownloadTarget -Destination $modelXmlPath
}
catch {
WriteLog "Failed to register Dell model XML for current-run cleanup ($modelXmlPath): $($_.Exception.Message)"
}
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Selecting latest drivers...' }
$packages = Get-DellLatestDriverPackages -ModelXmlPath $modelXmlPath -WindowsArch $WindowsArch -WindowsRelease $WindowsRelease
}
@@ -23,10 +23,11 @@ function Get-LenovoDriversModelList {
# If anyone knows how to reliably get the the model and machine type information from Lenovo, let me know.
# https://download.lenovo.com/cdrt/td/catalogv2.xml only provides a subset of the information available from PSREF (e.g. it's missing 300w, 500w, and other consumer models).
# $lenovoCookie = "X-PSREF-USER-TOKEN=eyJ0eXAiOiJKV1QifQ.bjVTdWk0YklZeUc2WnFzL0lXU0pTeU1JcFo0aExzRXl1UGxHN3lnS1BtckI0ZVU5WEJyVGkvaFE0NmVNU2U1ZjNrK3ZqTEVIZ29nTk1TNS9DQmIwQ0pTN1Q1VytlY1RpNzZTUldXbm4wZ1g2RGJuQWg4MXRkTmxKT2YrOW9LRjBzQUZzV05HM3NpcU92WFVTM0o0blM1SDQyUlVXNThIV1VBS2R0c1B2NjJyQjIrUGxNZ2x6RTRhUjY5UDZWclBX.ZDBmM2EyMWRjZTg2N2JmYWMxZDIxY2NiYjQzMWFhNjg1YjEzZTAxNmU2M2RmN2M5ZjIyZWJhMzZkOWI1OWJhZg"
$lenovoCookie = "X-PSREF-USER-TOKEN=eyJ0eXAiOiJKV1QifQ.bjVTdWk0YklZeUc2WnFzL0lXU0pTeU1JcFo0aExzRXl1UGxHN3lnS1BtckI0ZVU5WEJyVGkvaFE0NmVNU2U1ZjNrK3ZqTEVIZ29nTk1TNS9DQmIwQ0pTN1Q1VytlY1RpNzZTUldXbm4wZ1g2RGJuQWg4MXRkTmxKT2YrOW9LRjBzQUZzV05HM3NpcU92WFVTM0o0blM1SDQyUlVXNThIV1VBS2R0c1B2NjJyQjIrUGxNZ2x6RTRhUjY5UDZWclBX.ZDBmM2EyMWRjZTg2N2JmYWMxZDIxY2NiYjQzMWFhNjg1YjEzZTAxNmU2M2RmN2M5ZjIyZWJhMzZkOWI1OWJhZg"
# Wrote a separate function to grab the token. Check the function notes for more details. Keep the above comment for now to see if the cookie ever changes.
$lenovoCookie = Get-LenovoPSREFToken
# 3/25/2026 - The cookie is still the same after 8 months, but we'll keep the retrieval function in case it changes in the future or if we need to get a new one.
# $lenovoCookie = Get-LenovoPSREFToken
# Add the cookie to the headers
$Headers["Cookie"] = $lenovoCookie
@@ -10,72 +10,13 @@ function Get-MicrosoftDriversModelList {
[CmdletBinding()]
param(
[hashtable]$Headers, # Pass necessary headers
[string]$UserAgent # Pass UserAgent
[string]$UserAgent, # Pass UserAgent
[Parameter(Mandatory = $true)]
[string]$DriversFolder
)
$url = "https://support.microsoft.com/en-us/surface/download-drivers-and-firmware-for-surface-09bb2e09-2a4b-cb69-0951-078a7739e120"
$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."
}
# Keep the UI signature unchanged while using the shared Learn-based source.
return @(Get-SurfaceDriverModelIndex -DriversFolder $DriversFolder)
}
# Function to download and extract drivers for a specific Microsoft model (Modified for ForEach-Object -Parallel)
function Save-MicrosoftDriversTask {
@@ -152,6 +93,47 @@ function Save-MicrosoftDriversTask {
### GET THE DOWNLOAD LINK
$status = "Getting download link..."
if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelName -Status $status }
# Initialize Win10/Win11 link variables
$win10Link = $null
$win10FileName = $null
$win11Link = $null
$win11FileName = $null
# Prefer cached Download Center details (Source C) to avoid unnecessary internet calls and cache rewrites
$useCachedDownloadCenterDetails = $false
try {
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
$cachedDetails = @($cache.DownloadCenterDetails | Where-Object { $_.Link -eq $modelLink } | Select-Object -First 1)
if ($cachedDetails.Count -gt 0 -and $cachedDetails[0].Files -and $cachedDetails[0].Files.Count -gt 0) {
$useCachedDownloadCenterDetails = $true
WriteLog "Surface cache: Using cached Download Center details for $modelName from $modelLink"
foreach ($downloadFile in @($cachedDetails[0].Files)) {
if ($null -eq $downloadFile) { continue }
$currentFileName = $downloadFile.Name
$fileUrl = $downloadFile.Url
if ([string]::IsNullOrWhiteSpace($currentFileName) -or [string]::IsNullOrWhiteSpace($fileUrl)) { continue }
if ($currentFileName -match "Win10") {
$win10Link = $fileUrl
$win10FileName = $currentFileName
WriteLog "Found Win10 link (cached): $win10FileName"
}
elseif ($currentFileName -match "Win11") {
$win11Link = $fileUrl
$win11FileName = $currentFileName
WriteLog "Found Win11 link (cached): $win11FileName"
}
}
}
}
catch {
WriteLog "Surface cache: Failed loading cached Download Center details for '$modelName'. Error: $($_.Exception.Message)"
}
# Cache miss: download and parse the model's Download Center page (Source C), then backfill the cache
if (-not $useCachedDownloadCenterDetails) {
WriteLog "Getting download page content for $modelName from $modelLink"
$OriginalVerbosePreference = $VerbosePreference
$VerbosePreference = 'SilentlyContinue'
@@ -172,12 +154,6 @@ function Save-MicrosoftDriversTask {
$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
@@ -195,6 +171,45 @@ function Save-MicrosoftDriversTask {
}
}
# Update local cache with Download Center file details (Source C) for this model.
# This runs during download (not during Get Models) so it won't slow the listview population.
try {
$filesForCache = [System.Collections.Generic.List[pscustomobject]]::new()
if ($win10Link -and $win10FileName) {
$filesForCache.Add([pscustomobject]@{ Name = $win10FileName; Url = $win10Link })
}
if ($win11Link -and $win11FileName) {
$filesForCache.Add([pscustomobject]@{ Name = $win11FileName; Url = $win11Link })
}
if ($filesForCache.Count -gt 0) {
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
$detailsEntry = [pscustomobject][ordered]@{
Model = $modelName
Link = $modelLink
Files = @($filesForCache)
}
$newDetails = [System.Collections.Generic.List[pscustomobject]]::new()
foreach ($item in @($cache.DownloadCenterDetails)) {
if ($null -ne $item -and $item.PSObject.Properties['Link'] -and $item.Link -ne $modelLink) {
$newDetails.Add($item)
}
}
$newDetails.Add($detailsEntry)
$cache.DownloadCenterDetails = @($newDetails)
Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder
}
}
catch {
WriteLog "Surface cache: Failed updating Download Center details cache for '$modelName'. Error: $($_.Exception.Message)"
}
$useCachedDownloadCenterDetails = $true
}
}
if ($useCachedDownloadCenterDetails) {
# Decision logic to select the appropriate download link
$downloadLink = $null
$fileName = $null
@@ -36,9 +36,40 @@ function Get-DriverDisplayName {
}
return "$($BaseName.Trim()) ($($Identifier.Trim()))"
}
}
function Convert-DriverItemToJsonModel {
function Get-EffectiveDriverWindowsRelease {
param(
[Parameter(Mandatory = $true)]
[int]$WindowsRelease,
[string]$WindowsReleaseDisplay,
[string]$WindowsSku
)
# Normalize LTSC/LTSB UI release selections to client driver releases for OEM catalogs.
if (-not [string]::IsNullOrWhiteSpace($WindowsReleaseDisplay)) {
if (($WindowsReleaseDisplay -like 'Windows 10*') -and (($WindowsReleaseDisplay -like '*LTSB*') -or ($WindowsReleaseDisplay -like '*LTSC*'))) {
return 10
}
if (($WindowsReleaseDisplay -like 'Windows 11*') -and ($WindowsReleaseDisplay -like '*LTSC*')) {
return 11
}
}
# Use SKU-based fallback when display text is unavailable.
if (-not [string]::IsNullOrWhiteSpace($WindowsSku) -and $WindowsSku -like '*LTS*') {
if ($WindowsRelease -in 2016, 2019, 2021) {
return 10
}
if ($WindowsRelease -eq 2024) {
return 11
}
}
return $WindowsRelease
}
function Convert-DriverItemToJsonModel {
param(
[Parameter(Mandatory = $true)]
[pscustomobject]$DriverItem
@@ -154,8 +185,20 @@ function Convert-DriverItemToJsonModel {
# Get necessary values from UI or script scope
$localDriversFolder = $State.Controls.txtDriversFolder.Text
$localWindowsRelease = $null
$localWindowsReleaseDisplay = $null
if ($null -ne $State.Controls.cmbWindowsRelease.SelectedItem) {
$localWindowsRelease = $State.Controls.cmbWindowsRelease.SelectedItem.Value
$localWindowsReleaseDisplay = $State.Controls.cmbWindowsRelease.SelectedItem.Display
}
# Resolve effective release used specifically for OEM driver operations.
$localWindowsSku = if ($null -ne $State.Controls.cmbWindowsSKU.SelectedItem) { [string]$State.Controls.cmbWindowsSKU.SelectedItem } else { $null }
$localDriverWindowsRelease = $localWindowsRelease
if ($null -ne $localWindowsRelease) {
$localDriverWindowsRelease = Get-EffectiveDriverWindowsRelease -WindowsRelease $localWindowsRelease -WindowsReleaseDisplay $localWindowsReleaseDisplay -WindowsSku $localWindowsSku
if ($localDriverWindowsRelease -ne $localWindowsRelease) {
WriteLog "Normalized WindowsRelease for model retrieval from $localWindowsRelease to $localDriverWindowsRelease (Display='$localWindowsReleaseDisplay', SKU='$localWindowsSku')."
}
}
# Get headers and user agent from Get-CoreStaticVariables
@@ -170,10 +213,10 @@ function Convert-DriverItemToJsonModel {
switch ($SelectedMake) {
'Microsoft' {
$rawModels = Get-MicrosoftDriversModelList -Headers $Headers -UserAgent $UserAgent
$rawModels = Get-MicrosoftDriversModelList -Headers $Headers -UserAgent $UserAgent -DriversFolder $localDriversFolder
}
'Dell' {
$rawModels = Get-DellDriversModelList -WindowsRelease $localWindowsRelease -DriversFolder $localDriversFolder -Make $SelectedMake
$rawModels = Get-DellDriversModelList -WindowsRelease $localDriverWindowsRelease -DriversFolder $localDriversFolder -Make $SelectedMake
}
'HP' {
$rawModels = Get-HPDriversModelList -DriversFolder $localDriversFolder -Make $SelectedMake
@@ -330,6 +373,7 @@ function Search-DriverModels {
}
# The view will automatically refresh. No need to call .Refresh() explicitly for filtering.
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
$filteredCount = 0
if ($null -ne $collectionView) {
foreach ($item in $collectionView) { $filteredCount++ }
@@ -344,7 +388,10 @@ function Save-DriversJson {
[psobject]$State
)
WriteLog "Save-DriversJson function called."
$selectedDrivers = @($State.Controls.lstDriverModels.Items | Where-Object { $_.IsSelected })
# Save from the master model list so filtered-out selected rows are preserved.
$driverSelectionSource = if ($null -ne $State.Data.allDriverModels) { $State.Data.allDriverModels } else { $State.Controls.lstDriverModels.Items }
$selectedDrivers = @($driverSelectionSource | Where-Object { $_.IsSelected })
if (-not $selectedDrivers) {
[System.Windows.MessageBox]::Show("No drivers selected to save.", "Save Drivers", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
@@ -669,6 +716,7 @@ function Import-DriversJson {
# Update the UI and apply any existing filter
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
Search-DriverModels -filterText $State.Controls.txtModelFilter.Text -State $State
$message = "Driver import complete.`nNew models added: $newModelsAdded`nExisting models updated: $existingModelsUpdated"
@@ -740,6 +788,7 @@ function Invoke-GetModels {
# Update the UI ItemsSource to point to the new list and clear the filter
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
$State.Controls.txtModelFilter.Text = ""
if ($State.Data.allDriverModels.Count -gt 0) {
@@ -831,6 +880,12 @@ function Invoke-DownloadSelectedDrivers {
$localDriversFolder = $State.Controls.txtDriversFolder.Text
$localWindowsRelease = $State.Controls.cmbWindowsRelease.SelectedItem.Value
$localWindowsReleaseDisplay = $State.Controls.cmbWindowsRelease.SelectedItem.Display
$localWindowsSku = if ($null -ne $State.Controls.cmbWindowsSKU.SelectedItem) { [string]$State.Controls.cmbWindowsSKU.SelectedItem } else { $null }
$localDriverWindowsRelease = Get-EffectiveDriverWindowsRelease -WindowsRelease $localWindowsRelease -WindowsReleaseDisplay $localWindowsReleaseDisplay -WindowsSku $localWindowsSku
if ($localDriverWindowsRelease -ne $localWindowsRelease) {
WriteLog "Normalized WindowsRelease for driver download from $localWindowsRelease to $localDriverWindowsRelease (Display='$localWindowsReleaseDisplay', SKU='$localWindowsSku')."
}
$localWindowsArch = $State.Controls.cmbWindowsArch.SelectedItem
$localWindowsVersion = if ($null -ne $State.Controls.cmbWindowsVersion -and $null -ne $State.Controls.cmbWindowsVersion.SelectedItem) { $State.Controls.cmbWindowsVersion.SelectedItem } else { $null }
$coreStaticVars = Get-CoreStaticVariables
@@ -848,10 +903,10 @@ function Invoke-DownloadSelectedDrivers {
WriteLog "Dell drivers selected. Ensuring Dell Catalog is up-to-date..."
try {
$dellDriversFolder = Join-Path -Path $localDriversFolder -ChildPath "Dell"
$catalogBaseName = if ($localWindowsRelease -le 11) { "CatalogIndexPC" } else { "Catalog" }
$catalogBaseName = if ($localDriverWindowsRelease -le 11) { "CatalogIndexPC" } else { "Catalog" }
$dellCabFile = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).cab"
$dellCatalogXML = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).xml"
$catalogUrl = if ($localWindowsRelease -le 11) { "https://downloads.dell.com/catalog/CatalogIndexPC.cab" } else { "https://downloads.dell.com/catalog/Catalog.cab" }
$catalogUrl = if ($localDriverWindowsRelease -le 11) { "https://downloads.dell.com/catalog/CatalogIndexPC.cab" } else { "https://downloads.dell.com/catalog/Catalog.cab" }
$downloadCatalog = $true
if (Test-Path -Path $dellCatalogXML -PathType Leaf) {
@@ -891,7 +946,7 @@ function Invoke-DownloadSelectedDrivers {
$preserveSource = ($State.Controls.chkUseDriversAsPEDrivers.IsChecked -and $State.Controls.chkCompressDriversToWIM.IsChecked)
$taskArguments = @{
DriversFolder = $localDriversFolder
WindowsRelease = $localWindowsRelease
WindowsRelease = $localDriverWindowsRelease
WindowsArch = $localWindowsArch
WindowsVersion = $localWindowsVersion
Headers = $localHeaders
@@ -969,6 +1024,11 @@ function Invoke-DownloadSelectedDrivers {
Model = $modelName
DriverPath = $driverPath
}
if ($driverMetadata.PSObject.Properties['Link'] -and -not [string]::IsNullOrWhiteSpace($driverMetadata.Link)) {
$driverRecord | Add-Member -NotePropertyName Link -NotePropertyValue $driverMetadata.Link
}
if ($driverMetadata.PSObject.Properties['SystemId'] -and -not [string]::IsNullOrWhiteSpace($driverMetadata.SystemId)) {
$driverRecord | Add-Member -NotePropertyName SystemId -NotePropertyValue $driverMetadata.SystemId
}
@@ -5,6 +5,341 @@
This module is dedicated to managing user interactions within the FFU Builder UI. It contains the Register-EventHandlers function, which connects UI controls defined in the XAML to their corresponding actions in the PowerShell backend. This includes handling button clicks, text input validation, checkbox state changes, and list view interactions across all tabs, effectively wiring up the application's front-end to its core logic.
#>
function Update-VMNetworkingControls {
param([PSCustomObject]$State)
$isVmNetworkingEnabled = $true -eq $State.Controls.chkEnableVMNetworking.IsChecked
$State.Controls.spVMNetworkingSettings.IsEnabled = $isVmNetworkingEnabled
if (-not $isVmNetworkingEnabled) {
$State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
return
}
if ($State.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') {
$State.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
if ([string]::IsNullOrWhiteSpace($State.Controls.txtCustomVMSwitchName.Text) -and $null -ne $State.Data.customVMSwitchName) {
$State.Controls.txtCustomVMSwitchName.Text = $State.Data.customVMSwitchName
}
}
else {
$State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
}
}
function Get-SelectedDeviceNamingMode {
param([PSCustomObject]$State)
if ($true -eq $State.Controls.rbDeviceNamingPrompt.IsChecked) {
return 'Prompt'
}
if ($true -eq $State.Controls.rbDeviceNamingTemplate.IsChecked) {
return 'Template'
}
if ($true -eq $State.Controls.rbDeviceNamingPrefixes.IsChecked) {
return 'Prefixes'
}
if ($true -eq $State.Controls.rbDeviceNamingSerialComputerNames.IsChecked) {
return 'SerialComputerNames'
}
return 'None'
}
function Set-DeviceNamingMode {
param(
[PSCustomObject]$State,
[ValidateSet('None', 'Prompt', 'Template', 'Prefixes', 'SerialComputerNames')]
[string]$Mode
)
$State.Controls.rbDeviceNamingNone.IsChecked = $Mode -eq 'None'
$State.Controls.rbDeviceNamingPrompt.IsChecked = $Mode -eq 'Prompt'
$State.Controls.rbDeviceNamingTemplate.IsChecked = $Mode -eq 'Template'
$State.Controls.rbDeviceNamingPrefixes.IsChecked = $Mode -eq 'Prefixes'
$State.Controls.rbDeviceNamingSerialComputerNames.IsChecked = $Mode -eq 'SerialComputerNames'
}
function Set-DeviceNamingModeState {
param(
[PSCustomObject]$State,
[ValidateSet('None', 'Prompt', 'Template', 'Prefixes', 'SerialComputerNames')]
[string]$DisplayMode,
[AllowNull()]
[string]$LoadedMode
)
if ($null -eq $State.Flags) {
$State.Flags = @{}
}
if ($null -eq $State.Data) {
$State.Data = @{}
}
$previousSuppressionState = $true -eq $State.Flags.suppressDeviceNamingChangeTracking
$State.Flags.suppressDeviceNamingChangeTracking = $true
try {
Set-DeviceNamingMode -State $State -Mode $DisplayMode
}
finally {
$State.Flags.suppressDeviceNamingChangeTracking = $previousSuppressionState
}
$State.Data.loadedDeviceNamingMode = if ([string]::IsNullOrWhiteSpace($LoadedMode)) {
$null
}
else {
$LoadedMode.Trim()
}
$State.Flags.deviceNamingModeWasExplicitlyChanged = $false
}
function Get-ConfiguredDeviceNamingMode {
param([PSCustomObject]$State)
if (($null -ne $State.Flags) -and ($true -eq $State.Flags.deviceNamingModeWasExplicitlyChanged)) {
return Get-SelectedDeviceNamingMode -State $State
}
if (($null -ne $State.Data) -and -not [string]::IsNullOrWhiteSpace([string]$State.Data.loadedDeviceNamingMode)) {
return [string]$State.Data.loadedDeviceNamingMode
}
return $null
}
function Get-DeviceNamePrefixes {
param([PSCustomObject]$State)
if ($null -eq $State.Controls.txtDeviceNamePrefixes) {
return @()
}
return @(
$State.Controls.txtDeviceNamePrefixes.Text -split "\r?\n" |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
ForEach-Object { $_.Trim() }
)
}
function Get-SerialComputerNamesLines {
param([PSCustomObject]$State)
if ($null -eq $State.Controls.txtDeviceNameSerialComputerNames) {
return @()
}
return @(
$State.Controls.txtDeviceNameSerialComputerNames.Text -split "\r?\n" |
Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
ForEach-Object { $_.Trim() }
)
}
function Import-DeviceNamePrefixesFile {
param(
[PSCustomObject]$State,
[string]$FilePath
)
if ([string]::IsNullOrWhiteSpace($FilePath) -or -not (Test-Path -Path $FilePath -PathType Leaf)) {
return $false
}
$prefixLines = @(Get-Content -Path $FilePath | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() })
if ($null -ne $State.Controls.txtDeviceNamePrefixesPath) {
$State.Controls.txtDeviceNamePrefixesPath.Text = $FilePath
}
$State.Controls.txtDeviceNamePrefixes.Text = $prefixLines -join [System.Environment]::NewLine
WriteLog "Imported device name prefixes from $FilePath"
return $true
}
function Import-SerialComputerNamesFile {
param(
[PSCustomObject]$State,
[string]$FilePath
)
if ([string]::IsNullOrWhiteSpace($FilePath) -or -not (Test-Path -Path $FilePath -PathType Leaf)) {
return $false
}
$serialMappingLines = @(Get-Content -Path $FilePath | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() })
if ($null -ne $State.Controls.txtDeviceNameSerialComputerNamesPath) {
$State.Controls.txtDeviceNameSerialComputerNamesPath.Text = $FilePath
}
$State.Controls.txtDeviceNameSerialComputerNames.Text = $serialMappingLines -join [System.Environment]::NewLine
WriteLog "Imported serial computer-name mappings from $FilePath"
return $true
}
function Get-DefaultDeviceNamePrefixesPath {
param([string]$FFUDevelopmentPath)
if ([string]::IsNullOrWhiteSpace($FFUDevelopmentPath)) {
return $null
}
return Join-Path (Join-Path $FFUDevelopmentPath 'unattend') 'prefixes.txt'
}
function Get-DefaultSerialComputerNamesPath {
param([string]$FFUDevelopmentPath)
if ([string]::IsNullOrWhiteSpace($FFUDevelopmentPath)) {
return $null
}
return Join-Path (Join-Path $FFUDevelopmentPath 'unattend') 'SerialComputerNames.csv'
}
function Get-DefaultUnattendFilePath {
param(
[string]$FFUDevelopmentPath,
[ValidateSet('x64', 'arm64')]
[string]$WindowsArch
)
if ([string]::IsNullOrWhiteSpace($FFUDevelopmentPath)) {
return $null
}
$fileName = if ($WindowsArch -ieq 'arm64') { 'unattend_arm64.xml' } else { 'unattend_x64.xml' }
return Join-Path (Join-Path $FFUDevelopmentPath 'unattend') $fileName
}
function Import-DeviceNamePrefixesFromConfiguredPath {
param(
[PSCustomObject]$State,
[switch]$SkipIfTextPresent
)
if ($SkipIfTextPresent -and -not [string]::IsNullOrWhiteSpace($State.Controls.txtDeviceNamePrefixes.Text)) {
return
}
$prefixFilePath = $State.Controls.txtDeviceNamePrefixesPath.Text
if ([string]::IsNullOrWhiteSpace($prefixFilePath)) {
$prefixFilePath = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text
if (-not [string]::IsNullOrWhiteSpace($prefixFilePath) -and $null -ne $State.Controls.txtDeviceNamePrefixesPath) {
$State.Controls.txtDeviceNamePrefixesPath.Text = $prefixFilePath
}
}
if (Test-Path -Path $prefixFilePath -PathType Leaf) {
Import-DeviceNamePrefixesFile -State $State -FilePath $prefixFilePath | Out-Null
}
}
function Import-SerialComputerNamesFromConfiguredPath {
param(
[PSCustomObject]$State,
[switch]$SkipIfTextPresent
)
if ($SkipIfTextPresent -and -not [string]::IsNullOrWhiteSpace($State.Controls.txtDeviceNameSerialComputerNames.Text)) {
return
}
$serialComputerNamesPath = $State.Controls.txtDeviceNameSerialComputerNamesPath.Text
if ([string]::IsNullOrWhiteSpace($serialComputerNamesPath)) {
$serialComputerNamesPath = Get-DefaultSerialComputerNamesPath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text
if (-not [string]::IsNullOrWhiteSpace($serialComputerNamesPath) -and $null -ne $State.Controls.txtDeviceNameSerialComputerNamesPath) {
$State.Controls.txtDeviceNameSerialComputerNamesPath.Text = $serialComputerNamesPath
}
}
if (Test-Path -Path $serialComputerNamesPath -PathType Leaf) {
Import-SerialComputerNamesFile -State $State -FilePath $serialComputerNamesPath | Out-Null
}
}
function Test-DeviceNameTemplateUsesSerialToken {
param([PSCustomObject]$State)
return ((Get-SelectedDeviceNamingMode -State $State) -eq 'Template') -and ($State.Controls.txtDeviceNameTemplate.Text -match '(?i)%serial%')
}
function Update-UnattendSelectionControls {
param([PSCustomObject]$State)
$selectedDeviceNamingMode = Get-SelectedDeviceNamingMode -State $State
$isCopyUnattendSelected = $true -eq $State.Controls.chkCopyUnattend.IsChecked
$isInjectUnattendSelected = $true -eq $State.Controls.chkInjectUnattend.IsChecked
$deviceNameTemplateUsesSerialToken = Test-DeviceNameTemplateUsesSerialToken -State $State
$requiresCopiedUnattend = ($selectedDeviceNamingMode -in @('Prompt', 'Prefixes', 'SerialComputerNames')) -or $deviceNameTemplateUsesSerialToken
if ($isCopyUnattendSelected -and $isInjectUnattendSelected) {
if ($requiresCopiedUnattend) {
$State.Controls.chkInjectUnattend.IsChecked = $false
$isInjectUnattendSelected = $false
}
else {
$State.Controls.chkCopyUnattend.IsChecked = $false
$isCopyUnattendSelected = $false
}
}
if ($requiresCopiedUnattend) {
if (-not $isCopyUnattendSelected) {
$State.Controls.chkCopyUnattend.IsChecked = $true
$isCopyUnattendSelected = $true
}
if ($isInjectUnattendSelected) {
$State.Controls.chkInjectUnattend.IsChecked = $false
$isInjectUnattendSelected = $false
}
$State.Controls.chkCopyUnattend.IsEnabled = $false
$State.Controls.chkInjectUnattend.IsEnabled = $false
return
}
if ($isCopyUnattendSelected) {
$State.Controls.chkCopyUnattend.IsEnabled = $true
$State.Controls.chkInjectUnattend.IsEnabled = $false
}
elseif ($isInjectUnattendSelected) {
$State.Controls.chkCopyUnattend.IsEnabled = $false
$State.Controls.chkInjectUnattend.IsEnabled = $true
}
else {
$State.Controls.chkCopyUnattend.IsEnabled = $true
$State.Controls.chkInjectUnattend.IsEnabled = $true
}
}
function Update-DeviceNamingControls {
param([PSCustomObject]$State)
if (($true -eq $State.Controls.chkInjectUnattend.IsChecked) -and (($true -eq $State.Controls.rbDeviceNamingPrompt.IsChecked) -or ($true -eq $State.Controls.rbDeviceNamingPrefixes.IsChecked) -or ($true -eq $State.Controls.rbDeviceNamingSerialComputerNames.IsChecked))) {
$State.Controls.rbDeviceNamingNone.IsChecked = $true
}
$selectedDeviceNamingMode = Get-SelectedDeviceNamingMode -State $State
$State.Controls.deviceNameTemplatePanel.Visibility = if ($selectedDeviceNamingMode -eq 'Template') { 'Visible' } else { 'Collapsed' }
$State.Controls.deviceNamePrefixesPanel.Visibility = if ($selectedDeviceNamingMode -eq 'Prefixes') { 'Visible' } else { 'Collapsed' }
$State.Controls.deviceNameSerialComputerNamesPanel.Visibility = if ($selectedDeviceNamingMode -eq 'SerialComputerNames') { 'Visible' } else { 'Collapsed' }
$State.Controls.rbDeviceNamingPrompt.IsEnabled = -not ($true -eq $State.Controls.chkInjectUnattend.IsChecked)
$State.Controls.rbDeviceNamingPrefixes.IsEnabled = -not ($true -eq $State.Controls.chkInjectUnattend.IsChecked)
$State.Controls.rbDeviceNamingSerialComputerNames.IsEnabled = -not ($true -eq $State.Controls.chkInjectUnattend.IsChecked)
if ($selectedDeviceNamingMode -eq 'Prefixes') {
Import-DeviceNamePrefixesFromConfiguredPath -State $State -SkipIfTextPresent
}
elseif ($selectedDeviceNamingMode -eq 'SerialComputerNames') {
Import-SerialComputerNamesFromConfiguredPath -State $State -SkipIfTextPresent
}
Update-UnattendSelectionControls -State $State
}
function Register-EventHandlers {
param([PSCustomObject]$State)
WriteLog "Registering UI event handlers..."
@@ -24,7 +359,7 @@ function Register-EventHandlers {
# Define a handler to validate pasted text, ensuring it's only integers
$integerPastingHandler = {
param($sender, $pastingEventArgs)
param($eventSource, $pastingEventArgs)
if ($pastingEventArgs.DataObject.GetDataPresent([string])) {
$pastedText = $pastingEventArgs.DataObject.GetData([string])
# Check if the pasted text consists ONLY of one or more digits.
@@ -87,6 +422,132 @@ function Register-EventHandlers {
})
}
# Navigation Sidebar Event Handlers
# Main navigation list - switches content pages based on selected nav item
if ($null -ne $State.Controls.lstNavigation) {
$State.Controls.lstNavigation.Add_SelectionChanged({
param($eventSource, $selectionChangedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
if ($null -eq $window -or $null -eq $window.Tag) {
return
}
$localState = $window.Tag
$selectedIndex = $eventSource.SelectedIndex
if ($selectedIndex -lt 0) { return }
# Clear Settings selection when main nav is used
if ($null -ne $localState.Controls.lstNavSettings) {
$localState.Controls.lstNavSettings.SelectedIndex = -1
}
# Hide all content pages
foreach ($page in $localState.Controls.navigationPages) {
if ($null -ne $page) { $page.Visibility = 'Collapsed' }
}
if ($null -ne $localState.Controls.pageSettings) {
$localState.Controls.pageSettings.Visibility = 'Collapsed'
}
# Show the selected page
if ($selectedIndex -lt $localState.Controls.navigationPages.Count) {
$localState.Controls.navigationPages[$selectedIndex].Visibility = 'Visible'
}
# Update the shared page title to match the selected navigation item
if ($null -ne $localState.Controls.txtPageTitle) {
$selectedNavigationItem = $eventSource.SelectedItem
if ($null -ne $selectedNavigationItem -and -not [string]::IsNullOrWhiteSpace([string]$selectedNavigationItem.Tag)) {
$localState.Controls.txtPageTitle.Text = [string]$selectedNavigationItem.Tag
}
}
})
}
# Settings navigation item
if ($null -ne $State.Controls.lstNavSettings) {
$State.Controls.lstNavSettings.Add_SelectionChanged({
param($eventSource, $selectionChangedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
if ($null -eq $window -or $null -eq $window.Tag) {
return
}
$localState = $window.Tag
if ($eventSource.SelectedIndex -lt 0) { return }
# Clear main navigation selection
if ($null -ne $localState.Controls.lstNavigation) {
$localState.Controls.lstNavigation.SelectedIndex = -1
}
# Hide all content pages
foreach ($page in $localState.Controls.navigationPages) {
if ($null -ne $page) { $page.Visibility = 'Collapsed' }
}
# Show Settings page
if ($null -ne $localState.Controls.pageSettings) {
$localState.Controls.pageSettings.Visibility = 'Visible'
}
# Update the shared page title to match the selected navigation item
if ($null -ne $localState.Controls.txtPageTitle) {
$selectedNavigationItem = $eventSource.SelectedItem
if ($null -ne $selectedNavigationItem -and -not [string]::IsNullOrWhiteSpace([string]$selectedNavigationItem.Tag)) {
$localState.Controls.txtPageTitle.Text = [string]$selectedNavigationItem.Tag
}
else {
$localState.Controls.txtPageTitle.Text = 'Settings'
}
}
})
}
# Hyperlink navigation handlers for Home page links
$hyperlinkNames = @(
'linkQuickStart',
'linkDocs',
'linkGitHub',
'linkReleases',
'linkChangelog',
'linkVideo1',
'linkDiscussion1',
'linkDiscussion2',
'linkDiscussion3',
'linkDiscussion4',
'linkDiscussion5',
'linkDiscussions'
)
foreach ($linkName in $hyperlinkNames) {
$link = $State.Window.FindName($linkName)
if ($null -ne $link) {
$link.Add_RequestNavigate({
param($eventSource, $requestNavigateEventArgs)
Start-Process $requestNavigateEventArgs.Uri.AbsoluteUri
$requestNavigateEventArgs.Handled = $true
})
}
}
# Settings Page Event Handlers
# Theme mode selector - switches between Light, Dark, and System Fluent themes
if ($null -ne $State.Controls.cmbThemeMode) {
$State.Controls.cmbThemeMode.Add_SelectionChanged({
param($eventSource, $selectionChangedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
if ($null -eq $window -or $null -eq $window.Tag) {
return
}
$localState = $window.Tag
if (-not $localState.Flags.isFluentSupported) {
return
}
$selectedTheme = $eventSource.SelectedItem
if (-not [string]::IsNullOrWhiteSpace($selectedTheme)) {
Initialize-FluentTheme -Window $window -ThemeMode $selectedTheme -State $localState
}
})
}
# Build Tab Event Handlers
$State.Controls.btnBrowseFFUDevPath.Add_Click({
param($eventSource, $routedEventArgs)
@@ -94,7 +555,34 @@ function Register-EventHandlers {
$localState = $window.Tag
$selectedPath = Invoke-BrowseAction -Type 'Folder' -Title "Select FFU Development Path"
if ($selectedPath) {
$currentPrefixesPath = $localState.Controls.txtDeviceNamePrefixesPath.Text
$currentSerialComputerNamesPath = $localState.Controls.txtDeviceNameSerialComputerNamesPath.Text
$currentUnattendX64FilePath = $localState.Controls.txtUnattendX64FilePath.Text
$currentUnattendArm64FilePath = $localState.Controls.txtUnattendArm64FilePath.Text
$previousDefaultPrefixesPath = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text
$previousDefaultSerialComputerNamesPath = Get-DefaultSerialComputerNamesPath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text
$previousDefaultUnattendX64FilePath = Get-DefaultUnattendFilePath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text -WindowsArch 'x64'
$previousDefaultUnattendArm64FilePath = Get-DefaultUnattendFilePath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text -WindowsArch 'arm64'
$localState.Controls.txtFFUDevPath.Text = $selectedPath
$newDefaultPrefixesPath = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $selectedPath
$newDefaultSerialComputerNamesPath = Get-DefaultSerialComputerNamesPath -FFUDevelopmentPath $selectedPath
$newDefaultUnattendX64FilePath = Get-DefaultUnattendFilePath -FFUDevelopmentPath $selectedPath -WindowsArch 'x64'
$newDefaultUnattendArm64FilePath = Get-DefaultUnattendFilePath -FFUDevelopmentPath $selectedPath -WindowsArch 'arm64'
if ([string]::IsNullOrWhiteSpace($currentPrefixesPath) -or $currentPrefixesPath -ieq $previousDefaultPrefixesPath) {
$localState.Controls.txtDeviceNamePrefixesPath.Text = $newDefaultPrefixesPath
}
if ([string]::IsNullOrWhiteSpace($currentSerialComputerNamesPath) -or $currentSerialComputerNamesPath -ieq $previousDefaultSerialComputerNamesPath) {
$localState.Controls.txtDeviceNameSerialComputerNamesPath.Text = $newDefaultSerialComputerNamesPath
}
if ([string]::IsNullOrWhiteSpace($currentUnattendX64FilePath) -or $currentUnattendX64FilePath -ieq $previousDefaultUnattendX64FilePath) {
$localState.Controls.txtUnattendX64FilePath.Text = $newDefaultUnattendX64FilePath
}
if ([string]::IsNullOrWhiteSpace($currentUnattendArm64FilePath) -or $currentUnattendArm64FilePath -ieq $previousDefaultUnattendArm64FilePath) {
$localState.Controls.txtUnattendArm64FilePath.Text = $newDefaultUnattendArm64FilePath
}
Import-DeviceNamePrefixesFromConfiguredPath -State $localState
Import-SerialComputerNamesFromConfiguredPath -State $localState
Update-DeviceNamingControls -State $localState
}
})
@@ -108,19 +596,246 @@ function Register-EventHandlers {
}
})
$State.Controls.rbDeviceNamingNone.Add_Checked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
if (-not ($true -eq $localState.Flags.suppressDeviceNamingChangeTracking)) {
$localState.Flags.deviceNamingModeWasExplicitlyChanged = $true
$localState.Data.loadedDeviceNamingMode = $null
}
Update-DeviceNamingControls -State $localState
})
$State.Controls.rbDeviceNamingPrompt.Add_Checked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
if (-not ($true -eq $localState.Flags.suppressDeviceNamingChangeTracking)) {
$localState.Flags.deviceNamingModeWasExplicitlyChanged = $true
$localState.Data.loadedDeviceNamingMode = $null
}
Update-DeviceNamingControls -State $localState
})
$State.Controls.rbDeviceNamingTemplate.Add_Checked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
if (-not ($true -eq $localState.Flags.suppressDeviceNamingChangeTracking)) {
$localState.Flags.deviceNamingModeWasExplicitlyChanged = $true
$localState.Data.loadedDeviceNamingMode = $null
}
Update-DeviceNamingControls -State $localState
})
$State.Controls.txtDeviceNameTemplate.Add_TextChanged({
param($eventSource, $textChangedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
if ($null -ne $window -and $null -ne $window.Tag) {
Update-DeviceNamingControls -State $window.Tag
}
})
$State.Controls.rbDeviceNamingPrefixes.Add_Checked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
if (-not ($true -eq $localState.Flags.suppressDeviceNamingChangeTracking)) {
$localState.Flags.deviceNamingModeWasExplicitlyChanged = $true
$localState.Data.loadedDeviceNamingMode = $null
}
Update-DeviceNamingControls -State $localState
})
$State.Controls.rbDeviceNamingSerialComputerNames.Add_Checked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
if (-not ($true -eq $localState.Flags.suppressDeviceNamingChangeTracking)) {
$localState.Flags.deviceNamingModeWasExplicitlyChanged = $true
$localState.Data.loadedDeviceNamingMode = $null
}
Update-DeviceNamingControls -State $localState
})
$State.Controls.btnBrowseDeviceNamePrefixesPath.Add_Click({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
$currentPrefixesPath = $localState.Controls.txtDeviceNamePrefixesPath.Text
if ([string]::IsNullOrWhiteSpace($currentPrefixesPath)) {
$currentPrefixesPath = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text
}
$initialDirectory = if ([string]::IsNullOrWhiteSpace($currentPrefixesPath)) {
$null
}
else {
Split-Path $currentPrefixesPath -Parent
}
$fileName = if ([string]::IsNullOrWhiteSpace($currentPrefixesPath)) { 'prefixes.txt' } else { Split-Path $currentPrefixesPath -Leaf }
$selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title 'Select prefixes file path' -Filter 'Text files (*.txt)|*.txt|All files (*.*)|*.*' -InitialDirectory $initialDirectory -FileName $fileName
if (Import-DeviceNamePrefixesFile -State $localState -FilePath $selectedPath) {
Update-DeviceNamingControls -State $localState
}
})
$State.Controls.btnBrowseDeviceNameSerialComputerNamesPath.Add_Click({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
$currentSerialComputerNamesPath = $localState.Controls.txtDeviceNameSerialComputerNamesPath.Text
if ([string]::IsNullOrWhiteSpace($currentSerialComputerNamesPath)) {
$currentSerialComputerNamesPath = Get-DefaultSerialComputerNamesPath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text
}
$initialDirectory = if ([string]::IsNullOrWhiteSpace($currentSerialComputerNamesPath)) {
$null
}
else {
Split-Path $currentSerialComputerNamesPath -Parent
}
$fileName = if ([string]::IsNullOrWhiteSpace($currentSerialComputerNamesPath)) { 'SerialComputerNames.csv' } else { Split-Path $currentSerialComputerNamesPath -Leaf }
$selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title 'Select Serial Computer Names CSV Mapping File Path' -Filter 'CSV files (*.csv)|*.csv|All files (*.*)|*.*' -InitialDirectory $initialDirectory -FileName $fileName
if (Import-SerialComputerNamesFile -State $localState -FilePath $selectedPath) {
Update-DeviceNamingControls -State $localState
}
})
$State.Controls.btnBrowseUnattendX64FilePath.Add_Click({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
$currentUnattendX64FilePath = $localState.Controls.txtUnattendX64FilePath.Text
if ([string]::IsNullOrWhiteSpace($currentUnattendX64FilePath)) {
$currentUnattendX64FilePath = Get-DefaultUnattendFilePath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text -WindowsArch 'x64'
}
$initialDirectory = if ([string]::IsNullOrWhiteSpace($currentUnattendX64FilePath)) {
$null
}
else {
Split-Path $currentUnattendX64FilePath -Parent
}
$fileName = if ([string]::IsNullOrWhiteSpace($currentUnattendX64FilePath)) { 'unattend_x64.xml' } else { Split-Path $currentUnattendX64FilePath -Leaf }
$selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title 'Select x64 unattend XML file' -Filter 'XML files (*.xml)|*.xml|All files (*.*)|*.*' -InitialDirectory $initialDirectory -FileName $fileName
if (-not [string]::IsNullOrWhiteSpace($selectedPath)) {
$localState.Controls.txtUnattendX64FilePath.Text = $selectedPath
}
})
$State.Controls.btnBrowseUnattendArm64FilePath.Add_Click({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
$currentUnattendArm64FilePath = $localState.Controls.txtUnattendArm64FilePath.Text
if ([string]::IsNullOrWhiteSpace($currentUnattendArm64FilePath)) {
$currentUnattendArm64FilePath = Get-DefaultUnattendFilePath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text -WindowsArch 'arm64'
}
$initialDirectory = if ([string]::IsNullOrWhiteSpace($currentUnattendArm64FilePath)) {
$null
}
else {
Split-Path $currentUnattendArm64FilePath -Parent
}
$fileName = if ([string]::IsNullOrWhiteSpace($currentUnattendArm64FilePath)) { 'unattend_arm64.xml' } else { Split-Path $currentUnattendArm64FilePath -Leaf }
$selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title 'Select arm64 unattend XML file' -Filter 'XML files (*.xml)|*.xml|All files (*.*)|*.*' -InitialDirectory $initialDirectory -FileName $fileName
if (-not [string]::IsNullOrWhiteSpace($selectedPath)) {
$localState.Controls.txtUnattendArm64FilePath.Text = $selectedPath
}
})
$State.Controls.btnSaveDeviceNamePrefixes.Add_Click({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
$prefixLines = @(Get-DeviceNamePrefixes -State $localState)
if ($prefixLines.Count -eq 0) {
[System.Windows.MessageBox]::Show("Enter at least one prefix before saving the prefixes file.", "Prefixes Required", "OK", "Warning") | Out-Null
return
}
$currentPrefixesPath = $localState.Controls.txtDeviceNamePrefixesPath.Text
if ([string]::IsNullOrWhiteSpace($currentPrefixesPath)) {
$currentPrefixesPath = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text
if (-not [string]::IsNullOrWhiteSpace($currentPrefixesPath)) {
$localState.Controls.txtDeviceNamePrefixesPath.Text = $currentPrefixesPath
}
}
if ([string]::IsNullOrWhiteSpace($currentPrefixesPath)) {
[System.Windows.MessageBox]::Show("Select a valid Prefixes File Path before saving prefixes.", "Prefixes File Path Required", "OK", "Warning") | Out-Null
return
}
try {
$prefixLines | Set-Content -Path $currentPrefixesPath -Encoding UTF8
$localState.Controls.txtDeviceNamePrefixesPath.Text = $currentPrefixesPath
WriteLog "Saved device name prefixes to $currentPrefixesPath"
}
catch {
[System.Windows.MessageBox]::Show("Saving prefixes failed for '$currentPrefixesPath'. $($_.Exception.Message)", "Save Prefixes Failed", "OK", "Error") | Out-Null
}
})
$State.Controls.btnSaveDeviceNameSerialComputerNames.Add_Click({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
$serialComputerNameLines = @(Get-SerialComputerNamesLines -State $localState)
if ($serialComputerNameLines.Count -eq 0) {
[System.Windows.MessageBox]::Show("Enter CSV content before saving the serial mapping file.", "Serial Mapping Required", "OK", "Warning") | Out-Null
return
}
$currentSerialComputerNamesPath = $localState.Controls.txtDeviceNameSerialComputerNamesPath.Text
if ([string]::IsNullOrWhiteSpace($currentSerialComputerNamesPath)) {
$currentSerialComputerNamesPath = Get-DefaultSerialComputerNamesPath -FFUDevelopmentPath $localState.Controls.txtFFUDevPath.Text
if (-not [string]::IsNullOrWhiteSpace($currentSerialComputerNamesPath)) {
$localState.Controls.txtDeviceNameSerialComputerNamesPath.Text = $currentSerialComputerNamesPath
}
}
if ([string]::IsNullOrWhiteSpace($currentSerialComputerNamesPath)) {
[System.Windows.MessageBox]::Show("Select a valid Serial Computer Names CSV Mapping File Path before saving the serial mapping.", "Serial Mapping File Path Required", "OK", "Warning") | Out-Null
return
}
try {
$serialComputerNameLines | Set-Content -Path $currentSerialComputerNamesPath -Encoding UTF8
$localState.Controls.txtDeviceNameSerialComputerNamesPath.Text = $currentSerialComputerNamesPath
WriteLog "Saved serial computer-name mappings to $currentSerialComputerNamesPath"
}
catch {
[System.Windows.MessageBox]::Show("Saving serial mapping failed for '$currentSerialComputerNamesPath'. $($_.Exception.Message)", "Save Serial Mapping Failed", "OK", "Error") | Out-Null
}
})
$State.Controls.chkCopyUnattend.Add_Checked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
$localState.Controls.chkInjectUnattend.IsChecked = $false
Update-DeviceNamingControls -State $localState
})
$State.Controls.chkCopyUnattend.Add_Unchecked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
Update-DeviceNamingControls -State $window.Tag
})
$State.Controls.chkInjectUnattend.Add_Checked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
$localState.Controls.chkCopyUnattend.IsChecked = $false
Update-DeviceNamingControls -State $localState
})
$State.Controls.chkInjectUnattend.Add_Unchecked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
Update-DeviceNamingControls -State $window.Tag
})
# Build USB Drive Settings Event Handlers
# The USB Expander is always visible; the checkbox controls child settings only
$State.Controls.chkBuildUSBDriveEnable.Add_Checked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
$localState.Controls.usbSection.Visibility = 'Visible'
$localState.Controls.chkSelectSpecificUSBDrives.IsEnabled = $true
})
$State.Controls.chkBuildUSBDriveEnable.Add_Unchecked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
$localState.Controls.usbSection.Visibility = 'Collapsed'
$localState.Controls.chkSelectSpecificUSBDrives.IsEnabled = $false
$localState.Controls.chkSelectSpecificUSBDrives.IsChecked = $false
$localState.Controls.lstUSBDrives.Items.Clear()
@@ -220,6 +935,7 @@ function Register-EventHandlers {
$driveObject | Add-Member -MemberType NoteProperty -Name 'IsSelected' -Value $false -Force
$localState.Controls.lstUSBDrives.Items.Add($driveObject)
}
Request-ListViewColumnAutoResize -ListView $localState.Controls.lstUSBDrives
if ($localState.Controls.lstUSBDrives.Items.Count -gt 0) {
$localState.Controls.lstUSBDrives.SelectedIndex = 0
}
@@ -253,42 +969,30 @@ function Register-EventHandlers {
})
# Hyper-V tab event handlers
$State.Controls.chkEnableVMNetworking.Add_Checked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
Update-VMNetworkingControls -State $localState
})
$State.Controls.chkEnableVMNetworking.Add_Unchecked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
Update-VMNetworkingControls -State $localState
})
$State.Controls.cmbVMSwitchName.Add_SelectionChanged({
param($eventSource, $selectionChangedEventArgs)
# The state object is available via the parent window's Tag property
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
$selectedItem = $eventSource.SelectedItem
if ($selectedItem -eq 'Other') {
$localState.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
if ([string]::IsNullOrWhiteSpace($localState.Controls.txtCustomVMSwitchName.Text) -and $null -ne $localState.Data.customVMSwitchName) {
$localState.Controls.txtCustomVMSwitchName.Text = $localState.Data.customVMSwitchName
}
if ($null -ne $localState.Data.customVMHostIP -and -not [string]::IsNullOrWhiteSpace($localState.Data.customVMHostIP)) {
$localState.Controls.txtVMHostIPAddress.Text = $localState.Data.customVMHostIP
}
}
else {
$localState.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
if ($null -ne $selectedItem -and $localState.Data.vmSwitchMap.ContainsKey($selectedItem)) {
$localState.Controls.txtVMHostIPAddress.Text = $localState.Data.vmSwitchMap[$selectedItem]
}
else {
$localState.Controls.txtVMHostIPAddress.Text = '' # Clear IP if not found or key null
}
}
Update-VMNetworkingControls -State $localState
})
# Persist custom VM switch name/IP when user edits them while 'Other' is selected
$State.Controls.txtVMHostIPAddress.Add_LostFocus({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
if ($localState.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') {
$localState.Data.customVMHostIP = $localState.Controls.txtVMHostIPAddress.Text
}
})
# Persist custom VM switch name when user edits it while 'Other' is selected
$State.Controls.txtCustomVMSwitchName.Add_LostFocus({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
@@ -299,12 +1003,31 @@ function Register-EventHandlers {
})
# Windows Settings tab Event Handlers
$State.Controls.txtISOPath.Add_TextChanged({
param($eventSource, $textChangedEventArgs)
# Windows Media Source radio buttons
if ($null -ne $State.Controls.rbProvideISO) {
$State.Controls.rbProvideISO.Add_Checked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
if ($null -eq $window -or $null -eq $window.Tag) { return }
$localState = $window.Tag
Get-WindowsSettingsCombos -isoPath $localState.Controls.txtISOPath.Text -State $localState
$localState.Controls.isoPathPanel.Visibility = 'Visible'
# Use a placeholder .iso path to trigger ISO mode even before a real path is provided
$isoPath = $localState.Controls.txtISOPath.Text
if ([string]::IsNullOrWhiteSpace($isoPath)) {
$isoPath = 'placeholder.iso'
}
Get-WindowsSettingsCombos -isoPath $isoPath -State $localState
})
$State.Controls.rbProvideISO.Add_Unchecked({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
if ($null -eq $window -or $null -eq $window.Tag) { return }
$localState = $window.Tag
$localState.Controls.isoPathPanel.Visibility = 'Collapsed'
$localState.Controls.txtISOPath.Text = ''
Get-WindowsSettingsCombos -isoPath '' -State $localState
})
}
$State.Controls.cmbWindowsRelease.Add_SelectionChanged({
param($eventSource, $selectionChangedEventArgs)
@@ -314,9 +1037,18 @@ function Register-EventHandlers {
if ($null -ne $localState.Controls.cmbWindowsRelease.SelectedItem) {
$selectedReleaseValue = $localState.Controls.cmbWindowsRelease.SelectedItem.Value
}
Update-WindowsVersionCombo -selectedRelease $selectedReleaseValue -isoPath $localState.Controls.txtISOPath.Text -State $localState
# Determine ISO path based on radio button state
$isoPath = ''
if ($null -ne $localState.Controls.rbProvideISO -and $localState.Controls.rbProvideISO.IsChecked) {
$isoPath = $localState.Controls.txtISOPath.Text
if ([string]::IsNullOrWhiteSpace($isoPath)) { $isoPath = 'placeholder.iso' }
}
Update-WindowsVersionCombo -selectedRelease $selectedReleaseValue -isoPath $isoPath -State $localState
Update-WindowsSkuCombo -State $localState
Update-WindowsArchCombo -State $localState
# Re-evaluate Install Apps dependency when Windows release changes
Update-InstallAppsState -State $localState
})
$State.Controls.cmbWindowsVersion.Add_SelectionChanged({
@@ -369,6 +1101,8 @@ function Register-EventHandlers {
$State.Controls.chkUpdateOneDrive.Add_Unchecked($updateCheckboxHandler)
$State.Controls.chkUpdateLatestMSRT.Add_Checked($updateCheckboxHandler)
$State.Controls.chkUpdateLatestMSRT.Add_Unchecked($updateCheckboxHandler)
$State.Controls.chkUpdateLatestCU.Add_Checked($updateCheckboxHandler)
$State.Controls.chkUpdateLatestCU.Add_Unchecked($updateCheckboxHandler)
# Also attach the handler to the Office checkbox
$State.Controls.chkInstallOffice.Add_Checked($updateCheckboxHandler)
@@ -430,10 +1164,18 @@ function Register-EventHandlers {
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
$selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title "Select AppList.json File" -Filter "JSON files (*.json)|*.json" -AllowNewFile
$selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title "Select Winget AppList File" -Filter "JSON files (*.json)|*.json" -AllowNewFile
if ($selectedPath) { $localState.Controls.txtAppListJsonPath.Text = $selectedPath }
})
$State.Controls.btnBrowseUserAppListPath.Add_Click({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
$selectedPath = Invoke-BrowseAction -Type 'OpenFile' -Title "Select BYO AppList File" -Filter "JSON files (*.json)|*.json" -AllowNewFile
if ($selectedPath) { $localState.Controls.txtUserAppListPath.Text = $selectedPath }
})
$State.Controls.btnBrowseAppSource.Add_Click({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
@@ -461,17 +1203,23 @@ function Register-EventHandlers {
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
$initialDir = $localState.Controls.txtApplicationPath.Text
# Default the save dialog to the configured BYO app list path.
$currentPath = $localState.Controls.txtUserAppListPath.Text
$initialDir = if (-not [string]::IsNullOrWhiteSpace($currentPath)) { Split-Path -Path $currentPath -Parent } else { $localState.Controls.txtApplicationPath.Text }
if ([string]::IsNullOrWhiteSpace($initialDir) -or -not (Test-Path $initialDir)) { $initialDir = $localState.FFUDevelopmentPath }
$fileName = if (-not [string]::IsNullOrWhiteSpace($currentPath)) { Split-Path -Path $currentPath -Leaf } else { "UserAppList.json" }
$savePath = Invoke-BrowseAction -Type 'SaveFile' `
-Title "Save Application List" `
-Title "Save BYO App List" `
-Filter "JSON files (*.json)|*.json|All files (*.*)|*.*" `
-InitialDirectory $initialDir `
-FileName "UserAppList.json" `
-FileName $fileName `
-DefaultExt ".json"
if ($savePath) { Save-BYOApplicationList -Path $savePath -State $localState }
if ($savePath) {
$localState.Controls.txtUserAppListPath.Text = $savePath
Save-BYOApplicationList -Path $savePath -State $localState
}
})
$State.Controls.btnLoadBYOApplications.Add_Click({
@@ -479,15 +1227,18 @@ function Register-EventHandlers {
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
$initialDir = $localState.Controls.txtApplicationPath.Text
# Default the import dialog to the configured BYO app list path.
$currentPath = $localState.Controls.txtUserAppListPath.Text
$initialDir = if (-not [string]::IsNullOrWhiteSpace($currentPath)) { Split-Path -Path $currentPath -Parent } else { $localState.Controls.txtApplicationPath.Text }
if ([string]::IsNullOrWhiteSpace($initialDir) -or -not (Test-Path $initialDir)) { $initialDir = $localState.FFUDevelopmentPath }
$loadPath = Invoke-BrowseAction -Type 'OpenFile' `
-Title "Import Application List" `
-Title "Import BYO App List" `
-Filter "JSON files (*.json)|*.json|All files (*.*)|*.*" `
-InitialDirectory $initialDir
if ($loadPath) {
$localState.Controls.txtUserAppListPath.Text = $loadPath
Import-BYOApplicationList -Path $loadPath -State $localState
Update-CopyButtonState -State $localState
}
@@ -13,6 +13,97 @@
This module is critical for setting up the initial state of the application window when it first loads.
#>
function Initialize-FluentTheme {
param(
[Parameter(Mandatory = $true)]
[System.Windows.Window]$Window,
[Parameter(Mandatory = $false)]
[string]$ThemeMode = "System",
[Parameter(Mandatory = $false)]
[PSCustomObject]$State
)
# Check if the current .NET runtime supports Window.ThemeMode (requires .NET 9+ / PowerShell 7.5+)
$themeModeProperty = [System.Windows.Window].GetProperty("ThemeMode")
if ($null -eq $themeModeProperty) {
WriteLog "Fluent theme not available. Window.ThemeMode requires PowerShell 7.5+ (.NET 9+). Using default Aero2 theme."
if ($null -ne $State) {
$State.Flags.isFluentSupported = $false
}
# Still create tooltip styles for non-Fluent mode so Tag-to-ToolTip binding works
$controlTypes = @(
[System.Windows.Controls.TextBox],
[System.Windows.Controls.TextBlock],
[System.Windows.Controls.CheckBox]
)
foreach ($controlType in $controlTypes) {
$newStyle = New-Object System.Windows.Style($controlType)
$toolTipBinding = New-Object System.Windows.Data.Binding("Tag")
$toolTipBinding.RelativeSource = [System.Windows.Data.RelativeSource]::new([System.Windows.Data.RelativeSourceMode]::Self)
$toolTipSetter = New-Object System.Windows.Setter([System.Windows.FrameworkElement]::ToolTipProperty, $toolTipBinding)
$newStyle.Setters.Add($toolTipSetter)
if ($Window.Resources.Contains($controlType)) {
$Window.Resources.Remove($controlType)
}
$Window.Resources.Add($controlType, $newStyle)
}
WriteLog "Tooltip styles created for non-Fluent mode."
return
}
# Mark Fluent as supported in state
if ($null -ne $State) {
$State.Flags.isFluentSupported = $true
}
# Resolve the ThemeMode enum value using reflection to avoid compile-time experimental attribute issues
$themeModeType = [System.Windows.Window].GetProperty("ThemeMode").PropertyType
$themeModeValue = $null
switch ($ThemeMode) {
"Light" { $themeModeValue = $themeModeType::Light }
"Dark" { $themeModeValue = $themeModeType::Dark }
"System" { $themeModeValue = $themeModeType::System }
default { $themeModeValue = $themeModeType::System }
}
# Apply the Fluent theme mode to the window
$themeModeProperty.SetValue($Window, $themeModeValue)
WriteLog "Applied Fluent theme: $ThemeMode"
# Re-create implicit tooltip styles with BasedOn pointing to the Fluent base style
# This preserves the Tag-to-ToolTip binding while inheriting Fluent visual styling
$controlTypes = @(
[System.Windows.Controls.TextBox],
[System.Windows.Controls.TextBlock],
[System.Windows.Controls.CheckBox]
)
foreach ($controlType in $controlTypes) {
# Get the Fluent base style that was loaded by ThemeMode
$fluentBaseStyle = $Window.TryFindResource($controlType)
# Create a new implicit style with ToolTip binding
$newStyle = New-Object System.Windows.Style($controlType)
if ($null -ne $fluentBaseStyle) {
$newStyle.BasedOn = $fluentBaseStyle
}
# Add the ToolTip setter that binds to the Tag property
$toolTipBinding = New-Object System.Windows.Data.Binding("Tag")
$toolTipBinding.RelativeSource = [System.Windows.Data.RelativeSource]::new([System.Windows.Data.RelativeSourceMode]::Self)
$toolTipSetter = New-Object System.Windows.Setter([System.Windows.FrameworkElement]::ToolTipProperty, $toolTipBinding)
$newStyle.Setters.Add($toolTipSetter)
# Remove any existing implicit style for this type before adding the new one
if ($Window.Resources.Contains($controlType)) {
$Window.Resources.Remove($controlType)
}
$Window.Resources.Add($controlType, $newStyle)
}
WriteLog "Tooltip styles updated with Fluent base styles."
}
function Initialize-UIControls {
param([PSCustomObject]$State)
WriteLog "Initializing UI control references..."
@@ -21,6 +112,9 @@ function Initialize-UIControls {
$State.Controls.cmbWindowsRelease = $window.FindName('cmbWindowsRelease')
$State.Controls.cmbWindowsVersion = $window.FindName('cmbWindowsVersion')
$State.Controls.txtISOPath = $window.FindName('txtISOPath')
$State.Controls.rbDownloadESD = $window.FindName('rbDownloadESD')
$State.Controls.rbProvideISO = $window.FindName('rbProvideISO')
$State.Controls.isoPathPanel = $window.FindName('isoPathPanel')
$State.Controls.btnBrowseISO = $window.FindName('btnBrowseISO')
$State.Controls.cmbWindowsArch = $window.FindName('cmbWindowsArch')
$State.Controls.cmbWindowsLang = $window.FindName('cmbWindowsLang')
@@ -70,8 +164,10 @@ function Initialize-UIControls {
$State.Controls.txtWingetModuleVersion = $window.FindName('txtWingetModuleVersion')
$State.Controls.applicationPathPanel = $window.FindName('applicationPathPanel')
$State.Controls.appListJsonPathPanel = $window.FindName('appListJsonPathPanel')
$State.Controls.userAppListPathPanel = $window.FindName('userAppListPathPanel')
$State.Controls.btnBrowseApplicationPath = $window.FindName('btnBrowseApplicationPath')
$State.Controls.btnBrowseAppListJsonPath = $window.FindName('btnBrowseAppListJsonPath')
$State.Controls.btnBrowseUserAppListPath = $window.FindName('btnBrowseUserAppListPath')
$State.Controls.chkBringYourOwnApps = $window.FindName('chkBringYourOwnApps')
$State.Controls.byoApplicationPanel = $window.FindName('byoApplicationPanel')
$State.Controls.wingetSearchPanel = $window.FindName('wingetSearchPanel')
@@ -109,32 +205,51 @@ function Initialize-UIControls {
$State.Controls.txtStatus = $window.FindName('txtStatus')
$State.Controls.pbOverallProgress = $window.FindName('progressBar')
$State.Controls.txtOverallStatus = $window.FindName('txtStatus')
$State.Controls.chkEnableVMNetworking = $window.FindName('chkEnableVMNetworking')
$State.Controls.spVMNetworkingSettings = $window.FindName('spVMNetworkingSettings')
$State.Controls.cmbVMSwitchName = $window.FindName('cmbVMSwitchName')
$State.Controls.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.cmbBitsPriority = $window.FindName('cmbBitsPriority')
$State.Controls.txtMaxUSBDrives = $window.FindName('txtMaxUSBDrives')
$State.Controls.chkCompactOS = $window.FindName('chkCompactOS')
$State.Controls.chkOptimize = $window.FindName('chkOptimize')
$State.Controls.chkAllowVHDXCaching = $window.FindName('chkAllowVHDXCaching')
$State.Controls.chkCreateCaptureMedia = $window.FindName('chkCreateCaptureMedia')
$State.Controls.chkCreateDeploymentMedia = $window.FindName('chkCreateDeploymentMedia')
$State.Controls.chkInjectUnattend = $window.FindName('chkInjectUnattend')
$State.Controls.txtUnattendX64FilePath = $window.FindName('txtUnattendX64FilePath')
$State.Controls.btnBrowseUnattendX64FilePath = $window.FindName('btnBrowseUnattendX64FilePath')
$State.Controls.txtUnattendArm64FilePath = $window.FindName('txtUnattendArm64FilePath')
$State.Controls.btnBrowseUnattendArm64FilePath = $window.FindName('btnBrowseUnattendArm64FilePath')
$State.Controls.rbDeviceNamingNone = $window.FindName('rbDeviceNamingNone')
$State.Controls.rbDeviceNamingPrompt = $window.FindName('rbDeviceNamingPrompt')
$State.Controls.rbDeviceNamingTemplate = $window.FindName('rbDeviceNamingTemplate')
$State.Controls.rbDeviceNamingPrefixes = $window.FindName('rbDeviceNamingPrefixes')
$State.Controls.rbDeviceNamingSerialComputerNames = $window.FindName('rbDeviceNamingSerialComputerNames')
$State.Controls.deviceNameTemplatePanel = $window.FindName('deviceNameTemplatePanel')
$State.Controls.deviceNamePrefixesPanel = $window.FindName('deviceNamePrefixesPanel')
$State.Controls.deviceNameSerialComputerNamesPanel = $window.FindName('deviceNameSerialComputerNamesPanel')
$State.Controls.txtDeviceNameTemplate = $window.FindName('txtDeviceNameTemplate')
$State.Controls.txtDeviceNamePrefixesPath = $window.FindName('txtDeviceNamePrefixesPath')
$State.Controls.btnBrowseDeviceNamePrefixesPath = $window.FindName('btnBrowseDeviceNamePrefixesPath')
$State.Controls.txtDeviceNamePrefixes = $window.FindName('txtDeviceNamePrefixes')
$State.Controls.btnSaveDeviceNamePrefixes = $window.FindName('btnSaveDeviceNamePrefixes')
$State.Controls.txtDeviceNameSerialComputerNamesPath = $window.FindName('txtDeviceNameSerialComputerNamesPath')
$State.Controls.btnBrowseDeviceNameSerialComputerNamesPath = $window.FindName('btnBrowseDeviceNameSerialComputerNamesPath')
$State.Controls.txtDeviceNameSerialComputerNames = $window.FindName('txtDeviceNameSerialComputerNames')
$State.Controls.btnSaveDeviceNameSerialComputerNames = $window.FindName('btnSaveDeviceNameSerialComputerNames')
$State.Controls.chkVerbose = $window.FindName('chkVerbose')
$State.Controls.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.chkRemoveDownloadedESD = $window.FindName('chkRemoveDownloadedESD')
$State.Controls.txtDiskSize = $window.FindName('txtDiskSize')
$State.Controls.txtMemory = $window.FindName('txtMemory')
$State.Controls.txtProcessors = $window.FindName('txtProcessors')
@@ -158,6 +273,7 @@ function Initialize-UIControls {
$State.Controls.chkUpdatePreviewCU = $window.FindName('chkUpdatePreviewCU')
$State.Controls.txtApplicationPath = $window.FindName('txtApplicationPath')
$State.Controls.txtAppListJsonPath = $window.FindName('txtAppListJsonPath')
$State.Controls.txtUserAppListPath = $window.FindName('txtUserAppListPath')
$State.Controls.chkInstallDrivers = $window.FindName('chkInstallDrivers')
$State.Controls.chkCopyDrivers = $window.FindName('chkCopyDrivers')
$State.Controls.chkCompressDriversToWIM = $window.FindName('chkCompressDriversToWIM')
@@ -179,11 +295,59 @@ function Initialize-UIControls {
$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')
# Home page
$State.Controls.txtHomeCurrentBuildValue = $window.FindName('txtHomeCurrentBuildValue')
$State.Controls.txtHomeLatestReleaseValue = $window.FindName('txtHomeLatestReleaseValue')
$State.Controls.txtHomeReleaseStatusValue = $window.FindName('txtHomeReleaseStatusValue')
$State.Controls.spHomeReleaseNotesSections = $window.FindName('spHomeReleaseNotesSections')
$State.Controls.ellipseHomeDiskSpaceStatus = $window.FindName('ellipseHomeDiskSpaceStatus')
$State.Controls.txtHomeDiskSpaceStatusValue = $window.FindName('txtHomeDiskSpaceStatusValue')
$State.Controls.ellipseHomeHyperVStatus = $window.FindName('ellipseHomeHyperVStatus')
$State.Controls.txtHomeHyperVStatusValue = $window.FindName('txtHomeHyperVStatusValue')
$State.Controls.txtHomeDiscussionsStatusValue = $window.FindName('txtHomeDiscussionsStatusValue')
$State.Controls.tbDiscussion1 = $window.FindName('tbDiscussion1')
$State.Controls.linkDiscussion1 = $window.FindName('linkDiscussion1')
$State.Controls.runDiscussion1 = $window.FindName('runDiscussion1')
$State.Controls.tbDiscussion2 = $window.FindName('tbDiscussion2')
$State.Controls.linkDiscussion2 = $window.FindName('linkDiscussion2')
$State.Controls.runDiscussion2 = $window.FindName('runDiscussion2')
$State.Controls.tbDiscussion3 = $window.FindName('tbDiscussion3')
$State.Controls.linkDiscussion3 = $window.FindName('linkDiscussion3')
$State.Controls.runDiscussion3 = $window.FindName('runDiscussion3')
$State.Controls.tbDiscussion4 = $window.FindName('tbDiscussion4')
$State.Controls.linkDiscussion4 = $window.FindName('linkDiscussion4')
$State.Controls.runDiscussion4 = $window.FindName('runDiscussion4')
$State.Controls.tbDiscussion5 = $window.FindName('tbDiscussion5')
$State.Controls.linkDiscussion5 = $window.FindName('linkDiscussion5')
$State.Controls.runDiscussion5 = $window.FindName('runDiscussion5')
$State.Controls.tbDiscussionsLink = $window.FindName('tbDiscussionsLink')
$State.Controls.linkDiscussions = $window.FindName('linkDiscussions')
# Settings page
$State.Controls.cmbThemeMode = $window.FindName('cmbThemeMode')
# Shared page shell
$State.Controls.txtPageTitle = $window.FindName('txtPageTitle')
# Navigation controls
$State.Controls.lstNavigation = $window.FindName('lstNavigation')
$State.Controls.lstNavSettings = $window.FindName('lstNavSettings')
$State.Controls.lstLogOutput = $window.FindName('lstLogOutput')
# Content pages (for navigation visibility toggling)
$State.Controls.navigationPages = @(
$window.FindName('pageHome'),
$window.FindName('pageHyperV'),
$window.FindName('pageWindows'),
$window.FindName('pageUpdates'),
$window.FindName('pageApplications'),
$window.FindName('pageOffice'),
$window.FindName('pageDrivers'),
$window.FindName('pageBuild'),
$window.FindName('pageMonitor')
)
$State.Controls.pageSettings = $window.FindName('pageSettings')
# Initialize and bind the log data collection
$State.Data.logData = New-Object System.Collections.ObjectModel.ObservableCollection[string]
$State.Controls.lstLogOutput.ItemsSource = $State.Data.logData
@@ -204,19 +368,11 @@ function Initialize-VMSwitchData {
$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
}
}
@@ -232,8 +388,6 @@ function Initialize-UIDefaults {
$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.cmbBitsPriority.SelectedItem = $State.Defaults.generalDefaults.BitsPriority
$State.Controls.txtMaxUSBDrives.Text = $State.Defaults.generalDefaults.MaxUSBDrives
@@ -243,7 +397,8 @@ function Initialize-UIDefaults {
$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.txtUnattendX64FilePath.Text = $State.Defaults.generalDefaults.UnattendX64FilePath
$State.Controls.txtUnattendArm64FilePath.Text = $State.Defaults.generalDefaults.UnattendArm64FilePath
$State.Controls.chkCreateDeploymentMedia.IsChecked = $State.Defaults.generalDefaults.CreateDeploymentMedia
$State.Controls.chkAllowExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.AllowExternalHardDiskMedia
$State.Controls.chkPromptExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.PromptExternalHardDiskMedia
@@ -251,15 +406,29 @@ function Initialize-UIDefaults {
$State.Controls.chkCopyAutopilot.IsChecked = $State.Defaults.generalDefaults.CopyAutopilot
$State.Controls.chkCopyUnattend.IsChecked = $State.Defaults.generalDefaults.CopyUnattend
$State.Controls.chkCopyPPKG.IsChecked = $State.Defaults.generalDefaults.CopyPPKG
$defaultDeviceNamingMode = if ($State.Defaults.generalDefaults.DeviceNamingMode -in @('None', 'Prompt', 'Template', 'Prefixes', 'SerialComputerNames')) {
$State.Defaults.generalDefaults.DeviceNamingMode
}
else {
'None'
}
Set-DeviceNamingModeState -State $State -DisplayMode $defaultDeviceNamingMode -LoadedMode $null
$State.Controls.txtDeviceNameTemplate.Text = $State.Defaults.generalDefaults.DeviceNameTemplate
$State.Controls.txtDeviceNamePrefixesPath.Text = $State.Defaults.generalDefaults.DeviceNamePrefixesPath
$State.Controls.txtDeviceNamePrefixes.Text = ($State.Defaults.generalDefaults.DeviceNamePrefixes -join [System.Environment]::NewLine)
$State.Controls.txtDeviceNameSerialComputerNamesPath.Text = $State.Defaults.generalDefaults.DeviceNameSerialComputerNamesPath
$State.Controls.txtDeviceNameSerialComputerNames.Text = ($State.Defaults.generalDefaults.DeviceNameSerialComputerNames -join [System.Environment]::NewLine)
Import-DeviceNamePrefixesFromConfiguredPath -State $State
Import-SerialComputerNamesFromConfiguredPath -State $State
Update-DeviceNamingControls -State $State
$State.Controls.chkCleanupAppsISO.IsChecked = $State.Defaults.generalDefaults.CleanupAppsISO
$State.Controls.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.chkRemoveDownloadedESD.IsChecked = $State.Defaults.generalDefaults.RemoveDownloadedESD
$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
@@ -268,7 +437,9 @@ function Initialize-UIDefaults {
Update-BitsPrioritySetting -State $State
# Hyper-V Settings defaults from General Defaults
$State.Controls.chkEnableVMNetworking.IsChecked = $State.Defaults.generalDefaults.EnableVMNetworking
Initialize-VMSwitchData -State $State
$State.Controls.spVMNetworkingSettings.IsEnabled = $true -eq $State.Controls.chkEnableVMNetworking.IsChecked
$State.Controls.txtDiskSize.Text = $State.Defaults.generalDefaults.DiskSizeGB
$State.Controls.txtMemory.Text = $State.Defaults.generalDefaults.MemoryGB
$State.Controls.txtProcessors.Text = $State.Defaults.generalDefaults.Processors
@@ -277,7 +448,12 @@ function Initialize-UIDefaults {
$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
# Initialize Windows settings combos based on media source mode
$initIsoPath = $State.Defaults.windowsSettingsDefaults.DefaultISOPath
if ($null -ne $State.Controls.rbProvideISO -and -not $State.Controls.rbProvideISO.IsChecked) {
$initIsoPath = ''
}
Get-WindowsSettingsCombos -isoPath $initIsoPath -State $State
# Windows Settings tab defaults
$State.Controls.cmbWindowsLang.ItemsSource = $State.Defaults.windowsSettingsDefaults.AllowedLanguages
@@ -303,6 +479,7 @@ function Initialize-UIDefaults {
$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.txtUserAppListPath.Text = $State.Defaults.generalDefaults.UserAppListPath
$State.Controls.chkInstallWingetApps.IsChecked = $State.Defaults.generalDefaults.InstallWingetApps
$State.Controls.chkBringYourOwnApps.IsChecked = $State.Defaults.generalDefaults.BringYourOwnApps
@@ -343,6 +520,31 @@ function Initialize-UIDefaults {
# Set initial state for InstallApps checkbox based on updates
Update-InstallAppsState -State $State
# Set default theme mode and disable if Fluent is not supported
if ($null -ne $State.Controls.cmbThemeMode) {
$State.Controls.cmbThemeMode.SelectedItem = "System"
if (-not $State.Flags.isFluentSupported) {
$State.Controls.cmbThemeMode.IsEnabled = $false
$State.Controls.cmbThemeMode.Tag = "Fluent theme requires PowerShell 7.5+ (.NET 9+). Best experience on PowerShell 7.6+ (.NET 10)."
}
}
# Set default navigation selection to Home and initialize the shared page title
if ($null -ne $State.Controls.lstNavigation) {
$State.Controls.lstNavigation.SelectedIndex = 0
# Keep the shell header aligned with the selected navigation item on first render
if ($null -ne $State.Controls.txtPageTitle) {
$selectedNavigationItem = $State.Controls.lstNavigation.SelectedItem
if ($null -ne $selectedNavigationItem -and -not [string]::IsNullOrWhiteSpace([string]$selectedNavigationItem.Tag)) {
$State.Controls.txtPageTitle.Text = [string]$selectedNavigationItem.Tag
}
else {
$State.Controls.txtPageTitle.Text = 'Home'
}
}
}
# Set initial state for Office panel visibility
Update-OfficePanelVisibility -State $State
@@ -351,23 +553,48 @@ function Initialize-UIDefaults {
# Set initial state for BYO Apps copy button
Update-CopyButtonState -State $State
}
# Apply accent color to primary action button only (per Windows design guidance)
if ($State.Flags.isFluentSupported) {
try {
$State.Controls.btnRun = $State.Window.FindName('btnRun')
if ($null -ne $State.Controls.btnRun) {
# Use SetResourceReference for live accent color updates when user changes Windows theme
$State.Controls.btnRun.SetResourceReference(
[System.Windows.Controls.Control]::BackgroundProperty,
[System.Windows.SystemColors]::AccentColorBrushKey
)
$State.Controls.btnRun.Foreground = [System.Windows.Media.Brushes]::White
}
}
catch {
WriteLog "Could not apply accent color to Build FFU button: $($_.Exception.Message)"
}
}
}
function Initialize-DynamicUIElements {
param([PSCustomObject]$State)
WriteLog "Initializing dynamic UI elements (Grids, Columns)..."
# Get the Fluent base style for ListViewItem in GridView mode
# Must use GridViewItemContainerStyleKey (not the generic ListViewItem type key) because the
# generic Fluent ListViewItem style has a template without GridViewRowPresenter, which breaks
# column-based rendering and causes items to display their ToString() representation.
$listViewItemBaseStyle = $State.Window.TryFindResource([System.Windows.Controls.GridView]::GridViewItemContainerStyleKey)
# Driver Models ListView setup
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell
$itemStyleDriverModels = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
if ($null -ne $listViewItemBaseStyle) { $itemStyleDriverModels.BasedOn = $listViewItemBaseStyle }
$itemStyleDriverModels.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$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 the selectable column and scope header select-all to visible filtered rows.
Add-SelectableGridViewColumn -ListView $State.Controls.lstDriverModels -State $State -HeaderCheckBoxKeyName "chkSelectAllDriverModels" -ColumnWidth 70 -HeaderSelectionAffectsVisibleItemsOnly
# Add other sortable columns with left-aligned headers
Add-SortableColumn -gridView $driverModelsGridView -header "Make" -binding "Make" -width 100 -headerHorizontalAlignment Left
@@ -389,12 +616,16 @@ function Initialize-DynamicUIElements {
}
)
# Keep driver model columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels -FixedColumnIndexes @(0)
# Winget Search ListView setup
$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])
if ($null -ne $listViewItemBaseStyle) { $itemStyleWingetResults.BasedOn = $listViewItemBaseStyle }
$itemStyleWingetResults.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstWingetResults.ItemContainerStyle = $itemStyleWingetResults
@@ -442,9 +673,11 @@ function Initialize-DynamicUIElements {
$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
# Create a style to disable the ComboBox for 'msstore' source, inheriting the Fluent base style
$comboBoxStyle = New-Object System.Windows.Style
$comboBoxStyle.TargetType = [System.Windows.Controls.ComboBox]
$comboBoxBaseStyle = $State.Window.TryFindResource([System.Windows.Controls.ComboBox])
if ($null -ne $comboBoxBaseStyle) { $comboBoxStyle.BasedOn = $comboBoxBaseStyle }
$dataTrigger = New-Object System.Windows.DataTrigger
$dataTrigger.Binding = New-Object System.Windows.Data.Binding("Source")
@@ -545,12 +778,16 @@ function Initialize-DynamicUIElements {
}
)
# Keep Winget result columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults -FixedColumnIndexes @(0)
# BYO Applications ListView setup
$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])
if ($null -ne $listViewItemBaseStyle) { $itemStyleBYOApps.BasedOn = $listViewItemBaseStyle }
$itemStyleBYOApps.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstApplications.ItemContainerStyle = $itemStyleBYOApps
@@ -567,12 +804,16 @@ function Initialize-DynamicUIElements {
Add-SortableColumn -gridView $byoAppsGridView -header "Ignore Exit Codes" -binding "IgnoreExitCodes" -width 120 -headerHorizontalAlignment Left
Add-SortableColumn -gridView $byoAppsGridView -header "Copy Status" -binding "CopyStatus" -width 150 -headerHorizontalAlignment Left
# Keep BYO application columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstApplications -FixedColumnIndexes @(0)
# Apps Script Variables ListView setup
# 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])
if ($null -ne $listViewItemBaseStyle) { $itemStyleAppsScriptVars.BasedOn = $listViewItemBaseStyle }
$itemStyleAppsScriptVars.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstAppsScriptVariables.ItemContainerStyle = $itemStyleAppsScriptVars
@@ -619,6 +860,9 @@ function Initialize-DynamicUIElements {
}
}
)
# Keep apps script variable columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables -FixedColumnIndexes @(0)
}
else {
WriteLog "Warning: lstAppsScriptVariables.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
@@ -635,6 +879,7 @@ function Initialize-DynamicUIElements {
# 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])
if ($null -ne $listViewItemBaseStyle) { $itemStyleUSBDrives.BasedOn = $listViewItemBaseStyle }
$itemStyleUSBDrives.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstUSBDrives.ItemContainerStyle = $itemStyleUSBDrives
@@ -691,6 +936,9 @@ function Initialize-DynamicUIElements {
}
}
)
# Keep USB drive columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstUSBDrives -FixedColumnIndexes @(0)
}
else {
WriteLog "Warning: lstUSBDrives.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
@@ -698,6 +946,7 @@ function Initialize-DynamicUIElements {
# Additional FFUs ListView setup
$itemStyleAdditionalFFUs = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
if ($null -ne $listViewItemBaseStyle) { $itemStyleAdditionalFFUs.BasedOn = $listViewItemBaseStyle }
$itemStyleAdditionalFFUs.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstAdditionalFFUs.ItemContainerStyle = $itemStyleAdditionalFFUs
@@ -736,6 +985,9 @@ function Initialize-DynamicUIElements {
}
}
)
# Keep additional FFU columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstAdditionalFFUs -FixedColumnIndexes @(0)
}
else {
WriteLog "Warning: lstAdditionalFFUs.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
+296 -13
View File
@@ -20,6 +20,7 @@ function Update-ListViewPriorities {
}
}
$ListView.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $ListView
}
# Function to move selected item to the top
@@ -133,6 +134,7 @@ function Update-ListViewItemStatus {
if ($null -ne $itemToUpdate) {
$itemToUpdate.$StatusProperty = $StatusValue
$ListView.Items.Refresh() # Refresh the view to show the change
Request-ListViewColumnAutoResize -ListView $ListView
}
else {
# Log if item not found (for debugging)
@@ -329,7 +331,8 @@ function Add-SelectableGridViewColumn {
[string]$HeaderCheckBoxKeyName,
[Parameter(Mandatory)]
[double]$ColumnWidth,
[string]$IsSelectedPropertyName = "IsSelected"
[string]$IsSelectedPropertyName = "IsSelected",
[switch]$HeaderSelectionAffectsVisibleItemsOnly
)
# Ensure the ListView has a GridView
@@ -342,11 +345,13 @@ function Add-SelectableGridViewColumn {
# Create the "Select All" CheckBox for the header
$headerCheckBox = New-Object System.Windows.Controls.CheckBox
$headerCheckBox.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center
$headerCheckBox.VerticalAlignment = [System.Windows.VerticalAlignment]::Center
# MODIFICATION: Store the actual ListView object in the header's Tag
# Store header metadata, including whether select-all should only affect visible rows.
$headerTagObject = [PSCustomObject]@{
PropertyName = $IsSelectedPropertyName
ListViewControl = $ListView
HeaderSelectionAffectsVisibleItemsOnly = [bool]$HeaderSelectionAffectsVisibleItemsOnly
}
$headerCheckBox.Tag = $headerTagObject
@@ -356,8 +361,24 @@ function Add-SelectableGridViewColumn {
$localPropertyName = $tagData.PropertyName
$actualListView = $tagData.ListViewControl
$collectionToUpdate = if ($null -ne $actualListView.ItemsSource) { $actualListView.ItemsSource } else { $actualListView.Items }
if ($null -ne $collectionToUpdate) {
# Select either visible view items only (filtered scope) or the full backing list.
$collectionToUpdate = @()
if ($tagData.HeaderSelectionAffectsVisibleItemsOnly -and $null -ne $actualListView.ItemsSource) {
$collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($actualListView.ItemsSource)
if ($null -ne $collectionView) {
foreach ($visibleItem in $collectionView) {
$collectionToUpdate += $visibleItem
}
}
}
elseif ($null -ne $actualListView.ItemsSource) {
$collectionToUpdate = @($actualListView.ItemsSource)
}
elseif ($actualListView.HasItems) {
$collectionToUpdate = @($actualListView.Items)
}
if ($collectionToUpdate.Count -gt 0) {
foreach ($item in $collectionToUpdate) { $item.$($localPropertyName) = $true }
$actualListView.Items.Refresh()
}
@@ -370,19 +391,57 @@ function Add-SelectableGridViewColumn {
$localPropertyName = $tagData.PropertyName
$actualListView = $tagData.ListViewControl
$collectionToUpdate = if ($null -ne $actualListView.ItemsSource) { $actualListView.ItemsSource } else { $actualListView.Items }
if ($null -ne $collectionToUpdate) {
# Clear either visible view items only (filtered scope) or the full backing list.
$collectionToUpdate = @()
if ($tagData.HeaderSelectionAffectsVisibleItemsOnly -and $null -ne $actualListView.ItemsSource) {
$collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($actualListView.ItemsSource)
if ($null -ne $collectionView) {
foreach ($visibleItem in $collectionView) {
$collectionToUpdate += $visibleItem
}
}
}
elseif ($null -ne $actualListView.ItemsSource) {
$collectionToUpdate = @($actualListView.ItemsSource)
}
elseif ($actualListView.HasItems) {
$collectionToUpdate = @($actualListView.Items)
}
if ($collectionToUpdate.Count -gt 0) {
foreach ($item in $collectionToUpdate) { $item.$($localPropertyName) = $false }
$actualListView.Items.Refresh()
}
}
})
# Wrap the header checkbox in a stretched container so it centers the same way as row cells.
# Apply a small left inset to mirror the Fluent ListViewItem content padding used by data rows.
$headerBorder = New-Object System.Windows.Controls.Border
$headerBorder.Padding = New-Object System.Windows.Thickness(12, 0, 0, 0)
$headerBorder.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Stretch
$headerBorder.VerticalAlignment = [System.Windows.VerticalAlignment]::Stretch
$headerGrid = New-Object System.Windows.Controls.Grid
$headerGrid.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Stretch
$headerGrid.VerticalAlignment = [System.Windows.VerticalAlignment]::Stretch
$headerGrid.Children.Add($headerCheckBox) | Out-Null
$headerBorder.Child = $headerGrid
# Use an explicit GridViewColumnHeader so we can remove the default header padding
# and control the checkbox alignment explicitly.
$selectableHeader = New-Object System.Windows.Controls.GridViewColumnHeader
$selectableHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Stretch
$selectableHeader.VerticalContentAlignment = [System.Windows.VerticalAlignment]::Stretch
$selectableHeader.Padding = New-Object System.Windows.Thickness(0)
$selectableHeader.Margin = New-Object System.Windows.Thickness(0)
$selectableHeader.Content = $headerBorder
$State.Controls[$HeaderCheckBoxKeyName] = $headerCheckBox
WriteLog "Add-SelectableGridViewColumn: Stored header checkbox in State.Controls with key '$HeaderCheckBoxKeyName'."
$selectableColumn = New-Object System.Windows.Controls.GridViewColumn
$selectableColumn.Header = $headerCheckBox
$selectableColumn.Header = $selectableHeader
$selectableColumn.Width = $ColumnWidth
$cellTemplate = New-Object System.Windows.DataTemplate
@@ -437,6 +496,209 @@ function Add-SelectableGridViewColumn {
WriteLog "Add-SelectableGridViewColumn: Successfully added selectable column to '$($ListView.Name)'."
}
# Function to request a deferred GridView column auto-size pass
function Request-ListViewColumnAutoResize {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[System.Windows.Controls.ListView]$ListView
)
# Skip startup calls until the visual tree has finished loading.
if (-not $ListView.IsLoaded) {
return
}
# Ensure the ListView has registered auto-resize metadata before scheduling work.
$autoResizeStateKey = 'FFU.ListViewColumnAutoResizeState'
if (-not $ListView.Resources.Contains($autoResizeStateKey)) {
return
}
if (-not ($ListView.View -is [System.Windows.Controls.GridView])) {
return
}
$autoResizeState = $ListView.Resources[$autoResizeStateKey]
if ($autoResizeState.ResizePending) {
return
}
$autoResizeState.ResizePending = $true
$previousErrorActionPreference = $ErrorActionPreference
try {
$ErrorActionPreference = 'Stop'
$gridView = [System.Windows.Controls.GridView]$ListView.View
$fixedColumnIndexes = @($autoResizeState.FixedColumnIndexes)
$visibleItems = [System.Collections.Generic.List[object]]::new()
if ($null -ne $ListView.ItemsSource) {
$collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($ListView.ItemsSource)
if ($null -ne $collectionView) {
foreach ($visibleItem in $collectionView) {
$visibleItems.Add($visibleItem)
}
}
}
else {
foreach ($visibleItem in $ListView.Items) {
$visibleItems.Add($visibleItem)
}
}
$ListView.UpdateLayout()
$columnIndex = 0
foreach ($column in $gridView.Columns) {
if ($fixedColumnIndexes -contains $columnIndex) {
$columnIndex++
continue
}
if ($null -eq $column) {
$columnIndex++
continue
}
$headerText = ""
$propertyName = $null
if ($null -ne $column.DisplayMemberBinding -and $null -ne $column.DisplayMemberBinding.Path) {
$propertyName = [string]$column.DisplayMemberBinding.Path.Path
}
if ($column.Header -is [System.Windows.Controls.GridViewColumnHeader]) {
if (-not [string]::IsNullOrWhiteSpace([string]$column.Header.Content)) {
$headerText = [string]$column.Header.Content
}
if ([string]::IsNullOrWhiteSpace($propertyName) -and -not [string]::IsNullOrWhiteSpace([string]$column.Header.Tag)) {
$propertyName = [string]$column.Header.Tag
}
}
elseif (-not [string]::IsNullOrWhiteSpace([string]$column.Header)) {
$headerText = [string]$column.Header
}
if ([string]::IsNullOrWhiteSpace($headerText)) {
$headerText = $propertyName
}
$headerMeasureBlock = New-Object System.Windows.Controls.TextBlock
$headerMeasureBlock.Text = if ([string]::IsNullOrWhiteSpace($headerText)) { ' ' } else { $headerText }
$headerMeasureBlock.FontFamily = $ListView.FontFamily
$headerMeasureBlock.FontSize = $ListView.FontSize
$headerMeasureBlock.FontStyle = $ListView.FontStyle
$headerMeasureBlock.FontWeight = $ListView.FontWeight
$headerMeasureBlock.FontStretch = $ListView.FontStretch
$headerMeasureBlock.Measure([System.Windows.Size]::new([double]::PositiveInfinity, [double]::PositiveInfinity))
$calculatedWidth = [math]::Ceiling($headerMeasureBlock.DesiredSize.Width + 36)
foreach ($item in $visibleItems) {
if ($null -eq $item -or [string]::IsNullOrWhiteSpace($propertyName)) {
continue
}
$itemProperty = $null
if ($null -ne $item.PSObject -and $null -ne $item.PSObject.Properties) {
$matchedProperties = $item.PSObject.Properties.Match($propertyName)
if ($null -ne $matchedProperties -and $matchedProperties.Count -gt 0) {
$itemProperty = $matchedProperties | Select-Object -First 1
}
}
if ($null -eq $itemProperty) {
continue
}
$itemText = [string]$itemProperty.Value
$extraWidth = 28
switch ($propertyName) {
'Architecture' {
$extraWidth = 52
}
'AdditionalExitCodes' {
$extraWidth = 44
}
'IgnoreNonZeroExitCodes' {
$itemText = ' '
$extraWidth = 48
}
'IgnoreExitCodes' {
$extraWidth = 28
}
}
$itemMeasureBlock = New-Object System.Windows.Controls.TextBlock
$itemMeasureBlock.Text = if ([string]::IsNullOrWhiteSpace($itemText)) { ' ' } else { $itemText }
$itemMeasureBlock.FontFamily = $ListView.FontFamily
$itemMeasureBlock.FontSize = $ListView.FontSize
$itemMeasureBlock.FontStyle = $ListView.FontStyle
$itemMeasureBlock.FontWeight = $ListView.FontWeight
$itemMeasureBlock.FontStretch = $ListView.FontStretch
$itemMeasureBlock.Measure([System.Windows.Size]::new([double]::PositiveInfinity, [double]::PositiveInfinity))
$itemWidth = [math]::Ceiling($itemMeasureBlock.DesiredSize.Width + $extraWidth)
if ($itemWidth -gt $calculatedWidth) {
$calculatedWidth = $itemWidth
}
}
if ($propertyName -eq 'IgnoreNonZeroExitCodes') {
$calculatedWidth = [math]::Max($calculatedWidth, 120)
}
$column.Width = [math]::Max($calculatedWidth, 40)
$columnIndex++
}
}
catch {
WriteLog "Request-ListViewColumnAutoResize: Failed for '$($ListView.Name)': $($_.Exception.Message)"
if (-not [string]::IsNullOrWhiteSpace($_.InvocationInfo.PositionMessage)) {
WriteLog $_.InvocationInfo.PositionMessage
}
if (-not [string]::IsNullOrWhiteSpace($_.ScriptStackTrace)) {
WriteLog $_.ScriptStackTrace
}
}
finally {
$ErrorActionPreference = $previousErrorActionPreference
$autoResizeState.ResizePending = $false
}
}
# Function to enable reusable auto-resizing for GridView-backed ListViews
function Enable-ListViewColumnAutoResize {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[System.Windows.Controls.ListView]$ListView,
[int[]]$FixedColumnIndexes = @()
)
$autoResizeStateKey = 'FFU.ListViewColumnAutoResizeState'
# Only GridView-backed lists can participate in column auto-sizing.
if (-not ($ListView.View -is [System.Windows.Controls.GridView])) {
WriteLog "Enable-ListViewColumnAutoResize: ListView '$($ListView.Name)' is not using a GridView. Skipping registration."
return
}
if ($ListView.Resources.Contains($autoResizeStateKey)) {
return
}
$autoResizeState = [PSCustomObject]@{
FixedColumnIndexes = @($FixedColumnIndexes)
ResizePending = $false
}
$ListView.Resources[$autoResizeStateKey] = $autoResizeState
}
# Function to update the IsChecked state of a "Select All" header CheckBox
function Update-SelectAllHeaderCheckBoxState {
param(
@@ -446,24 +708,38 @@ function Update-SelectAllHeaderCheckBoxState {
[System.Windows.Controls.CheckBox]$HeaderCheckBox
)
$collectionToInspect = $null
if ($null -ne $ListView.ItemsSource) {
# Determine whether this header should evaluate only visible (filtered) rows.
$inspectVisibleItemsOnly = $false
if ($null -ne $HeaderCheckBox.Tag -and $null -ne $HeaderCheckBox.Tag.PSObject.Properties['HeaderSelectionAffectsVisibleItemsOnly']) {
$inspectVisibleItemsOnly = [bool]$HeaderCheckBox.Tag.HeaderSelectionAffectsVisibleItemsOnly
}
# Build the collection to inspect based on scope (visible view vs full source).
$collectionToInspect = @()
if ($inspectVisibleItemsOnly -and $null -ne $ListView.ItemsSource) {
$collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($ListView.ItemsSource)
if ($null -ne $collectionView) {
foreach ($visibleItem in $collectionView) {
$collectionToInspect += $visibleItem
}
}
}
elseif ($null -ne $ListView.ItemsSource) {
$collectionToInspect = @($ListView.ItemsSource)
}
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) {
# If no items are available in the selected scope, force unchecked.
if ($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
$totalItemCount = $collectionToInspect.Count
WriteLog "Update-SelectAllHeaderCheckBoxState: Total item count is $totalItemCount for ListView '$($ListView.Name)'."
if ($totalItemCount -eq 0) {
@@ -502,6 +778,7 @@ function Invoke-ListViewItemToggle {
# Toggle the IsSelected property
$selectedItem.IsSelected = -not $selectedItem.IsSelected
$ListView.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $ListView
# Update the 'Select All' header checkbox state
$headerChk = $State.Controls[$HeaderCheckBoxKeyName]
@@ -672,6 +949,8 @@ function Invoke-ListViewSort {
$newView.Filter = $existingFilter
}
}
Request-ListViewColumnAutoResize -ListView $listView
}
# --------------------------------------------------------------------------
@@ -894,6 +1173,9 @@ function Invoke-BrowseAction {
if (-not [string]::IsNullOrWhiteSpace($InitialDirectory)) {
$dialog.InitialDirectory = $InitialDirectory
}
if (-not [string]::IsNullOrWhiteSpace($FileName)) {
$dialog.FileName = $FileName
}
if ($dialog.ShowDialog()) {
return $dialog.FileName
}
@@ -972,6 +1254,7 @@ function Clear-ListViewContent {
}
$ListViewControl.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $ListViewControl
# Clear any specified textboxes
if ($null -ne $TextBoxesToClear) {
@@ -59,6 +59,7 @@ function Search-WingetApps {
# Update the ListView's ItemsSource using the passed-in State object
$State.Controls.lstWingetResults.ItemsSource = $finalAppList.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
# Update status text
$statusText = ""
@@ -108,20 +109,28 @@ function Save-WingetList {
})
}
# Default the save dialog to the configured Winget app list path.
$currentPath = $State.Controls.txtAppListJsonPath.Text
$initialDirectory = if (-not [string]::IsNullOrWhiteSpace($currentPath)) { Split-Path -Path $currentPath -Parent } else { $State.Controls.txtApplicationPath.Text }
if ([string]::IsNullOrWhiteSpace($initialDirectory) -or -not (Test-Path -Path $initialDirectory -PathType Container)) {
$initialDirectory = $State.Controls.txtApplicationPath.Text
}
$fileName = if (-not [string]::IsNullOrWhiteSpace($currentPath)) { Split-Path -Path $currentPath -Leaf } else { "AppList.json" }
$sfd = New-Object System.Windows.Forms.SaveFileDialog
$sfd.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"
$sfd.Title = "Save Winget App List"
$sfd.InitialDirectory = $initialDirectory
$sfd.FileName = $fileName
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")
$State.Controls.txtAppListJsonPath.Text = $sfd.FileName
[System.Windows.MessageBox]::Show("Winget app list saved successfully.", "Success", "OK", "Information")
}
}
catch {
[System.Windows.MessageBox]::Show("Error saving app list: $_", "Error", "OK", "Error")
[System.Windows.MessageBox]::Show("Error saving Winget app list: $_", "Error", "OK", "Error")
}
}
@@ -132,11 +141,17 @@ function Import-WingetList {
[psobject]$State
)
try {
# Default the import dialog to the configured Winget app list path.
$currentPath = $State.Controls.txtAppListJsonPath.Text
$initialDirectory = if (-not [string]::IsNullOrWhiteSpace($currentPath)) { Split-Path -Path $currentPath -Parent } else { $State.Controls.txtApplicationPath.Text }
if ([string]::IsNullOrWhiteSpace($initialDirectory) -or -not (Test-Path -Path $initialDirectory -PathType Container)) {
$initialDirectory = $State.Controls.txtApplicationPath.Text
}
$ofd = New-Object System.Windows.Forms.OpenFileDialog
$ofd.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
$ofd.Title = "Import Winget App List"
$ofd.InitialDirectory = $initialDirectory
if ($ofd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
$importedAppsData = Get-Content $ofd.FileName -Raw | ConvertFrom-Json
@@ -144,16 +159,16 @@ function Import-WingetList {
$newAppListForItemsSource = [System.Collections.Generic.List[object]]::new()
if ($null -ne $importedAppsData.apps) {
# Get default architecture from the UI for fallback
# Get default architecture from the UI for fallback.
$defaultArch = $State.Controls.cmbWindowsArch.SelectedItem
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
IsSelected = $true
Name = $appInfo.name
Id = $appInfo.id
Version = "" # Will be populated when searching or if data exists
Version = ""
Source = $appInfo.source
Architecture = $arch
AdditionalExitCodes = if ($appInfo.PSObject.Properties['AdditionalExitCodes']) { $appInfo.AdditionalExitCodes } else { "" }
@@ -164,12 +179,14 @@ function Import-WingetList {
}
$State.Controls.lstWingetResults.ItemsSource = $newAppListForItemsSource.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
$State.Controls.txtAppListJsonPath.Text = $ofd.FileName
[System.Windows.MessageBox]::Show("App list imported successfully.", "Success", "OK", "Information")
[System.Windows.MessageBox]::Show("Winget app list imported successfully.", "Success", "OK", "Information")
}
}
catch {
[System.Windows.MessageBox]::Show("Error importing app list: $_", "Error", "OK", "Error")
[System.Windows.MessageBox]::Show("Error importing Winget app list: $_", "Error", "OK", "Error")
}
}
+813 -6
View File
@@ -106,16 +106,20 @@ function Get-GeneralDefaults {
$peDriversPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "PEDrivers"
$vmLocationPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "VM"
$ffuCapturePath = Join-Path -Path $FFUDevelopmentPath -ChildPath "FFU"
$unattendPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "unattend"
$officePath = Join-Path -Path $appsPath -ChildPath "Office"
$appListJsonPath = Join-Path -Path $appsPath -ChildPath "AppList.json"
$userAppListPath = Join-Path -Path $appsPath -ChildPath "UserAppList.json"
$driversJsonPath = Join-Path -Path $driversPath -ChildPath "Drivers.json"
$deviceNamePrefixesPath = Join-Path -Path $unattendPath -ChildPath "prefixes.txt"
$deviceNameSerialComputerNamesPath = Join-Path -Path $unattendPath -ChildPath "SerialComputerNames.csv"
$unattendX64FilePath = Join-Path -Path $unattendPath -ChildPath "unattend_x64.xml"
$unattendArm64FilePath = Join-Path -Path $unattendPath -ChildPath "unattend_arm64.xml"
return [PSCustomObject]@{
# Build Tab Defaults
CustomFFUNameTemplate = "{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}"
FFUCaptureLocation = $ffuCapturePath
ShareName = "FFUCaptureShare"
Username = "ffu_user"
Threads = 5
BitsPriority = 'Normal'
MaxUSBDrives = 5
@@ -123,7 +127,6 @@ function Get-GeneralDefaults {
CompactOS = $true
Optimize = $true
AllowVHDXCaching = $false
CreateCaptureMedia = $true
CreateDeploymentMedia = $true
Verbose = $false
AllowExternalHardDiskMedia = $false
@@ -134,15 +137,23 @@ function Get-GeneralDefaults {
CopyUnattend = $false
CopyPPKG = $false
InjectUnattend = $false
UnattendX64FilePath = $unattendX64FilePath
UnattendArm64FilePath = $unattendArm64FilePath
DeviceNamingMode = 'None'
DeviceNameTemplate = ''
DeviceNamePrefixesPath = $deviceNamePrefixesPath
DeviceNamePrefixes = @()
DeviceNameSerialComputerNamesPath = $deviceNameSerialComputerNamesPath
DeviceNameSerialComputerNames = @()
CleanupAppsISO = $true
CleanupCaptureISO = $true
CleanupDeployISO = $true
CleanupDrivers = $false
RemoveFFU = $false
RemoveApps = $false
RemoveUpdates = $false
RemoveDownloadedESD = $true
# Hyper-V Settings Defaults
VMHostIPAddress = ""
EnableVMNetworking = $false
DiskSizeGB = 50
MemoryGB = 4
Processors = 4
@@ -162,6 +173,7 @@ function Get-GeneralDefaults {
InstallApps = $false
ApplicationPath = $appsPath
AppListJsonPath = $appListJsonPath
UserAppListPath = $userAppListPath
InstallWingetApps = $false
BringYourOwnApps = $false
# M365 Apps/Office Tab Defaults
@@ -265,6 +277,7 @@ function Update-AdditionalFFUList {
foreach ($it in $items) { $listView.Items.Add($it) | Out-Null }
WriteLog "Additional FFUs: Found $($listView.Items.Count) FFU files in $ffuFolder."
}
Request-ListViewColumnAutoResize -ListView $listView
$headerChk = $State.Controls.chkSelectAllAdditionalFFUs
if ($null -ne $headerChk) {
Update-SelectAllHeaderCheckBoxState -ListView $listView -HeaderCheckBox $headerChk
@@ -304,6 +317,7 @@ function Update-ApplicationPanelVisibility {
$subOptionVisibility = if ($installAppsChecked) { 'Visible' } else { 'Collapsed' }
$State.Controls.applicationPathPanel.Visibility = $subOptionVisibility
$State.Controls.appListJsonPathPanel.Visibility = $subOptionVisibility
$State.Controls.userAppListPathPanel.Visibility = $subOptionVisibility
$State.Controls.chkInstallWingetApps.Visibility = $subOptionVisibility
$State.Controls.chkBringYourOwnApps.Visibility = $subOptionVisibility
$State.Controls.chkDefineAppsScriptVariables.Visibility = $subOptionVisibility
@@ -320,6 +334,23 @@ function Update-ApplicationPanelVisibility {
}
}
# Function to identify whether current Windows release selection is Windows 10 LTSB/LTSC
function Test-IsWindows10LtscReleaseSelection {
param([PSCustomObject]$State)
$releaseItem = $State.Controls.cmbWindowsRelease.SelectedItem
if ($null -eq $releaseItem) {
return $false
}
$releaseDisplay = [string]$releaseItem.Display
if ([string]::IsNullOrWhiteSpace($releaseDisplay)) {
return $false
}
return (($releaseDisplay -like 'Windows 10*') -and (($releaseDisplay -like '*LTSB*') -or ($releaseDisplay -like '*LTSC*')))
}
# Function to manage the state of the main "Install Apps" checkbox based on selections in Updates/Office
function Update-InstallAppsState {
param([PSCustomObject]$State)
@@ -327,11 +358,16 @@ function Update-InstallAppsState {
$installAppsChk = $State.Controls.chkInstallApps
$installOfficeChk = $State.Controls.chkInstallOffice
# Determine if Windows 10 LTSB/LTSC + Update Latest CU is selected
$isWindows10LtscRelease = Test-IsWindows10LtscReleaseSelection -State $State
$isLtscCuChecked = $State.Controls.chkUpdateLatestCU.IsChecked -and $isWindows10LtscRelease
# 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
$State.Controls.chkUpdateLatestMSRT.IsChecked -or `
$isLtscCuChecked
$isForced = $anyUpdateChecked -or $installOfficeChk.IsChecked
@@ -445,6 +481,777 @@ function Update-DriverDownloadPanelVisibility {
}
}
# --------------------------------------------------------------------------
# SECTION: Home Page Build Status
# --------------------------------------------------------------------------
# Function to normalize release strings so local builds and GitHub tags compare consistently
function ConvertTo-NormalizedReleaseVersion {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[AllowNull()]
[string]$Version
)
if ([string]::IsNullOrWhiteSpace($Version)) {
return $null
}
$normalizedVersion = $Version.Trim().ToLowerInvariant()
$normalizedVersion = $normalizedVersion -replace '^[v]', ''
return $normalizedVersion
}
# Function to read the current FFU Builder build from the main build script
function Get-FFUBuilderCurrentBuild {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$FFUDevelopmentPath
)
$buildScriptPath = Join-Path -Path $FFUDevelopmentPath -ChildPath 'BuildFFUVM.ps1'
if (-not (Test-Path -Path $buildScriptPath)) {
return 'Unknown'
}
try {
$buildScriptContent = Get-Content -Path $buildScriptPath -Raw -ErrorAction Stop
$versionMatch = [regex]::Match($buildScriptContent, '(?m)^\$version\s*=\s*''([^'']+)''')
if ($versionMatch.Success) {
return $versionMatch.Groups[1].Value
}
}
catch {
WriteLog "Unable to read the current FFU Builder build version: $($_.Exception.Message)"
}
return 'Unknown'
}
# Function to query GitHub for the latest published FFU Builder release
function Get-FFUBuilderLatestRelease {
[CmdletBinding()]
param()
$releaseApiUri = 'https://api.github.com/repos/rbalsleyMSFT/FFU/releases/latest'
$releaseHeaders = @{
'User-Agent' = 'FFUBuilderUI'
'Accept' = 'application/vnd.github+json'
}
$releaseResponse = Invoke-RestMethod -Uri $releaseApiUri -Headers $releaseHeaders -TimeoutSec 5 -ErrorAction Stop
$releaseVersion = if (-not [string]::IsNullOrWhiteSpace([string]$releaseResponse.tag_name)) {
[string]$releaseResponse.tag_name
}
elseif (-not [string]::IsNullOrWhiteSpace([string]$releaseResponse.name)) {
[string]$releaseResponse.name
}
else {
$null
}
return [PSCustomObject]@{
Version = $releaseVersion
HtmlUrl = [string]$releaseResponse.html_url
Body = [string]$releaseResponse.body
}
}
# Function to build a user-friendly release status message for the Home page
function Get-FFUBuilderReleaseStatusMessage {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$CurrentBuild,
[Parameter(Mandatory = $true)]
[string]$LatestRelease
)
# Format the release string for Home page display while keeping compare logic normalized
$displayLatestRelease = if ([string]::IsNullOrWhiteSpace($LatestRelease)) {
$LatestRelease
}
else {
$LatestRelease -replace '^[vV]', ''
}
$normalizedCurrentBuild = ConvertTo-NormalizedReleaseVersion -Version $CurrentBuild
$normalizedLatestRelease = ConvertTo-NormalizedReleaseVersion -Version $LatestRelease
if ([string]::IsNullOrWhiteSpace($normalizedCurrentBuild)) {
return 'Installed build information is unavailable.'
}
if ([string]::IsNullOrWhiteSpace($normalizedLatestRelease)) {
return 'Unable to compare the installed build with the latest release.'
}
if ($normalizedCurrentBuild -eq $normalizedLatestRelease) {
return 'You are running the latest published build.'
}
$currentVersionMatch = [regex]::Match($normalizedCurrentBuild, '^\d+(?:\.\d+){0,3}')
$latestVersionMatch = [regex]::Match($normalizedLatestRelease, '^\d+(?:\.\d+){0,3}')
if ($currentVersionMatch.Success -and $latestVersionMatch.Success) {
try {
$currentVersion = [version]$currentVersionMatch.Value
$latestVersion = [version]$latestVersionMatch.Value
if ($currentVersion -lt $latestVersion) {
return "A newer release is available: $displayLatestRelease."
}
if ($currentVersion -gt $latestVersion) {
return "This build is newer than the latest published release: $displayLatestRelease."
}
}
catch {
WriteLog "Unable to compare FFU Builder release versions numerically: $($_.Exception.Message)"
}
}
return "Installed build $CurrentBuild differs from the latest published release $displayLatestRelease."
}
# Function to normalize a markdown heading for release-notes display
function ConvertTo-ReleaseNotesHeadingText {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[AllowNull()]
[string]$Line
)
if ([string]::IsNullOrWhiteSpace($Line)) {
return ''
}
$cleanLine = $Line.Trim()
$cleanLine = $cleanLine -replace '^#+\s*', ''
$cleanLine = [regex]::Replace($cleanLine, '\[([^\]]+)\]\([^)]+\)', '$1')
$cleanLine = $cleanLine -replace '\*\*', ''
$cleanLine = $cleanLine -replace '`', ''
return $cleanLine.Trim()
}
# Function to clean plain text segments before rendering markdown-aware inlines
function ConvertTo-ReleaseNotesPlainText {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[AllowNull()]
[string]$Text
)
if ([string]::IsNullOrWhiteSpace($Text)) {
return ''
}
$cleanText = $Text
$cleanText = $cleanText -replace '\*\*', ''
$cleanText = $cleanText -replace '`', ''
return $cleanText
}
# Function to add markdown-aware inline content to a TextBlock
function Add-ReleaseNotesInlinesToTextBlock {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[System.Windows.Controls.TextBlock]$TextBlock,
[Parameter(Mandatory = $false)]
[AllowNull()]
[string]$Text
)
if ([string]::IsNullOrWhiteSpace($Text)) {
return
}
$matchPattern = '(?<MarkdownLink>\[(?<LinkText>[^\]]+)\]\((?<LinkUrl>https?://[^)\s]+)\))|(?<BareUrl>https?://[^\s)]+)|(?<Bold>\*\*(?<BoldText>.+?)\*\*)'
$currentIndex = 0
foreach ($match in [regex]::Matches($Text, $matchPattern)) {
if ($match.Index -gt $currentIndex) {
$plainText = ConvertTo-ReleaseNotesPlainText -Text $Text.Substring($currentIndex, $match.Index - $currentIndex)
if (-not [string]::IsNullOrWhiteSpace($plainText)) {
$TextBlock.Inlines.Add([System.Windows.Documents.Run]::new($plainText)) | Out-Null
}
}
if ($match.Groups['MarkdownLink'].Success) {
$hyperlink = [System.Windows.Documents.Hyperlink]::new()
$hyperlink.NavigateUri = [System.Uri]$match.Groups['LinkUrl'].Value
$hyperlink.ToolTip = $match.Groups['LinkUrl'].Value
$hyperlink.Inlines.Add([System.Windows.Documents.Run]::new($match.Groups['LinkText'].Value)) | Out-Null
$hyperlink.Add_RequestNavigate({
param($eventSource, $requestNavigateEventArgs)
Start-Process $requestNavigateEventArgs.Uri.AbsoluteUri
$requestNavigateEventArgs.Handled = $true
})
$TextBlock.Inlines.Add($hyperlink) | Out-Null
}
elseif ($match.Groups['BareUrl'].Success) {
$bareUrl = $match.Groups['BareUrl'].Value.TrimEnd('.', ',', ';', ':')
$hyperlink = [System.Windows.Documents.Hyperlink]::new()
$hyperlink.NavigateUri = [System.Uri]$bareUrl
$hyperlink.ToolTip = $bareUrl
$hyperlink.Inlines.Add([System.Windows.Documents.Run]::new($bareUrl)) | Out-Null
$hyperlink.Add_RequestNavigate({
param($eventSource, $requestNavigateEventArgs)
Start-Process $requestNavigateEventArgs.Uri.AbsoluteUri
$requestNavigateEventArgs.Handled = $true
})
$TextBlock.Inlines.Add($hyperlink) | Out-Null
$trailingCharactersLength = $match.Groups['BareUrl'].Value.Length - $bareUrl.Length
if ($trailingCharactersLength -gt 0) {
$trailingCharacters = $match.Groups['BareUrl'].Value.Substring($bareUrl.Length, $trailingCharactersLength)
$TextBlock.Inlines.Add([System.Windows.Documents.Run]::new($trailingCharacters)) | Out-Null
}
}
elseif ($match.Groups['Bold'].Success) {
$boldRun = [System.Windows.Documents.Run]::new((ConvertTo-ReleaseNotesPlainText -Text $match.Groups['BoldText'].Value))
$boldRun.FontWeight = 'SemiBold'
$TextBlock.Inlines.Add($boldRun) | Out-Null
}
$currentIndex = $match.Index + $match.Length
}
if ($currentIndex -lt $Text.Length) {
$remainingText = ConvertTo-ReleaseNotesPlainText -Text $Text.Substring($currentIndex)
if (-not [string]::IsNullOrWhiteSpace($remainingText)) {
$TextBlock.Inlines.Add([System.Windows.Documents.Run]::new($remainingText)) | Out-Null
}
}
}
# Function to build a formatted UI element for a release-notes section body
function New-ReleaseNotesSectionContent {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[AllowNull()]
[string]$Content
)
$contentPanel = New-Object System.Windows.Controls.StackPanel
$contentPanel.Margin = '0,2,0,2'
foreach ($contentLine in ($Content -split "`r?`n")) {
$trimmedLine = $contentLine.Trim()
if ([string]::IsNullOrWhiteSpace($trimmedLine)) {
continue
}
$isFirstRenderedLine = ($contentPanel.Children.Count -eq 0)
$textBlock = New-Object System.Windows.Controls.TextBlock
$textBlock.TextWrapping = 'Wrap'
$textBlock.Margin = if ($isFirstRenderedLine) { '0,2,0,0' } else { '0,12,0,0' }
$lineContent = $trimmedLine
$listItemMatch = [regex]::Match($trimmedLine, '^(?:[-*]|\d+\.)\s+(.+)$')
if ($listItemMatch.Success) {
$textBlock.Margin = if ($isFirstRenderedLine) { '0,2,0,0' } else { '0,10,0,0' }
$textBlock.Inlines.Add([System.Windows.Documents.Run]::new([string][char]0x2022 + ' ')) | Out-Null
$lineContent = $listItemMatch.Groups[1].Value
}
Add-ReleaseNotesInlinesToTextBlock -TextBlock $textBlock -Text $lineContent
$contentPanel.Children.Add($textBlock) | Out-Null
}
if ($contentPanel.Children.Count -eq 0) {
$fallbackTextBlock = New-Object System.Windows.Controls.TextBlock
$fallbackTextBlock.Text = 'No additional details were published for this section.'
$fallbackTextBlock.TextWrapping = 'Wrap'
$fallbackTextBlock.Margin = '0,2,0,0'
$contentPanel.Children.Add($fallbackTextBlock) | Out-Null
}
return $contentPanel
}
# Function to parse the full GitHub release notes into UI sections
function Get-FFUBuilderReleaseNotesSections {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[AllowNull()]
[string]$ReleaseNotesBody
)
$releaseNoteSections = [System.Collections.Generic.List[object]]::new()
if ([string]::IsNullOrWhiteSpace($ReleaseNotesBody)) {
$releaseNoteSections.Add([PSCustomObject]@{
Title = 'Release Notes'
Content = 'No release notes were published for this release.'
UseExpander = $false
IsExpanded = $true
})
return $releaseNoteSections
}
$currentTitle = 'Release Overview'
$currentLines = [System.Collections.Generic.List[string]]::new()
foreach ($releaseNotesLine in ($ReleaseNotesBody -split "`r?`n")) {
$trimmedLine = $releaseNotesLine.Trim()
if ($trimmedLine -match '^#+\s*(.+)$') {
$sectionContent = ($currentLines -join [Environment]::NewLine).Trim()
if (-not [string]::IsNullOrWhiteSpace($sectionContent)) {
$useExpander = (($sectionContent -split "`r?`n").Count -gt 2 -or $sectionContent.Length -gt 220)
$releaseNoteSections.Add([PSCustomObject]@{
Title = $currentTitle
Content = $sectionContent
UseExpander = $useExpander
IsExpanded = ($releaseNoteSections.Count -eq 0)
})
}
$currentTitle = ConvertTo-ReleaseNotesHeadingText -Line $matches[1]
$currentLines = [System.Collections.Generic.List[string]]::new()
continue
}
if ([string]::IsNullOrWhiteSpace($trimmedLine)) {
if ($currentLines.Count -gt 0 -and $currentLines[$currentLines.Count - 1] -ne '') {
$currentLines.Add('')
}
continue
}
$currentLines.Add($trimmedLine)
}
$finalSectionContent = ($currentLines -join [Environment]::NewLine).Trim()
if (-not [string]::IsNullOrWhiteSpace($finalSectionContent)) {
$useExpander = (($finalSectionContent -split "`r?`n").Count -gt 2 -or $finalSectionContent.Length -gt 220)
$releaseNoteSections.Add([PSCustomObject]@{
Title = $currentTitle
Content = $finalSectionContent
UseExpander = $useExpander
IsExpanded = ($releaseNoteSections.Count -eq 0)
})
}
if ($releaseNoteSections.Count -eq 0) {
$releaseNoteSections.Add([PSCustomObject]@{
Title = 'Release Notes'
Content = 'No release notes were published for this release.'
UseExpander = $false
IsExpanded = $true
})
}
return $releaseNoteSections
}
# Function to render formatted release notes into the Home page
function Set-HomeReleaseNotesContent {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[PSCustomObject]$State,
[Parameter(Mandatory = $false)]
[AllowNull()]
[string]$ReleaseNotesBody
)
$releaseNotesPanel = $State.Controls.spHomeReleaseNotesSections
if ($null -eq $releaseNotesPanel) {
return
}
$releaseNotesPanel.Children.Clear()
$releaseNoteSections = @(Get-FFUBuilderReleaseNotesSections -ReleaseNotesBody $ReleaseNotesBody)
foreach ($releaseNoteSection in $releaseNoteSections) {
$sectionContent = New-ReleaseNotesSectionContent -Content $releaseNoteSection.Content
if ($releaseNoteSection.UseExpander) {
$headerTextBlock = New-Object System.Windows.Controls.TextBlock
$headerTextBlock.Text = $releaseNoteSection.Title
$headerTextBlock.TextWrapping = 'Wrap'
$headerTextBlock.FontWeight = 'SemiBold'
$releaseNotesExpander = New-Object System.Windows.Controls.Expander
$releaseNotesExpander.Header = $headerTextBlock
$releaseNotesExpander.IsExpanded = [bool]$releaseNoteSection.IsExpanded
$releaseNotesExpander.Margin = '0,0,0,8'
$releaseNotesExpander.Content = $sectionContent
$releaseNotesPanel.Children.Add($releaseNotesExpander) | Out-Null
}
else {
$releaseNotesSectionPanel = New-Object System.Windows.Controls.StackPanel
$releaseNotesSectionPanel.Margin = '0,0,0,8'
if (-not [string]::IsNullOrWhiteSpace($releaseNoteSection.Title)) {
$titleTextBlock = New-Object System.Windows.Controls.TextBlock
$titleTextBlock.Text = $releaseNoteSection.Title
$titleTextBlock.FontWeight = 'SemiBold'
$titleTextBlock.TextWrapping = 'Wrap'
$releaseNotesSectionPanel.Children.Add($titleTextBlock) | Out-Null
}
$releaseNotesSectionPanel.Children.Add($sectionContent) | Out-Null
$releaseNotesPanel.Children.Add($releaseNotesSectionPanel) | Out-Null
}
}
}
# Function to return a Home page status light brush for environment checks
function Get-HomeStatusBrush {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet('Green', 'Yellow', 'Red')]
[string]$Level
)
switch ($Level) {
'Green' { return [System.Windows.Media.Brushes]::LimeGreen }
'Yellow' { return [System.Windows.Media.Brushes]::Gold }
'Red' { return [System.Windows.Media.Brushes]::IndianRed }
}
}
# Function to evaluate free disk space on the drive hosting the FFU development path
function Get-FFUBuilderDiskSpaceStatus {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$FFUDevelopmentPath
)
try {
$resolvedPath = if (Test-Path -Path $FFUDevelopmentPath) {
(Resolve-Path -Path $FFUDevelopmentPath -ErrorAction Stop).Path
}
else {
$FFUDevelopmentPath
}
$driveRoot = [System.IO.Path]::GetPathRoot($resolvedPath)
if ([string]::IsNullOrWhiteSpace($driveRoot)) {
throw "Unable to determine a drive root for path $FFUDevelopmentPath"
}
$driveInfo = [System.IO.DriveInfo]::new($driveRoot)
$freeSpaceGb = [math]::Round($driveInfo.AvailableFreeSpace / 1GB, 2)
if ($freeSpaceGb -lt 50) {
return [PSCustomObject]@{
Level = 'Red'
Message = "$freeSpaceGb GB free on $driveRoot. FFU Builder is likely to run out of disk space and should have at least 100 GB free."
}
}
if ($freeSpaceGb -lt 100) {
return [PSCustomObject]@{
Level = 'Yellow'
Message = "$freeSpaceGb GB free on $driveRoot. FFU Builder recommends at least 100 GB free space."
}
}
return [PSCustomObject]@{
Level = 'Green'
Message = "$freeSpaceGb GB free on $driveRoot. Free space is within the recommended range."
}
}
catch {
WriteLog "Unable to determine free disk space for FFUDevelopmentPath: $($_.Exception.Message)"
return [PSCustomObject]@{
Level = 'Red'
Message = 'Unable to determine free disk space for the FFUDevelopmentPath drive.'
}
}
}
# Function to evaluate the local Hyper-V installation state
function Get-FFUBuilderHyperVStatus {
[CmdletBinding()]
param()
try {
$hyperVFeature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -ErrorAction Stop
switch ([string]$hyperVFeature.State) {
'Enabled' {
return [PSCustomObject]@{
Level = 'Green'
Message = 'Hyper-V is installed and ready.'
}
}
'EnablePending' {
return [PSCustomObject]@{
Level = 'Yellow'
Message = 'Hyper-V is installed, but a reboot is required before it is ready.'
}
}
default {
return [PSCustomObject]@{
Level = 'Red'
Message = "Hyper-V is not installed. Current feature state: $($hyperVFeature.State)."
}
}
}
}
catch {
WriteLog "Unable to determine Hyper-V installation state: $($_.Exception.Message)"
return [PSCustomObject]@{
Level = 'Red'
Message = 'Unable to determine the Hyper-V installation state.'
}
}
}
# Function to update the Home page release status fields
function Update-HomeReleaseStatus {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[PSCustomObject]$State,
[Parameter(Mandatory = $true)]
[string]$CurrentBuild,
[Parameter(Mandatory = $true)]
[string]$LatestRelease,
[Parameter(Mandatory = $true)]
[string]$StatusMessage,
[Parameter(Mandatory = $true)]
[string]$ReleaseNotesBody
)
if ($null -ne $State.Controls.txtHomeCurrentBuildValue) {
$State.Controls.txtHomeCurrentBuildValue.Text = $CurrentBuild
}
if ($null -ne $State.Controls.txtHomeLatestReleaseValue) {
$State.Controls.txtHomeLatestReleaseValue.Text = $LatestRelease
}
if ($null -ne $State.Controls.txtHomeReleaseStatusValue) {
$State.Controls.txtHomeReleaseStatusValue.Text = $StatusMessage
}
# Render the full release notes into structured sections on the Home page
Set-HomeReleaseNotesContent -State $State -ReleaseNotesBody $ReleaseNotesBody
}
# Function to update the Home page environment check fields
function Update-HomeEnvironmentStatus {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[PSCustomObject]$State,
[Parameter(Mandatory = $true)]
[PSCustomObject]$DiskSpaceStatus,
[Parameter(Mandatory = $true)]
[PSCustomObject]$HyperVStatus
)
if ($null -ne $State.Controls.ellipseHomeDiskSpaceStatus) {
$State.Controls.ellipseHomeDiskSpaceStatus.Fill = Get-HomeStatusBrush -Level $DiskSpaceStatus.Level
}
if ($null -ne $State.Controls.txtHomeDiskSpaceStatusValue) {
$State.Controls.txtHomeDiskSpaceStatusValue.Text = $DiskSpaceStatus.Message
}
if ($null -ne $State.Controls.ellipseHomeHyperVStatus) {
$State.Controls.ellipseHomeHyperVStatus.Fill = Get-HomeStatusBrush -Level $HyperVStatus.Level
}
if ($null -ne $State.Controls.txtHomeHyperVStatusValue) {
$State.Controls.txtHomeHyperVStatusValue.Text = $HyperVStatus.Message
}
}
# Function to retrieve latest public GitHub discussions for Home page display
function Get-FFUBuilderLatestDiscussions {
[CmdletBinding()]
param()
$discussionUri = 'https://github.com/rbalsleyMSFT/FFU/discussions'
$discussionHeaders = @{
'User-Agent' = 'FFUBuilderUI'
'Accept' = 'text/html,application/xhtml+xml'
}
$discussionResponse = Invoke-WebRequest -Uri $discussionUri -Headers $discussionHeaders -TimeoutSec 5 -ErrorAction Stop
$discussionContent = [string]$discussionResponse.Content
$latestDiscussions = New-Object System.Collections.Generic.List[PSCustomObject]
$seenDiscussionUrls = @{}
# Parse the raw HTML instead of Invoke-WebRequest Links because GitHub's page structure
# does not reliably surface the discussion topic anchors through the Links collection.
$discussionMatches = [regex]::Matches(
$discussionContent,
'<a[^>]+href="(?<Href>/rbalsleyMSFT/FFU/discussions/(?<Id>\d+))"[^>]*>(?<InnerHtml>.*?)</a>',
[System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline
)
foreach ($discussionMatch in $discussionMatches) {
$discussionHref = [string]$discussionMatch.Groups['Href'].Value
$discussionUrl = "https://github.com$discussionHref"
if ($seenDiscussionUrls.ContainsKey($discussionUrl)) {
continue
}
$discussionInnerHtml = [string]$discussionMatch.Groups['InnerHtml'].Value
$discussionTitle = [regex]::Replace($discussionInnerHtml, '<[^>]+>', ' ')
$discussionTitle = [System.Net.WebUtility]::HtmlDecode($discussionTitle)
$discussionTitle = [regex]::Replace($discussionTitle, '\s+', ' ').Trim()
if ([string]::IsNullOrWhiteSpace($discussionTitle)) {
continue
}
# Skip links that resolve to comment counts or other numeric-only link text.
if ($discussionTitle -match '^\d+$') {
continue
}
$seenDiscussionUrls[$discussionUrl] = $true
$latestDiscussions.Add([PSCustomObject]@{
Title = $discussionTitle
Url = $discussionUrl
})
if ($latestDiscussions.Count -ge 5) {
break
}
}
return $latestDiscussions
}
# Function to update the Home page discussions card
function Update-HomeDiscussions {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[PSCustomObject]$State,
[Parameter(Mandatory = $true)]
[string]$StatusMessage,
[Parameter(Mandatory = $false)]
[AllowNull()]
[System.Collections.IEnumerable]$Discussions
)
if ($null -ne $State.Controls.txtHomeDiscussionsStatusValue) {
$State.Controls.txtHomeDiscussionsStatusValue.Text = $StatusMessage
}
$discussionItems = @($Discussions)
for ($index = 1; $index -le 5; $index++) {
$container = $State.Controls["tbDiscussion$index"]
$link = $State.Controls["linkDiscussion$index"]
$run = $State.Controls["runDiscussion$index"]
if ($null -eq $container -or $null -eq $link -or $null -eq $run) {
continue
}
if ($index -le $discussionItems.Count -and $null -ne $discussionItems[$index - 1]) {
$discussionItem = $discussionItems[$index - 1]
$run.Text = $discussionItem.Title
$link.NavigateUri = [System.Uri]$discussionItem.Url
$container.Visibility = 'Visible'
}
else {
$run.Text = ''
$link.NavigateUri = [System.Uri]'https://github.com/rbalsleyMSFT/FFU/discussions'
$container.Visibility = 'Collapsed'
}
}
if ($null -ne $State.Controls.tbDiscussionsLink) {
$State.Controls.tbDiscussionsLink.Visibility = 'Visible'
}
}
# Function to populate the Home page build status after the window has rendered
function Start-HomeStatusRefresh {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[PSCustomObject]$State
)
# Populate local status checks immediately so Home is useful even before network requests complete
$currentBuild = Get-FFUBuilderCurrentBuild -FFUDevelopmentPath $State.FFUDevelopmentPath
$diskSpaceStatus = Get-FFUBuilderDiskSpaceStatus -FFUDevelopmentPath $State.FFUDevelopmentPath
$hyperVStatus = Get-FFUBuilderHyperVStatus
Update-HomeReleaseStatus -State $State -CurrentBuild $currentBuild -LatestRelease 'Checking GitHub...' -StatusMessage 'Checking whether this build is current...' -ReleaseNotesBody 'Checking latest release notes...'
Update-HomeEnvironmentStatus -State $State -DiskSpaceStatus $diskSpaceStatus -HyperVStatus $hyperVStatus
Update-HomeDiscussions -State $State -StatusMessage 'Checking latest discussions...' -Discussions @()
if ($null -eq $State.Window) {
return
}
# Capture the state values before dispatching to avoid losing them in the deferred callback
$refreshState = $State
$refreshCurrentBuild = $currentBuild
$refreshAction = {
$latestReleaseDisplay = 'Unable to check'
$statusMessage = 'Unable to check the latest release right now. Check GitHub Releases when you are back online.'
$releaseNotesBody = 'Unable to load the latest release notes right now.'
$discussionsStatusMessage = 'Unable to load the latest GitHub discussions right now.'
$latestDiscussions = @()
try {
$latestRelease = Get-FFUBuilderLatestRelease
if ($null -ne $latestRelease -and -not [string]::IsNullOrWhiteSpace($latestRelease.Version)) {
# Strip the GitHub tag prefix so Home shows the same style as the installed build
$latestReleaseDisplay = $latestRelease.Version -replace '^[vV]', ''
$statusMessage = Get-FFUBuilderReleaseStatusMessage -CurrentBuild $refreshCurrentBuild -LatestRelease $latestRelease.Version
$releaseNotesBody = if ([string]::IsNullOrWhiteSpace($latestRelease.Body)) {
'No release notes were published for this release.'
}
else {
$latestRelease.Body
}
}
}
catch {
WriteLog "Unable to retrieve the latest FFU Builder release: $($_.Exception.Message)"
}
try {
$latestDiscussions = @(Get-FFUBuilderLatestDiscussions)
if ($latestDiscussions.Count -gt 0) {
$discussionsStatusMessage = 'Latest public GitHub discussions.'
}
else {
$discussionsStatusMessage = 'No recent public discussion topics were found.'
}
}
catch {
WriteLog "Unable to retrieve the latest FFU Builder discussions: $($_.Exception.Message)"
}
Update-HomeReleaseStatus -State $refreshState -CurrentBuild $refreshCurrentBuild -LatestRelease $latestReleaseDisplay -StatusMessage $statusMessage -ReleaseNotesBody $releaseNotesBody
Update-HomeDiscussions -State $refreshState -StatusMessage $discussionsStatusMessage -Discussions $latestDiscussions
}.GetNewClosure()
# Queue the network checks after the UI renders so startup remains responsive
$null = $State.Window.Dispatcher.BeginInvoke(
[System.Action]$refreshAction,
[System.Windows.Threading.DispatcherPriority]::Background
)
}
# --------------------------------------------------------------------------
# SECTION: Module Export
# --------------------------------------------------------------------------
@@ -1,4 +0,0 @@
select disk 0
select partition 3
Assign letter="M"
exit
@@ -1,237 +0,0 @@
$VMHostIPAddress = '192.168.1.158'
$ShareName = 'FFUCaptureShare'
$UserName = 'ffu_user'
$Password = '23202eb4-10c3-47e9-b389-f0c462663a23'
$CustomFFUNameTemplate = '{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}'
$netuseCommand = "net use W: \\$VMHostIPAddress\$ShareName /user:$UserName $Password 2>&1"
# Connect to network share
try {
Write-Host "Connecting to network share via $netuseCommand"
$netUseResult = net use W: "\\$VMHostIPAddress\$ShareName" "/user:$UserName" "$Password" 2>&1
# Check if the result contains an error
if ($LASTEXITCODE -ne 0) {
# Extract the error code from the Exception Message
# Example message format: "System error 53 has occurred."
$message = $netUseResult.Exception.Message
$regex = [regex]'System error (\d+)'
$match = $regex.Match($message)
if ($match.Success) {
$errorCode = [int]$match.Groups[1].Value
$errorMessage = switch ($errorCode) {
53 { "Network path not found. Verify the IP address is correct and the server is accessible." }
67 { "Network name cannot be found. Verify the share name exists on the server." }
86 { "Password is incorrect for the specified username." }
1219 { "Multiple connections to the share exist."}
1326 { "Logon failure: unknown username or bad password." }
1385 { "Logon failure: the user has not been granted the requested logon type at this computer.
This is likely due to changes to the User Rights Assignment: Access this computer from the network local security policy
See: https://github.com/rbalsleyMSFT/FFU/issues/122 for more info" }
1792 { "Unable to connect. Verify the server is running and accepting connections." }
2250 { "Network connection attempt timed out." }
default { "Network connection failed with error code: $errorCode. Details: $message" }
}
# Write-Error $errorMessage
throw $errorMessage
}
}
} catch {
Write-Error "Failed to connect to network share: Error code: $errorcode $_"
Write-Host "Some things to try:"
Write-Host '1. If not using an external switch, change to using an external switch'
Write-Host '2. Make sure the VMHostIPAddress is correct for the VMSwitch that is being used'
Write-Host '3. Try disabling the Windows Firewall on the host machine as a test only. If that helps, there is a Windows firewall rule that is blocking SMB 445 into the VM host'
Write-Host '4. If this is a machine that is managed by your organization, try using another machine that is not managed. There could be security policies in place that are blocking the connection to the share.'
Write-Host '5. You can also try disabling Hyper-V and re-enabling it. This has helped some users in the past.'
Write-Host '6. If all else fails, open an issue on the github repo and attach screenshots of this message, your FFUDevelopment.log, your command line that you used to build the FFU, and/or the config file you used (if you used one).'
pause
throw
}
$AssignDriveLetter = 'x:\AssignDriveLetter.txt'
try {
Write-Host 'Assigning M: as Windows drive letter'
Start-Process -FilePath diskpart.exe -ArgumentList "/S $AssignDriveLetter" -Wait -ErrorAction Stop | Out-Null
}
catch {
Write-Error "Failed to assign drive letter using diskpart: $_"
}
#Load Registry Hive
$Software = 'M:\Windows\System32\config\software'
try {
Write-Host "Loading software registry hive to $Software"
if (-not (Test-Path -Path $Software)) {
throw "Software registry hive not found at $Software"
}
$regResult = reg load "HKLM\FFU" $Software 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Registry load failed with exit code $($LASTEXITCODE): $regResult"
}
Write-Host "Successfully loaded software registry hive."
}
catch {
Write-Error "Failed to load registry hive: $_"
}
try {
#Find Windows version values
Write-Host "Retrieving Windows information from the registry..."
$SKU = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'EditionID'
Write-Host "SKU: $SKU"
[int]$CurrentBuild = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'CurrentBuild'
Write-Host "CurrentBuild: $CurrentBuild"
if ($CurrentBuild -notin 14393, 17763) {
Write-Host "CurrentBuild is not 14393 or 17763, retrieving WindowsVersion..."
$WindowsVersion = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'DisplayVersion'
Write-Host "WindowsVersion: $WindowsVersion"
}
$InstallationType = Get-ItemPropertyValue -Path 'HKLM:\FFU\Microsoft\Windows NT\CurrentVersion\' -Name 'InstallationType'
Write-Host "InstallationType: $InstallationType"
$BuildDate = Get-Date -uformat %b%Y
Write-Host "BuildDate: $BuildDate"
$SKU = switch ($SKU) {
Core { 'Home' }
CoreN { 'Home_N' }
CoreSingleLanguage { 'Home_SL' }
Professional { 'Pro' }
ProfessionalN { 'Pro_N' }
ProfessionalEducation { 'Pro_Edu' }
ProfessionalEducationN { 'Pro_Edu_N' }
Enterprise { 'Ent' }
EnterpriseN { 'Ent_N' }
EnterpriseS { 'Ent_LTSC' }
EnterpriseSN { 'Ent_N_LTSC' }
IoTEnterpriseS { 'IoT_Ent_LTSC' }
Education { 'Edu' }
EducationN { 'Edu_N' }
ProfessionalWorkstation { 'Pro_Wks' }
ProfessionalWorkstationN { 'Pro_Wks_N' }
ServerStandard { 'Srv_Std' }
ServerDatacenter { 'Srv_Dtc' }
}
if ($InstallationType -eq "Client") {
if ($CurrentBuild -ge 22000) {
$WindowsRelease = 'Win11'
Write-Host "WindowsRelease: $WindowsRelease"
}
else {
$WindowsRelease = 'Win10'
Write-Host "WindowsRelease: $WindowsRelease"
}
}
else {
$WindowsRelease = switch ($CurrentBuild) {
26100 { '2025' }
20348 { '2022' }
17763 { '2019' }
14393 { '2016' }
Default { $WindowsVersion }
}
Write-Host "WindowsRelease: $WindowsRelease"
if ($InstallationType -eq "Server Core") {
$SKU += "_Core"
Write-Host "InstallType is Server Core, changing SKU to: $SKU"
}
}
if ($CustomFFUNameTemplate) {
Write-Host 'Using custom FFU name template...'
$FFUFileName = $CustomFFUNameTemplate
$FFUFileName = $FFUFileName -replace '{WindowsRelease}', $WindowsRelease
$FFUFileName = $FFUFileName -replace '{WindowsVersion}', $WindowsVersion
$FFUFileName = $FFUFileName -replace '{SKU}', $SKU
$FFUFileName = $FFUFileName -replace '{BuildDate}', $BuildDate
$FFUFileName = $FFUFileName -replace '{yyyy}', (Get-Date -UFormat '%Y')
$FFUFileName = $FFUFileName -creplace '{MM}', (Get-Date -UFormat '%m')
$FFUFileName = $FFUFileName -replace '{dd}', (Get-Date -UFormat '%d')
$FFUFileName = $FFUFileName -creplace '{HH}', (Get-Date -UFormat '%H')
$FFUFileName = $FFUFileName -creplace '{hh}', (Get-Date -UFormat '%I')
$FFUFileName = $FFUFileName -creplace '{mm}', (Get-Date -UFormat '%M')
$FFUFileName = $FFUFileName -replace '{tt}', (Get-Date -UFormat '%p')
Write-Host "FFU File Name: $FFUFileName"
#If the custom FFU name template does not end with .ffu, append it
if ($FFUFileName -notlike '*.ffu') {
$FFUFileName += '.ffu'
Write-Host "Appended .ffu to FFU file name: $FFUFileName"
}
$dismArgs = "/capture-ffu /imagefile=W:\$FFUFileName /capturedrive=\\.\PhysicalDrive0 /name:$WindowsRelease$WindowsVersion$SKU /Compress:Default"
Write-Host "DISM arguments for capture: $dismArgs"
}
else {
#If Office is installed, modify the file name of the FFU
$Office = Get-ChildItem -Path 'M:\Program Files\Microsoft Office' -ErrorAction SilentlyContinue
if ($Office) {
$ffuFilePath = "W:\$WindowsRelease`_$WindowsVersion`_$SKU`_Office`_$BuildDate.ffu"
Write-Host "Office is installed, using modified FFU file name: $ffuFilePath"
}
else {
$ffuFilePath = "W:\$WindowsRelease`_$WindowsVersion`_$SKU`_Apps`_$BuildDate.ffu"
Write-Host "Office is not installed, using modified FFU file name: $ffuFilePath"
}
$dismArgs = "/capture-ffu /imagefile=$ffuFilePath /capturedrive=\\.\PhysicalDrive0 /name:$WindowsRelease$WindowsVersion$SKU /Compress:Default"
Write-Host "DISM arguments for capture: $dismArgs"
}
#Unload Registry
Set-Location X:\
Remove-Variable SKU
Remove-Variable CurrentBuild
if ($CurrentBuild -notin 14393, 17763) {
Remove-Variable WindowsVersion
}
if ($Office) {
Remove-Variable Office
}
try {
Write-Host "Unloading registry hive HKLM\FFU..."
$regUnloadResult = reg unload "HKLM\FFU" 2>&1
if ($LASTEXITCODE -ne 0) {
throw "Registry unload failed with exit code $($LASTEXITCODE): $regUnloadResult"
}
Write-Host "Successfully unloaded registry hive."
}
catch {
Write-Error "Failed to unload registry hive: $_"
}
Write-Host "Sleeping for 60 seconds to allow registry to unload prior to capture"
Start-sleep 60
try {
Write-Host "Starting DISM FFU capture..."
$dismProcess = Start-Process -FilePath dism.exe -ArgumentList $dismArgs -Wait -PassThru -ErrorAction Stop
if ($dismProcess.ExitCode -ne 0) {
throw "DISM capture failed with exit code $($dismProcess.ExitCode)"
}
Write-Host "DISM FFU capture completed successfully."
}
catch {
Write-Error "FFU capture failed: $_"
}
try {
Write-Host "Copying DISM log to network share..."
xcopy X:\Windows\logs\dism\dism.log W:\ /Y | Out-Null
}
catch {
Write-Warning "Failed to copy DISM log: $_"
}
Write-Host "DISM log copied to network share, shutting down..."
wpeutil Shutdown
}
catch {
Write-Error "An unexpected error occurred: $_"
}
@@ -1,5 +0,0 @@
wpeinit
powercfg /s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c
powershell -Noprofile -ExecutionPolicy Bypass -File x:\CaptureFFU.ps1
exit
+359 -129
View File
@@ -23,7 +23,7 @@ function Get-HardDrive() {
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'
$diskDriveCandidates = @(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 $_.SCSILogicalUnit -eq 0
})
@@ -41,6 +41,59 @@ function WriteLog($LogText) {
Add-Content -path $LogFile -value "$((Get-Date).ToString()) $LogText"
}
function Read-MenuSelection {
param(
[Parameter(Mandatory = $true)]
[string]$Prompt,
[Parameter(Mandatory = $true)]
[string]$InvalidInputMessage,
[Parameter()]
[int[]]$ValidSelections,
[Parameter()]
[int]$Minimum = [int]::MinValue,
[Parameter()]
[int]$Maximum = [int]::MaxValue,
[Parameter()]
[switch]$AllowSkip
)
do {
$userInput = Read-Host $Prompt
if ([string]::IsNullOrWhiteSpace($userInput)) {
Write-Host $InvalidInputMessage
continue
}
$selection = 0
if (-not [int]::TryParse($userInput, [ref]$selection)) {
Write-Host $InvalidInputMessage
continue
}
if ($AllowSkip -and $selection -eq 0) {
return 0
}
if ($PSBoundParameters.ContainsKey('ValidSelections')) {
if ($ValidSelections -notcontains $selection) {
Write-Host $InvalidInputMessage
continue
}
}
elseif ($selection -lt $Minimum -or $selection -gt $Maximum) {
Write-Host $InvalidInputMessage
continue
}
return $selection
} until ($false)
}
function Set-DiskpartAnswerFiles($DiskpartFile, $DiskID) {
(Get-Content $DiskpartFile).Replace('disk 0', "disk $DiskID") | Set-Content -Path $DiskpartFile
}
@@ -64,6 +117,68 @@ function Set-Computername($computername) {
return $computername
}
function Get-UnattendComputerNameValue {
if ($null -eq $UnattendFile) {
return $null
}
[xml]$xml = Get-Content $UnattendFile
foreach ($component in $xml.unattend.settings.component) {
if ($component.ComputerName) {
return [string]$component.ComputerName
}
}
return $null
}
function Test-LegacyPromptComputerName($computername) {
if ([string]::IsNullOrWhiteSpace($computername)) {
return $false
}
$normalizedName = $computername.Trim().ToLowerInvariant()
return $normalizedName -in @('mycomputer', 'default')
}
function Get-NormalizedComputerName($computername) {
if ([string]::IsNullOrWhiteSpace($computername)) {
throw 'Computer name cannot be empty.'
}
$normalizedName = ($computername -replace "\s", '').Trim()
if ([string]::IsNullOrWhiteSpace($normalizedName)) {
throw 'Computer name cannot be empty after removing spaces.'
}
if ($normalizedName.Length -gt 15) {
$normalizedName = $normalizedName.Substring(0, 15)
}
return $normalizedName
}
function Resolve-ComputerNameTemplate($computerNameTemplate, $serialNumber) {
if ([string]::IsNullOrWhiteSpace($computerNameTemplate)) {
throw 'Computer name template cannot be empty.'
}
$resolvedName = $computerNameTemplate -replace '(?i)%serial%', $serialNumber
if ($resolvedName -match '%') {
throw 'Unsupported device name variable found. Only %serial% is supported.'
}
return Get-NormalizedComputerName($resolvedName)
}
function Set-ConfiguredComputerName($computername) {
$normalizedName = Get-NormalizedComputerName($computername)
$normalizedName = Set-Computername($normalizedName)
Writelog "Computer name will be set to $normalizedName"
Write-Host "Computer name will be set to $normalizedName"
return $normalizedName
}
function Invoke-Process {
[CmdletBinding(SupportsShouldProcess)]
param
@@ -74,7 +189,13 @@ function Invoke-Process {
[Parameter()]
[ValidateNotNullOrEmpty()]
[string]$ArgumentList
[string]$ArgumentList,
[Parameter()]
[switch]$IgnoreExitCode,
[Parameter()]
[switch]$PassThruExitCode
)
$ErrorActionPreference = 'Stop'
@@ -96,19 +217,39 @@ function Invoke-Process {
$cmd = Start-Process @startProcessParams
$cmdOutput = Get-Content -Path $stdOutTempFile -Raw
$cmdError = Get-Content -Path $stdErrTempFile -Raw
if ($cmd.ExitCode -ne 0) {
# Non-terminating mode: capture output to Scriptlog and continue
if ($IgnoreExitCode) {
if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) {
WriteLog $cmdOutput
}
if ([string]::IsNullOrEmpty($cmdError) -eq $false) {
WriteLog $cmdError
}
if ($PassThruExitCode) {
return $cmd.ExitCode
}
return
}
if ($cmdError) {
throw $cmdError.Trim()
}
if ($cmdOutput) {
throw $cmdOutput.Trim()
}
throw "Process failed. ExitCode = $($cmd.ExitCode)."
}
else {
if ([string]::IsNullOrEmpty($cmdOutput) -eq $false) {
WriteLog $cmdOutput
}
}
if ($PassThruExitCode) {
return $cmd.ExitCode
}
}
}
catch {
@@ -558,6 +699,21 @@ function Find-DriverMappingRule {
return $null
}
'Microsoft' {
# Prefer System SKU matching for Microsoft/Surface when available.
if (-not [string]::IsNullOrWhiteSpace($systemSkuNormalized)) {
foreach ($rule in $rulesForMake) {
if ($rule.PSObject.Properties['SystemSku'] -and $null -ne $rule.SystemSku) {
foreach ($sku in @($rule.SystemSku)) {
if (-not [string]::IsNullOrWhiteSpace($sku) -and $sku.Trim().ToUpperInvariant() -eq $systemSkuNormalized) {
WriteLog "DriverMapping: Microsoft SystemSku '$systemSkuNormalized' matched '$($rule.Model)'."
return $rule
}
}
}
}
}
# Fallback to model string comparison (legacy behavior).
foreach ($rule in $rulesForMake) {
$ruleModelNorm = ConvertTo-ComparableModelName -Text $rule.Model
if (-not [string]::IsNullOrWhiteSpace($ruleModelNorm) -and $ruleModelNorm -eq $normalizedModel) {
@@ -733,9 +889,9 @@ function Test-DriverFolderHasInstallableContent {
WriteLog "Failed to inspect driver folder '$Path': $($_.Exception.Message)"
return $false
}
}
}
function Get-AvailableDriveLetter {
function Get-AvailableDriveLetter {
$usedLetters = (Get-PSDrive -PSProvider FileSystem).Name | ForEach-Object { $_.ToUpperInvariant() }
for ($ascii = [int][char]'Z'; $ascii -ge [int][char]'A'; $ascii--) {
$candidate = [char]$ascii
@@ -744,9 +900,9 @@ function Test-DriverFolderHasInstallableContent {
}
}
return $null
}
}
function New-DriverSubstMapping {
function New-DriverSubstMapping {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
@@ -769,9 +925,9 @@ function Test-DriverFolderHasInstallableContent {
DriveName = $driveName
DrivePath = $mappedPath
}
}
}
function Remove-DriverSubstMapping {
function Remove-DriverSubstMapping {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
@@ -787,14 +943,14 @@ function Test-DriverFolderHasInstallableContent {
catch {
WriteLog "Failed to remove SUBST drive $($driveName): $_"
}
}
}
#Get USB Drive and create log file
#Get USB Drive and create log file
$LogFileName = 'ScriptLog.txt'
$USBDrive = Get-USBDrive
New-item -Path $USBDrive -Name $LogFileName -ItemType "file" -Force | Out-Null
$LogFile = $USBDrive + $LogFilename
$version = '2601.1Preview'
$version = '2604.1'
WriteLog 'Begin Logging'
WriteLog "Script version: $version"
@@ -850,21 +1006,7 @@ else {
}
$displayList | Format-Table -AutoSize -Property Disk, 'Size (GB)', Sector, 'Bus Type', Model
do {
try {
$var = $true
[int]$diskSelection = Read-Host 'Enter the disk number to apply the FFU to'
}
catch {
Write-Host 'Input was not in correct format. Please enter a valid disk number'
$var = $false
}
# Validate selected disk is in the list of available disks
if ($var -and $validDiskIndexes -notcontains $diskSelection) {
Write-Host "Invalid disk number. Please select from the available disks."
$var = $false
}
} until ($var)
$diskSelection = Read-MenuSelection -Prompt 'Enter the disk number to apply the FFU to' -InvalidInputMessage 'Invalid disk number. Please select from the available disks.' -ValidSelections $validDiskIndexes
$selectedDisk = $diskDriveCandidates | Where-Object { $_.Index -eq $diskSelection }
WriteLog "Disk selection: DiskNumber=$($selectedDisk.Index), Model=$($selectedDisk.Model), SizeGB=$([math]::Round(($selectedDisk.Size / 1GB), 2)), BusType=$($selectedDisk.InterfaceType)"
@@ -908,18 +1050,8 @@ If ($FFUCount -gt 1) {
$array += New-Object PSObject -Property $Properties
}
$array | Format-Table -AutoSize -Property Number, FFUFile | Out-Host
do {
try {
$var = $true
[int]$FFUSelected = Read-Host 'Enter the FFU number to install'
$FFUSelected = Read-MenuSelection -Prompt 'Enter the FFU number to install' -InvalidInputMessage 'Input was not in correct format. Please enter a valid FFU number.' -Minimum 1 -Maximum $FFUCount
$FFUSelected = $FFUSelected - 1
}
catch {
Write-Host 'Input was not in correct format. Please enter a valid FFU number'
$var = $false
}
} until (($FFUSelected -le $FFUCount - 1) -and $var)
$FFUFileToInstall = $array[$FFUSelected].FFUFile
WriteLog "$FFUFileToInstall was selected"
@@ -982,13 +1114,26 @@ If (Test-Path -Path $UnattendComputerNamePath) {
}
}
#Ask for device name if unattend exists
If ($Unattend -or $UnattendPrefix -or $UnattendComputerName) {
$UnattendConfiguredComputerName = $null
$RequiresLegacyDeviceNamePrompt = $false
$RequiresTemplateDeviceName = $false
if ($Unattend) {
$UnattendConfiguredComputerName = Get-UnattendComputerNameValue
$RequiresLegacyDeviceNamePrompt = Test-LegacyPromptComputerName($UnattendConfiguredComputerName)
if (-not [string]::IsNullOrWhiteSpace($UnattendConfiguredComputerName) -and $UnattendConfiguredComputerName -match '(?i)%serial%') {
$RequiresTemplateDeviceName = $true
}
}
#Ask for device name if naming is explicitly required
If ($UnattendPrefix -or $UnattendComputerName -or $RequiresTemplateDeviceName -or $RequiresLegacyDeviceNamePrompt) {
Write-SectionHeader 'Device Name Selection'
if ($Unattend -and $UnattendPrefix) {
Writelog 'Unattend file found with prefixes.txt. Getting prefixes.'
$UnattendPrefixes = @(Get-content $UnattendPrefixFile)
$UnattendPrefixCount = $UnattendPrefixes.Count
$skipPrefixSelection = $false
$PrefixToUse = $null
If ($UnattendPrefixCount -gt 1) {
WriteLog "Found $UnattendPrefixCount Prefixes"
$array = @()
@@ -997,21 +1142,19 @@ If ($Unattend -or $UnattendPrefix -or $UnattendComputerName) {
$array += New-Object PSObject -Property $Properties
}
$array | Format-Table -AutoSize -Property Number, DeviceNamePrefix
do {
try {
$var = $true
[int]$PrefixSelected = Read-Host 'Enter the prefix number to use for the device name'
$PrefixSelected = $PrefixSelected - 1
$prefixSelection = Read-MenuSelection -Prompt 'Enter the prefix number to use for the device name (0 to skip)' -InvalidInputMessage 'Input was not in correct format. Please enter 0 to skip or a valid prefix number.' -Minimum 1 -Maximum $UnattendPrefixCount -AllowSkip
if ($prefixSelection -eq 0) {
$skipPrefixSelection = $true
WriteLog 'User chose to skip device name prefix selection. Existing unattend computer name will remain unchanged.'
Write-Host "`nDevice name prefix selection was skipped. The existing unattend computer name will remain unchanged."
}
catch {
Write-Host 'Input was not in correct format. Please enter a valid prefix number'
$var = $false
}
} until (($PrefixSelected -le $UnattendPrefixCount - 1) -and $var)
else {
$PrefixSelected = $prefixSelection - 1
$PrefixToUse = $array[$PrefixSelected].DeviceNamePrefix
WriteLog "$PrefixToUse was selected"
Write-Host "`n$PrefixToUse was selected as device name prefix"
}
}
elseif ($UnattendPrefixCount -eq 1) {
WriteLog "Found $UnattendPrefixCount Prefix"
Write-Host "Found $UnattendPrefixCount Prefix"
@@ -1019,17 +1162,10 @@ If ($Unattend -or $UnattendPrefix -or $UnattendComputerName) {
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
if (-not $skipPrefixSelection) {
$serial = (Get-CimInstance -ClassName win32_bios).SerialNumber.Trim()
#Combine prefix with serial
$computername = ($PrefixToUse + $serial) -replace "\s", "" # Remove spaces because windows does not support spaces in the computer names
#If computername is longer than 15 characters, reduce to 15. Sysprep/unattend doesn't like ComputerName being longer than 15 characters even though Windows accepts it
If ($computername.Length -gt 15) {
$computername = $computername.substring(0, 15)
$computername = Set-ConfiguredComputerName($PrefixToUse + $serial)
}
$computername = Set-Computername($computername)
Writelog "Computer name will be set to $computername"
Write-Host "Computer name will be set to $computername"
}
elseif ($Unattend -and $UnattendComputerName) {
Writelog 'Unattend file found with SerialComputerNames.csv. Getting name for current computer.'
@@ -1039,32 +1175,31 @@ If ($Unattend -or $UnattendPrefix -or $UnattendComputerName) {
$SCName = $SerialComputerNames | Where-Object { $_.SerialNumber -eq $SerialNumber }
If ($SCName) {
[string]$computername = $SCName.ComputerName
$computername = Set-Computername($computername)
Writelog "Computer name will be set to $computername"
Write-Host "Computer name will be set to $computername"
[string]$computername = Set-ConfiguredComputerName($SCName.ComputerName)
}
else {
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]$_ })))
$computername = Set-Computername($computername)
Writelog "Computer name will be set to $computername"
Write-Host "Computer name will be set to $computername"
[string]$computername = Set-ConfiguredComputerName(("FFU-" + ( -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 11 | ForEach-Object { [char]$_ }))))
}
}
elseif ($Unattend) {
elseif ($Unattend -and $RequiresTemplateDeviceName) {
Writelog 'Unattend file found with a %serial% computer name template. Resolving the template.'
$serialNumber = (Get-CimInstance -ClassName Win32_Bios).SerialNumber.Trim()
[string]$computername = Set-ConfiguredComputerName((Resolve-ComputerNameTemplate -computerNameTemplate $UnattendConfiguredComputerName -serialNumber $serialNumber))
}
elseif ($Unattend -and $RequiresLegacyDeviceNamePrompt) {
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'
$computername = Set-Computername($computername)
Writelog "Computer name will be set to $computername"
Write-Host "Computer name will be set to $computername"
[string]$computername = Set-ConfiguredComputerName((Read-Host 'Enter device name'))
}
else {
WriteLog 'Device naming assets detected without unattend.xml. Skipping device naming prompts.'
}
}
elseif ($Unattend) {
WriteLog 'Unattend file found. Device naming is not required, but unattend settings will still be applied.'
}
else {
WriteLog 'No unattend folder found. Device name will be set via PPKG, AP JSON, or default OS name.'
}
@@ -1073,17 +1208,7 @@ else {
If ($autopilot -eq $true -and $PPKG -eq $true) {
WriteLog 'Both PPKG and Autopilot json files found'
Write-Host 'Both Autopilot JSON files and Provisioning packages were found.'
do {
try {
$var = $true
[int]$APorPPKG = Read-Host 'Enter 1 for Autopilot or 2 for Provisioning Package'
}
catch {
Write-Host 'Incorrect value. Please enter 1 for Autopilot or 2 for Provisioning Package'
$var = $false
}
} until (($APorPPKG -gt 0 -and $APorPPKG -lt 3) -and $var)
$APorPPKG = Read-MenuSelection -Prompt 'Enter 1 for Autopilot or 2 for Provisioning Package' -InvalidInputMessage 'Incorrect value. Please enter 1 for Autopilot or 2 for Provisioning Package.' -Minimum 1 -Maximum 2
If ($APorPPKG -eq 1) {
$PPKG = $false
}
@@ -1102,22 +1227,20 @@ If ($APFilesCount -gt 1 -and $autopilot -eq $true) {
$array += New-Object PSObject -Property $Properties
}
$array | Format-Table -AutoSize -Property Number, APFileName
do {
try {
$var = $true
[int]$APFileSelected = Read-Host 'Enter the AP json file number to install'
$APFileSelected = $APFileSelected - 1
}
$APFileSelection = Read-MenuSelection -Prompt 'Enter the AP json file number to install (0 to skip)' -InvalidInputMessage 'Input was not in correct format. Please enter 0 to skip or a valid AP json file number.' -Minimum 1 -Maximum $APFilesCount -AllowSkip
catch {
Write-Host 'Input was not in correct format. Please enter a valid AP json file number'
$var = $false
if ($APFileSelection -eq 0) {
$APFileToInstall = $null
$APFileName = $null
WriteLog 'User chose to skip Autopilot JSON selection.'
Write-Host "`nAutopilot JSON selection was skipped."
}
} until (($APFileSelected -le $APFilesCount - 1) -and $var)
else {
$APFileSelected = $APFileSelection - 1
$APFileToInstall = $array[$APFileSelected].APFile
$APFileName = $array[$APFileSelected].APFileName
WriteLog "$APFileToInstall was selected"
}
}
elseif ($APFilesCount -eq 1 -and $autopilot -eq $true) {
WriteLog "Found $APFilesCount AP File"
@@ -1140,22 +1263,19 @@ If ($PPKGFilesCount -gt 1 -and $PPKG -eq $true) {
$array += New-Object PSObject -Property $Properties
}
$array | Format-Table -AutoSize -Property Number, PPKGFileName
do {
try {
$var = $true
[int]$PPKGFileSelected = Read-Host 'Enter the PPKG file number to install'
$PPKGFileSelected = $PPKGFileSelected - 1
}
$PPKGFileSelection = Read-MenuSelection -Prompt 'Enter the PPKG file number to install (0 to skip)' -InvalidInputMessage 'Input was not in correct format. Please enter 0 to skip or a valid PPKG file number.' -Minimum 1 -Maximum $PPKGFilesCount -AllowSkip
catch {
Write-Host 'Input was not in correct format. Please enter a valid PPKG file number'
$var = $false
if ($PPKGFileSelection -eq 0) {
$PPKGFileToInstall = $null
WriteLog 'User chose to skip Provisioning Package selection.'
Write-Host "`nProvisioning Package selection was skipped."
}
} until (($PPKGFileSelected -le $PPKGFilesCount - 1) -and $var)
else {
$PPKGFileSelected = $PPKGFileSelection - 1
$PPKGFileToInstall = $array[$PPKGFileSelected].PPKGFile
WriteLog "$PPKGFileToInstall was selected"
Write-Host "`n$PPKGFileToInstall will be used"
}
}
elseif ($PPKGFilesCount -eq 1 -and $PPKG -eq $true) {
Write-SectionHeader -Title 'Provisioning Package Selection'
@@ -1271,7 +1391,7 @@ if ($null -eq $DriverSourcePath) {
if ([string]::IsNullOrWhiteSpace($relativeSegment)) {
return Split-Path -Path $normalizedPath -Leaf
}
return $relativePath = $relativeSegment
return $relativeSegment
}
return $normalizedPath
}
@@ -1350,21 +1470,13 @@ if ($null -eq $DriverSourcePath) {
$DriverSelected = -1
$skipDriverInstall = $false
do {
try {
$var = $true
[int]$userSelection = Read-Host 'Enter the number of the driver source to install (0 to skip)'
$userSelection = Read-MenuSelection -Prompt 'Enter the number of the driver source to install (0 to skip)' -InvalidInputMessage 'Input was not in correct format. Please enter 0 to skip or a valid number.' -Minimum 1 -Maximum $DriverSourcesCount -AllowSkip
if ($userSelection -eq 0) {
$skipDriverInstall = $true
break
}
else {
$DriverSelected = $userSelection - 1
}
catch {
Write-Host 'Input was not in correct format. Please enter a valid number.'
$var = $false
}
} until ((($DriverSelected -ge 0 -and $DriverSelected -lt $DriverSourcesCount) -or $skipDriverInstall) -and $var)
if ($skipDriverInstall) {
$DriverSourcePath = $null
@@ -1527,8 +1639,9 @@ If ($PPKGFileToInstall) {
}
}
#Set DeviceName
If ($computername) {
Write-SectionHeader -Title 'Applying Computer Name and Unattend Configuration'
If ($Unattend) {
$unattendSectionTitle = if ($computername) { 'Applying Computer Name and Unattend Configuration' } else { 'Applying Unattend Configuration' }
Write-SectionHeader -Title $unattendSectionTitle
try {
$PantherDir = 'w:\windows\panther'
If (Test-Path -Path $PantherDir) {
@@ -1549,8 +1662,8 @@ If ($computername) {
}
}
catch {
WriteLog "Copying Unattend.xml to name device failed"
Stop-Script -Message "Copying Unattend.xml to name device failed with error: $_"
WriteLog 'Copying Unattend.xml to Panther failed'
Stop-Script -Message "Copying Unattend.xml to Panther failed with error: $_"
}
}
@@ -1562,65 +1675,182 @@ if ($null -ne $DriverSourcePath) {
Write-Host "Installing drivers from WIM: $DriverSourcePath"
$TempDriverDir = "W:\TempDrivers"
try {
# Create working folder for WIM-based drivers
WriteLog "Creating temporary directory for drivers at $TempDriverDir"
New-Item -Path $TempDriverDir -ItemType Directory -Force | Out-Null
# Mount the driver WIM read-only so DISM can recurse the extracted INF tree
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
$mountExitCode = $LASTEXITCODE
if ($mountExitCode -ne 0) {
throw "DISM WIM mount failed. LastExitCode = $mountExitCode."
}
WriteLog "WIM mount successful."
# Inject drivers into the offline Windows image; failures here should not stop deployment
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."
$driverInjectExitCode = Invoke-Process -FilePath dism.exe -ArgumentList "/image:W:\ /Add-Driver /Driver:""$TempDriverDir"" /Recurse" -IgnoreExitCode -PassThruExitCode
if ($driverInjectExitCode -ne 0) {
$warningMessage = "Warning: One or more drivers failed to inject from WIM. ExitCode = $driverInjectExitCode. Continuing deployment."
WriteLog $warningMessage
Write-Host $warningMessage -ForegroundColor Yellow
# Copy setupapi.offline.log to the USB drive when driver injection fails
$setupApiLogPath = 'W:\Windows\INF\setupapi.offline.log'
if (Test-Path -Path $setupApiLogPath) {
try {
Invoke-Process xcopy.exe """$setupApiLogPath"" ""$USBDrive"" /Y"
}
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 $_
WriteLog "Warning: Failed to copy setupapi.offline.log to $USBDrive. "
}
}
else {
WriteLog "Warning: setupapi.offline.log not found at $setupApiLogPath"
}
}
else {
WriteLog "Driver injection from WIM succeeded."
Write-Host "Driver injection from WIM succeeded."
}
}
catch {
$warningMessage = "Warning: An error occurred during WIM driver installation. Continuing deployment."
WriteLog $warningMessage
Write-Host $warningMessage -ForegroundColor Yellow
# Copy troubleshooting logs to the USB drive when driver installation fails
try {
Invoke-Process cmd.exe "/c copy /Y ""X:\Windows\logs\dism\dism.log"" ""$($USBDrive)dism_driverinject.log"""
}
catch {
WriteLog "Warning: Failed to copy dism.log to $USBDrive."
}
$setupApiLogPath = 'W:\Windows\INF\setupapi.offline.log'
if (Test-Path -Path $setupApiLogPath) {
try {
Invoke-Process xcopy.exe """$setupApiLogPath"" ""$USBDrive"" /Y"
}
catch {
WriteLog "Warning: Failed to copy setupapi.offline.log to $USBDrive."
}
}
else {
WriteLog "Warning: setupapi.offline.log not found at $setupApiLogPath"
}
}
finally {
if (Test-Path -Path $TempDriverDir) {
# Always attempt to unmount and clean up; unmount failures should not stop deployment
WriteLog "Unmounting WIM from $TempDriverDir"
Write-Host "Unmounting WIM from $TempDriverDir"
try {
Invoke-Process dism.exe "/Unmount-Image /MountDir:""$TempDriverDir"" /Discard"
WriteLog "Unmount successful."
Write-Host "Unmount successful."
}
catch {
$warningMessage = "Warning: Failed to unmount WIM from $TempDriverDir. Continuing cleanup."
WriteLog $warningMessage
Write-Host $warningMessage -ForegroundColor Yellow
}
WriteLog "Cleaning up temporary driver directory: $TempDriverDir"
Write-Host "Cleaning up temporary driver directory: $TempDriverDir"
try {
Remove-Item -Path $TempDriverDir -Recurse -Force
WriteLog "Cleanup successful."
Write-Host "Cleanup successful."
}
catch {
$warningMessage = "Warning: Failed to clean up temporary driver directory: $TempDriverDir."
WriteLog $warningMessage
Write-Host $warningMessage -ForegroundColor Yellow
}
}
}
}
elseif ($DriverSourceType -eq 'Folder') {
$substMapping = $null
try {
# Use SUBST to shorten long paths for DISM /Add-Driver
$substMapping = New-DriverSubstMapping -SourcePath $DriverSourcePath
$shortDriverPath = $substMapping.DrivePath
WriteLog "Injecting drivers from folder via SUBST. Source: $DriverSourcePath, Mapped: $($substMapping.DriveName)"
Write-Host "Injecting drivers from folder: $shortDriverPath"
Write-Host "This may take a while, please be patient."
Invoke-Process dism.exe "/image:W:\ /Add-Driver /Driver:$shortDriverPath /Recurse"
# Inject drivers into the offline Windows image; failures here should not stop deployment
$driverInjectExitCode = Invoke-Process -FilePath dism.exe -ArgumentList "/image:W:\ /Add-Driver /Driver:$shortDriverPath /Recurse" -IgnoreExitCode -PassThruExitCode
if ($driverInjectExitCode -ne 0) {
$warningMessage = "Warning: One or more drivers failed to inject from folder. ExitCode = $driverInjectExitCode. Continuing deployment."
WriteLog $warningMessage
Write-Host $warningMessage -ForegroundColor Yellow
# Copy setupapi.offline.log to the USB drive when driver injection fails
$setupApiLogPath = 'W:\Windows\INF\setupapi.offline.log'
if (Test-Path -Path $setupApiLogPath) {
try {
Invoke-Process xcopy.exe """$setupApiLogPath"" ""$USBDrive"" /Y"
}
catch {
WriteLog "Warning: Failed to copy setupapi.offline.log to $USBDrive. "
}
}
else {
WriteLog "Warning: setupapi.offline.log not found at $setupApiLogPath"
}
}
else {
WriteLog "Driver injection from folder succeeded."
Write-Host "Driver injection from folder succeeded."
}
}
catch {
WriteLog "An error occurred during folder driver installation: $_"
$warningMessage = "Warning: An error occurred during folder driver installation. Continuing deployment."
WriteLog $warningMessage
Write-Host $warningMessage -ForegroundColor Yellow
# Copy troubleshooting logs to the USB drive when driver installation fails
try {
Invoke-Process xcopy.exe "X:\Windows\logs\dism\dism.log $USBDrive /Y"
throw $_
}
catch {
WriteLog "Warning: Failed to copy dism.log to $USBDrive."
}
$setupApiLogPath = 'W:\Windows\INF\setupapi.offline.log'
if (Test-Path -Path $setupApiLogPath) {
try {
Invoke-Process xcopy.exe """$setupApiLogPath"" ""$USBDrive"" /Y"
}
catch {
WriteLog "Warning: Failed to copy setupapi.offline.log to $USBDrive."
}
}
else {
WriteLog "Warning: setupapi.offline.log not found at $setupApiLogPath"
}
}
finally {
# Always attempt to remove SUBST mapping; failures here should not stop deployment
if ($null -ne $substMapping) {
try {
Remove-DriverSubstMapping -DriveLetter $substMapping.DriveLetter
}
catch {
$warningMessage = "Warning: Failed to remove SUBST mapping $($substMapping.DriveLetter). Continuing deployment."
WriteLog $warningMessage
Write-Host $warningMessage -ForegroundColor Yellow
}
}
}
}
}
Binary file not shown.
@@ -0,0 +1,4 @@
SerialNumber,ComputerName
ABC12345,CORP-001
DEF67890,KIOSK-010
XYZ24680,STORE-015
1 SerialNumber ComputerName
2 ABC12345 CORP-001
3 DEF67890 KIOSK-010
4 XYZ24680 STORE-015
@@ -3,7 +3,7 @@
<settings pass="specialize">
<!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it -->
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ComputerName>MYCOMPUTER</ComputerName><!--Leave Default will be renamed-->
<ComputerName>*</ComputerName><!--Leave Default will be renamed-->
<TimeZone>Eastern Standard Time</TimeZone><!--Add Your Local TimeZone-->
</component>
<!-- Place additional Components Elements and Settings below here: -->
+1 -1
View File
@@ -3,7 +3,7 @@
<settings pass="specialize">
<!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it -->
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="arm64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ComputerName>MyComputer</ComputerName>
<ComputerName>*</ComputerName>
</component>
<!--Place addtional Components Elements and settings below here. -->
</settings>
+1 -1
View File
@@ -3,7 +3,7 @@
<settings pass="specialize">
<!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it -->
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ComputerName>MyComputer</ComputerName>
<ComputerName>*</ComputerName>
</component>
<!--Place addtional Components Elements and settings below here. -->
</settings>
+6 -78
View File
@@ -18,88 +18,16 @@ And the best part: **it takes less than two minutes** to apply the image, even w
The Full-Flash update (FFU) process can automatically download the latest release of Windows 11, the updates mentioned above, and creates a USB drive that can be used to quickly reimage a machine.
# 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
- Download the latest [release](https://github.com/rbalsleyMSFT/FFU/releases)
- Extract the FFUDevelopment folder from the ZIP file (recommend to C:\FFUDevelopment)
- Watch the Youtube video (updated docs for the UI coming soon)
If you're new to FFU Builder or new to the FFU Builder UI version, check out the [Quick Start Guide](https://rbalsleymsft.github.io/FFU/quickstart.html).
## YouTube Detailed Walkthrough
This will be the fastest way to create your first FFU. There's a new [FFU Builder Quickstart Youtube video](https://youtu.be/38sUc3M5Yls) based on the 2604.1 release.
Here's a detailed overview of the new UI process.
## Older Youtube Videos
[![Reimage Windows Fast with FFU Builder 2507.1 Preview](https://img.youtube.com/vi/oozG1aVcg9M/maxresdefault.jpg)](https://youtu.be/oozG1aVcg9M "Reimage Windows Fast with FFU Builder 2507.1 Preview")
[2602.1 UI Preview Quickstart Video](https://www.youtube.com/watch?v=kOIK5OmDugc) - Original quickstart video without the Fluent UI.
Chapters:
[2507.1 UI Preview Video](https://www.youtube.com/watch?v=oozG1aVcg9M) - First UI Preview release video. This goes deeper than the quick start video, but is missing some features that have been added since 2507.1 was released.
[00:00](https://www.youtube.com/watch?v=oozG1aVcg9M&t=0s) Begin
[01:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=67s) Prereqs
[06:32](https://www.youtube.com/watch?v=oozG1aVcg9M&t=392s) Demo Begins
[07:16](https://www.youtube.com/watch?v=oozG1aVcg9M&t=436s) Running the BuildFFUVM_UI.ps1 script
[08:15](https://www.youtube.com/watch?v=oozG1aVcg9M&t=495s) UI Overview
[10:13](https://www.youtube.com/watch?v=oozG1aVcg9M&t=613s) Hyper-V Settings
[16:04](https://www.youtube.com/watch?v=oozG1aVcg9M&t=964s) Windows Settings
[22:35](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1355s) Updates
[24:49](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1489s) Applications
[29:39](https://www.youtube.com/watch?v=oozG1aVcg9M&t=1779s) Install Winget Applications
[45:29](https://www.youtube.com/watch?v=oozG1aVcg9M&t=2729s) Bring Your Own Applications
[54:14](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3254s) Apps Script Variables
[57:43](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3463s) M365 Apps/Office
[59:01](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3541s) Drivers
[01:01:22](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3682s) Drivers.json example
[01:02:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3727s) DriverMapping.json explanation
[01:06:08](https://www.youtube.com/watch?v=oozG1aVcg9M&t=3968s) Driver WIM Compression
[01:10:50](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4250s) Build
[01:12:41](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4361s) Build USB Drive
[01:20:07](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4807s) Monitor
[01:20:32](https://www.youtube.com/watch?v=oozG1aVcg9M&t=4832s) Setting up the Demo Build
[01:24:10](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5050s) Save/Load Config Files
[01:25:11](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5111s) Kicking off the Demo Build/Going over the monitor tab
[01:32:26](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5546s) Demoing the new FFU Builder Orchestrator
[01:35:25](https://www.youtube.com/watch?v=oozG1aVcg9M&t=5725s) New captureffu.ps1 console output
[01:42:29](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6149s) Demo Build Complete
[01:42:42](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6162s) How to configure a VM to test your newly built FFU
[01:48:58](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6538s) The moment of truth: What does the new deployment experience look like?
[01:53:13](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6793s) How to bypass OOBE using a provisioning package
[01:55:49](https://www.youtube.com/watch?v=oozG1aVcg9M&t=6949s) Preview Focus Areas
[02:04:04](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7444s) Known Issues/Things to fix before GA
[02:05:38](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7538s) Providing Feedback
[02:06:43](https://www.youtube.com/watch?v=oozG1aVcg9M&t=7603s) Thank you
[2407.2 Video](https://www.youtube.com/watch?v=rqXRbgeeKSQ) - This was the main deep-dive video on FFU Builder (before it had that name). This is a good deep dive into how the BuildFFUVM.ps1 script works, but a lot has changed since that build.
+69
View File
@@ -0,0 +1,69 @@
---
title: M365 Apps/Office
nav_order: 8
prev_url: /appsscriptvariables.html
prev_label: Apps Script Variables
next_url: /drivers.html
next_label: Drivers
parent: UI Overview
---
# M365 Apps/Office
![1776379074990](image/M365appsoffice/1776379074990.png)
FFU Builder uses the Office Deployment Toolkit (ODT) to install Office. In the `.\FFUDevelopment\Apps\Office` folder you'll find two files:
* `DownloadFFU.xml`
* `DeployFFU.xml`
## DownloadFFU.xml
`DownloadFFU.xml` is responsible for the download of Office. It's invoked by `setup.exe /download .\DownloadFFU.xml` during the build process. It defaults to downloading the current channel 64-bit version of Office matching the current OS language to `C:\FFUDevelopment\Apps\Office`.
`DownloadFFU.xml` contents:
```
<Configuration ID="efa6df21-a106-428e-8eaa-d89a5dda6030">
<Add SourcePath="C:\FFUDevelopment\Apps\Office" OfficeClientEdition="64" Channel="Current">
<Product ID="O365ProPlusRetail">
<Language ID="MatchOS" />
</Product>
</Add>
</Configuration>
```
If you want to modify the language, you'll need to change the language ID to the language you wish to download and install.
For more information about deploying languages see: [Overview of deploying languages for Microsoft 365 Apps - Microsoft 365 Apps Microsoft Learn](https://learn.microsoft.com/en-us/microsoft-365-apps/deploy/overview-deploying-languages-microsoft-365-apps)
## DeployFFU.xml
`DeployFFU.xml` is responsible for customizing the installation of Office. If you don't provide a custom XML, it will default to using what's in `DeployFFU.xml`. The default configuration will install the 64-bit current channel version of Office with Word, Excel, Powerpoint. Below is what's currently in `DeployFFU.xml`:
```
<Configuration ID="efa6df21-a106-428e-8eaa-d89a5dda6030">
<Add OfficeClientEdition="64" Channel="Current">
<Product ID="O365ProPlusRetail">
<Language ID="MatchOS" />
<ExcludeApp ID="Access" />
<ExcludeApp ID="Lync" />
<ExcludeApp ID="Publisher" />
<ExcludeApp ID="Bing" />
<ExcludeApp ID="Teams" />
<ExcludeApp ID="Outlook" />
</Product>
</Add>
<Property Name="SharedComputerLicensing" Value="0" />
<Property Name="FORCEAPPSHUTDOWN" Value="FALSE" />
<Property Name="DeviceBasedLicensing" Value="0" />
<Property Name="SCLCacheOverride" Value="0" />
<Updates Enabled="TRUE" />
<Display Level="None" AcceptEULA="TRUE" />
</Configuration>
```
## Copy Office Configuration XML
If you want to include your own custom XML file for office, check **Copy Office Configuration XML** and browse to the location of your custom XML file. The path to your custom Office configuration XML file is stored in the `OfficeConfigXMLFile` parameter. This file gets added to the `.\FFUDevelopment\Apps\Office` folder and is referenced in the `.\FFUDevelopment\Apps\Orchestration\Install-Office.ps1` file.
{% include page_nav.html %}
+25
View File
@@ -0,0 +1,25 @@
title: FFU Builder
description: Build and deploy Windows FFU images
remote_theme: just-the-docs/just-the-docs@v0.10.1
plugins:
- jekyll-remote-theme
- jekyll-seo-tag
search_enabled: true
# Canonical host for absolute URLs (sitemap/robots/canonical tags)
url: "https://rbalsleymsft.github.io"
# Because youll publish as a project site at /FFU
baseurl: "/FFU"
callouts:
note:
title: Note
color: purple
tip:
title: Tip
color: green
warning:
title: Warning
color: yellow
+292
View File
@@ -0,0 +1,292 @@
{% if page.url == '/' or page.url == '/index.html' %}
<meta name="google-site-verification" content="2O8GqDcQF_fvyvZjeTz-YlTaN2p62FfWd9w-xHU4Zbc" />
{% endif %}
{% if page.url == '/' or page.url == '/index.html' %}
<meta name="google-site-verification" content="2O8GqDcQF_fvyvZjeTz-YlTaN2p62FfWd9w-xHU4Zbc" />
{% endif %}
<!-- docs/_includes/head_custom.html -->
<style>
/* Layout: remove Just-the-Docs "centered narrow" constraints on wide screens */
@media (min-width: 50rem) {
.main {
max-width: none !important;
}
}
@media (min-width: 66.5rem) {
.side-bar {
width: 16.5rem !important;
min-width: 16.5rem !important;
}
.side-bar+.main {
margin-left: 16.5rem !important;
}
}
/* Readability: wider column + slightly larger, less-thin text */
@media (min-width: 66.5rem) {
.main-content {
max-width: 1100px;
}
}
@media (min-width: 90rem) {
.main-content {
max-width: 1280px;
}
}
/* Typography: approximate Microsoft Learn (Segoe UI Variable + regular body + semibold headings) */
body,
.main-content {
font-family: "Segoe UI Variable Text", "Segoe UI Variable", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
.main-content {
margin-right: auto;
margin-left: auto;
font-size: 1rem;
/* 16px-ish, closer to Learn */
line-height: 1.6;
font-weight: 400;
/* Just-the-Docs defaults body text to a mid-grey; make it closer to Learn */
color: #242424;
}
.main-content p,
.main-content li {
line-height: 1.65;
}
/* Lists: increase indentation (Learn-like) */
/* Just-the-Docs draws bullets/numbers via ::before with negative offsets */
.main-content ul,
.main-content ol {
padding-left: 2.25em;
}
.main-content h1,
.main-content h2,
.main-content h3 {
font-weight: 600;
letter-spacing: -0.01em;
}
.main-content code,
.main-content pre code {
font-size: 0.95em;
}
/* Wrapping: prevent long code/paths from overflowing into the page TOC */
.main-content {
overflow-wrap: anywhere;
word-break: break-word;
}
.main-content :not(pre)>code {
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.main-content a {
overflow-wrap: anywhere;
word-break: break-word;
}
/* Images: make it obvious they're zoomable (opt-out via class="no-zoom") */
.main-content img:not(.no-zoom) {
cursor: zoom-in;
}
/* Image zoom: ensure the zoom overlay sits above the right TOC */
.medium-zoom-overlay {
z-index: 9999 !important;
}
.medium-zoom-image--opened {
z-index: 10000 !important;
}
/* Right-side page TOC (desktop only) */
@media (min-width: 66.5rem) {
.main-content-wrap.has-page-toc {
display: grid;
grid-template-columns: minmax(0, 1fr) 16rem;
grid-template-rows: auto 1fr;
grid-template-areas:
"breadcrumb breadcrumb"
"content toc";
column-gap: 2rem;
align-items: start;
}
/* Breadcrumbs (when present) always span full width */
.main-content-wrap.has-page-toc .breadcrumb-nav {
grid-area: breadcrumb;
}
/* Main content always stays in the left column */
.main-content-wrap.has-page-toc .main-content {
grid-area: content;
/* Prevent wide tables/code from forcing overlap */
min-width: 0;
/* Force the content to respect the grid column width (no centering/max-width overflow) */
width: 100%;
max-width: 100%;
margin-left: 0;
margin-right: 0;
justify-self: stretch;
/* Keep heading permalink icons visible */
/* (Just-the-Docs positions .anchor-heading to the left at desktop widths) */
overflow-x: visible;
}
/* Safety net: prevent long code/tables from overlapping the TOC */
.main-content-wrap.has-page-toc .main-content pre,
.main-content-wrap.has-page-toc .main-content .highlighter-rouge,
.main-content-wrap.has-page-toc .main-content .table-wrapper {
overflow-x: auto;
}
/* TOC always stays in the right column */
.page-toc {
grid-area: toc;
position: sticky;
top: 5.5rem;
max-height: calc(100vh - 6.5rem);
overflow: auto;
padding-left: 1rem;
border-left: 1px solid #eeebee;
font-size: 0.875rem;
/* Ensure the TOC doesnt visually blend with overflowing content */
background-color: #fff;
z-index: 1;
}
.page-toc__title {
font-weight: 600;
color: #27262b;
margin-bottom: 0.75rem;
}
.page-toc__list {
list-style: none;
padding-left: 0;
margin: 0;
}
.page-toc__item {
margin: 0.4rem 0;
}
.page-toc__item--h3 {
padding-left: 0.75rem;
}
.page-toc__link {
color: inherit;
text-decoration: none;
display: block;
padding: 0.125rem 0 0.125rem 0.75rem;
border-left: 3px solid transparent;
}
.page-toc__link:hover {
text-decoration: underline;
}
.page-toc__link.is-active {
font-weight: 600;
color: #1a1a1a;
border-left-color: #2563eb;
}
}
/* Inline "In this article" TOC (narrow viewports; Learn-like) */
.page-toc.page-toc--inline {
margin: 1.5rem 0;
padding-left: 1rem;
border-left: 1px solid #eeebee;
font-size: 0.875rem;
}
.page-toc--inline .page-toc__title {
font-weight: 600;
color: #27262b;
margin-bottom: 0.75rem;
}
.page-toc--inline .page-toc__list {
list-style: none !important;
padding-left: 0;
margin: 0;
}
.page-toc--inline .page-toc__item {
list-style: none !important;
margin: 0.4rem 0;
}
.page-toc--inline .page-toc__item::marker {
content: "";
}
.page-toc--inline .page-toc__item--h3 {
padding-left: 0.75rem;
}
.page-toc--inline .page-toc__link {
color: inherit;
text-decoration: none;
display: block;
padding: 0.125rem 0 0.125rem 0.75rem;
border-left: 3px solid transparent;
}
.page-toc--inline .page-toc__link:hover {
text-decoration: underline;
}
.page-toc__item.is-hidden {
display: none;
}
/* Just-the-Docs renders UL bullets via li::before; disable for inline TOC */
.page-toc--inline ul > li::before,
.page-toc--inline .page-toc__list > li::before,
.page-toc--inline .page-toc__item::before {
content: "" !important;
display: none !important;
}
.page-toc__toggle {
background: none;
border: 0;
padding: 0.25rem 0 0 0.75rem;
font: inherit;
color: inherit;
cursor: pointer;
text-decoration: none;
}
.page-toc__toggle:hover {
text-decoration: underline;
}
</style>
<meta name="ffu-right-toc" content="{% if page.right_toc == false %}false{% else %}true{% endif %}">
<script src="{{ '/assets/js/vendor/medium-zoom.min.js' | relative_url }}" defer></script>
<script src="{{ '/assets/js/image-zoom.js' | relative_url }}" defer></script>
<script src="{{ '/assets/js/page-toc.js' | relative_url }}" defer></script>
<script src="{{ '/assets/js/external-links.js' | relative_url }}" defer></script>
+17
View File
@@ -0,0 +1,17 @@
<!-- docs/_includes/page_nav.html -->
<div class="d-flex flex-justify-between mt-6">
{% assign prev_url = include.prev_url | default: page.prev_url %}
{% assign prev_label = include.prev_label| default: page.prev_label | default: 'Home' %}
{% assign next_url = include.next_url | default: page.next_url %}
{% assign next_label = include.next_label| default: page.next_label | default: 'Next' %}
{% if prev_url %}
<a class="btn btn-outline" href="{{ prev_url | relative_url }}">← {{ prev_label }}</a>
{% else %}
<span></span>
{% endif %}
{% if next_url %}
<a class="btn btn-blue" href="{{ next_url | relative_url }}">{{ next_label }} →</a>
{% endif %}
</div>
+21
View File
@@ -0,0 +1,21 @@
---
title: Applications
nav_order: 4
prev_url: /updates.html
prev_label: Updates
next_url: /winget.html
next_label: Install Winget Applications
parent: UI Overview
has_toc: false
---
# Applications
![1776378755854](image/applications/1776378755854.png)
Applications can be installed in three different ways:
* Winget (using an AppList.json file)
* Bring Your Own Applications (using files you provide - can also be used to run command lines with or without content)
* Apps Script Variables (key/value pairs used in conjunction with a PowerShell script to install custom applications)
{% include page_nav.html %}
+131
View File
@@ -0,0 +1,131 @@
---
title: Apps Script Variables
nav_order: 7
prev_url: /byoapps.html
prev_label: BYO Applications
next_url: /M365appsoffice.html
next_label: M365 Apps Office
parent: Applications
grand_parent: UI Overview
---
# Apps Script Variables
![1776379026213](image/appsscriptvariables/1776379026213.png)
Apps Script Variables are key value pairs that are used to create a hashtable that is passed to the `BuildFFUVM.ps1` script (stored in the `$AppScriptVariables` parameter as a hashtable). At build time, `BuildFFUVM.ps1` will export the `$AppsScriptVariables` hashtable to an `AppsScriptVariables.json` file in the `$OrchestrationPath` folder (`$AppsPath\Orchestration`). You can also manually create your own `AppsScriptVariables.json `file and place it in the `$AppsPath\Orchestration` folder.
In the VM, the `Orchestrator.ps1` file will call `Invoke-AppsScript.ps1` if `AppsScriptVariables.json` exists. `Invoke-AppsScript.ps1` must be modified to handle your variables.
`Invoke-AppsScript.ps1` has the following commented example of how to modify the file:
```
# Example of how to use the AppsScriptVariables hashtable to control script execution
# Note: The UI saves the values as strings, so if you type true for a value, it'll save to the config file as a string, not boolean
# Example: Check if a variable named 'foo' is set to string 'bar' and run a script accordingly
# if ($AppsScriptVariables['foo'] -eq 'bar') {
# Write-Host "Foo would have installed"
# }
# else {
# Write-Host "Foo would not have installed"
# }
# Example: Check if a variable named 'Teams' is set to string 'true' and run a script accordingly
# if ($AppsScriptVariables['Teams'] -eq 'true') {
# Write-Host "Teams would have been installed"
# }
# else {
# Write-Host "Teams would not have been installed"
# }
```
## Why use Apps Script Variables?
This allows for you to create a dynamic task sequence via a PowerShell script with simple if statements to run apps, commands, etc. This is all driven by your `FFUConfig.json` file. For example, the following `FFUConfig.json` file contains an AppsScriptVariables hashtable of foo and vmwaretools like the screenshot above. If you build servers that require vmware tools, you may set the value to true. However there may be situations where you don't need vmwaretools installed. If that's the case, you set vmwaretools to false. This allows for your `Invoke-AppsScript.ps1` file to stay the same and all you have to do is adjust the variables.
```
s{
"AdditionalFFUFiles": [],
"AllowExternalHardDiskMedia": false,
"AllowVHDXCaching": false,
"AppListPath": "C:\\FFUDevelopment\\Apps\\AppList.json",
"AppsPath": "C:\\FFUDevelopment\\Apps",
"AppsScriptVariables": {
"foo": "bar",
"vmwaretools": "true"
},
"BuildUSBDrive": false,
"CleanupAppsISO": true,
"CleanupDeployISO": true,
"CleanupDrivers": false,
"CompactOS": true,
"CompressDownloadedDriversToWim": false,
"CopyAdditionalFFUFiles": false,
"CopyAutopilot": false,
"CopyDrivers": false,
"CopyOfficeConfigXML": false,
"CopyPEDrivers": false,
"CopyPPKG": false,
"CopyUnattend": false,
"CreateDeploymentMedia": true,
"CustomFFUNameTemplate": "{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}",
"Disksize": 53687091200,
"DownloadDrivers": false,
"DriversFolder": "C:\\FFUDevelopment\\Drivers",
"DriversJsonPath": "C:\\FFUDevelopment\\Drivers\\Drivers.json",
"FFUCaptureLocation": "C:\\FFUDevelopment\\FFU",
"FFUDevelopmentPath": "C:\\FFUDevelopment",
"FFUPrefix": "_FFU",
"InjectUnattend": false,
"InstallApps": true,
"InstallDrivers": false,
"InstallOffice": false,
"InstallWingetApps": false,
"ISOPath": "",
"LogicalSectorSizeBytes": 512,
"MaxUSBDrives": 5,
"MediaType": "Consumer",
"Memory": 4294967296,
"OfficeConfigXMLFile": "",
"OfficePath": "C:\\FFUDevelopment\\Apps\\Office",
"Optimize": true,
"OptionalFeatures": "",
"OrchestrationPath": "C:\\FFUDevelopment\\Apps\\Orchestration",
"PEDriversFolder": "C:\\FFUDevelopment\\PEDrivers",
"Processors": 4,
"ProductKey": "",
"PromptExternalHardDiskMedia": true,
"RemoveApps": false,
"RemoveFFU": false,
"RemoveUpdates": false,
"Threads": 5,
"UpdateADK": true,
"UpdateEdge": true,
"UpdateLatestCU": true,
"UpdateLatestDefender": true,
"UpdateLatestMicrocode": false,
"UpdateLatestMSRT": true,
"UpdateLatestNet": true,
"UpdateOneDrive": true,
"UpdatePreviewCU": false,
"USBDriveList": {},
"UseDriversAsPEDrivers": false,
"UserAppListPath": "C:\\FFUDevelopment\\Apps\\UserAppList.json",
"Verbose": false,
"VMLocation": "C:\\FFUDevelopment\\VM",
"VMSwitchName": "External",
"WindowsArch": "x64",
"WindowsLang": "en-us",
"WindowsRelease": 11,
"WindowsSKU": "Pro",
"WindowsVersion": "25H2"
}
```
Example command line to run with vmwaretools set to false and foo set to foo. This will create the `AppsScriptVariables.json` file in the Orchestration folder with the updated values of `foo=foo` and `vmwaretools=false` without the need to modify the config file.
`.\BuildFFUVM.ps1 -configFile 'C:\FFUDevelopment\config\FFUConfig.json' -appsScriptVariables @{foo='foo'; vmwaretools='false'}`
{% include page_nav.html %}
+91
View File
@@ -0,0 +1,91 @@
(function () {
'use strict';
function HasToken(tokens, token) {
for (var i = 0; i < tokens.length; i++) {
if (tokens[i] === token) {
return true;
}
}
return false;
}
function AddRelToken(anchor, token) {
var rel = (anchor.getAttribute('rel') || '').trim();
var tokens = rel ? rel.split(/\s+/) : [];
if (!HasToken(tokens, token)) {
tokens.push(token);
}
anchor.setAttribute('rel', tokens.join(' ').trim());
}
function IsExternalHttpLink(url) {
if (!url) {
return false;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return false;
}
return url.origin !== window.location.origin;
}
function InitExternalLinksNewTab() {
var mainContent = document.querySelector('.main-content');
if (!mainContent) {
return;
}
var anchors = mainContent.querySelectorAll('a[href]');
for (var i = 0; i < anchors.length; i++) {
var anchor = anchors[i];
var href = (anchor.getAttribute('href') || '').trim();
if (!href) {
continue;
}
if (href.charAt(0) === '#') {
continue;
}
if (href.indexOf('mailto:') === 0 || href.indexOf('tel:') === 0 || href.indexOf('javascript:') === 0) {
continue;
}
var url = null;
try {
url = new URL(href, window.location.href);
} catch (e) {
continue;
}
if (!IsExternalHttpLink(url)) {
continue;
}
var target = (anchor.getAttribute('target') || '').trim();
if (!target) {
anchor.setAttribute('target', '_blank');
target = '_blank';
}
if (target === '_blank') {
AddRelToken(anchor, 'noopener');
AddRelToken(anchor, 'noreferrer');
}
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', InitExternalLinksNewTab);
return;
}
InitExternalLinksNewTab();
})();
+22
View File
@@ -0,0 +1,22 @@
(function () {
'use strict';
function InitImageZoom() {
if (window.mediumZoom === undefined) {
return;
}
window.mediumZoom('.main-content img:not(.no-zoom):not([src$=".svg"])', {
margin: 24,
background: 'rgba(0,0,0,0.80)',
scrollOffset: 0
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', InitImageZoom);
return;
}
InitImageZoom();
})();
+403
View File
@@ -0,0 +1,403 @@
(function () {
'use strict';
var scrollSpyDispose = null;
var resizeReinitTimerId = null;
var inlineMaxVisibleItems = 4;
function IsRightTocEnabled() {
var meta = document.querySelector('meta[name="ffu-right-toc"]');
if (meta && meta.content && meta.content.toLowerCase() === 'false') {
return false;
}
return true;
}
function IsDesktopViewport() {
try {
return window.matchMedia && window.matchMedia('(min-width: 66.5rem)').matches;
} catch (e) {
return false;
}
}
function RemoveExistingToc() {
if (scrollSpyDispose) {
scrollSpyDispose();
scrollSpyDispose = null;
}
var existingTocs = document.querySelectorAll('.page-toc');
for (var i = 0; i < existingTocs.length; i++) {
existingTocs[i].remove();
}
var wrap = document.querySelector('.main-content-wrap');
if (wrap) {
wrap.classList.remove('has-page-toc');
}
}
function InsertInlineToc(main, toc) {
if (!main || !toc) {
return;
}
var title = main.querySelector('h1');
if (title && title.parentNode === main) {
if (title.nextSibling) {
main.insertBefore(toc, title.nextSibling);
return;
}
main.appendChild(toc);
return;
}
if (main.firstChild) {
main.insertBefore(toc, main.firstChild);
return;
}
main.appendChild(toc);
}
function GetHeadings(container) {
var headings = container.querySelectorAll('h2, h3');
var results = [];
for (var i = 0; i < headings.length; i++) {
var heading = headings[i];
if (heading.classList.contains('no_toc')) {
continue;
}
var id = heading.getAttribute('id');
if (!id) {
continue;
}
var text = (heading.textContent || '').trim();
if (!text) {
continue;
}
results.push({
level: heading.tagName.toLowerCase(),
id: id,
text: text
});
}
return results;
}
function BuildToc(headings, options) {
var variant = (options && options.variant) ? options.variant : 'right';
var maxVisible = (options && options.maxVisible) ? options.maxVisible : 0;
var isInline = 'inline' === variant;
var nav = document.createElement('nav');
nav.className = 'page-toc' + (isInline ? ' page-toc--inline' : '');
nav.setAttribute('aria-label', 'On this page');
var title = document.createElement('div');
title.className = 'page-toc__title';
title.textContent = 'In this article';
nav.appendChild(title);
var list = document.createElement('ul');
list.className = 'page-toc__list';
list.id = 'page-toc-list';
for (var i = 0; i < headings.length; i++) {
var item = headings[i];
var li = document.createElement('li');
li.className = 'page-toc__item page-toc__item--' + item.level;
var a = document.createElement('a');
a.className = 'page-toc__link';
a.href = '#' + item.id;
a.textContent = item.text;
li.appendChild(a);
list.appendChild(li);
if (isInline && maxVisible > 0 && i >= maxVisible) {
li.classList.add('is-hidden');
}
}
nav.appendChild(list);
if (isInline && maxVisible > 0 && headings.length > maxVisible) {
var hiddenCount = headings.length - maxVisible;
var isExpanded = false;
var toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'page-toc__toggle';
toggle.setAttribute('aria-controls', list.id);
toggle.setAttribute('aria-expanded', 'false');
function SetToggleText() {
if (isExpanded) {
toggle.textContent = 'Show less';
} else {
toggle.textContent = 'Show ' + hiddenCount + ' more';
}
}
function SetHiddenState() {
var items = list.querySelectorAll('.page-toc__item');
for (var j = 0; j < items.length; j++) {
if (j >= maxVisible) {
if (isExpanded) {
items[j].classList.remove('is-hidden');
} else {
items[j].classList.add('is-hidden');
}
}
}
toggle.setAttribute('aria-expanded', isExpanded ? 'true' : 'false');
SetToggleText();
}
toggle.addEventListener('click', function () {
isExpanded = !isExpanded;
SetHiddenState();
});
SetHiddenState();
nav.appendChild(toggle);
}
return nav;
}
function SetActiveTocLink(toc, activeId, keepVisibleInPanel) {
if (!toc) {
return;
}
var links = toc.querySelectorAll('.page-toc__link');
for (var i = 0; i < links.length; i++) {
var link = links[i];
var href = link.getAttribute('href') || '';
var isActive = ('#' + activeId) === href;
if (isActive) {
link.classList.add('is-active');
if (keepVisibleInPanel) {
/* Keep the active item visible inside the TOC panel (desktop/right TOC only) */
try {
link.scrollIntoView({ block: 'nearest' });
} catch (e) {
link.scrollIntoView();
}
}
} else {
link.classList.remove('is-active');
}
}
}
function SetupScrollSpy(main, toc, headings) {
if (!main || !toc || !headings || headings.length < 1) {
return null;
}
/* Scrollspy is desktop-only */
if (!IsDesktopViewport()) {
return null;
}
var headingElements = [];
for (var i = 0; i < headings.length; i++) {
var el = document.getElementById(headings[i].id);
if (el) {
headingElements.push(el);
}
}
if (headingElements.length < 1) {
return null;
}
var activeId = null;
var ticking = false;
var lockActiveUntilMs = 0;
function IsNearBottomOfPage() {
var thresholdPx = 24;
var scrollY = window.scrollY || window.pageYOffset || 0;
var viewportBottom = scrollY + window.innerHeight;
var pageHeight = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight);
return viewportBottom >= (pageHeight - thresholdPx);
}
function GetCurrentHeadingId() {
/* If we're at the bottom, force the last heading active */
if (IsNearBottomOfPage()) {
return headingElements[headingElements.length - 1].getAttribute('id');
}
/* Choose the heading closest to the top "activation line" */
var activationLine = 16;
var current = null;
for (var i = 0; i < headingElements.length; i++) {
var rectTop = headingElements[i].getBoundingClientRect().top;
if (rectTop <= activationLine) {
current = headingElements[i];
continue;
}
if (null === current) {
current = headingElements[i];
}
break;
}
if (null === current) {
current = headingElements[0];
}
return current.getAttribute('id');
}
function Update() {
ticking = false;
/* If the viewport becomes narrow after load, avoid scroll fighting */
if (!IsDesktopViewport()) {
return;
}
if (Date.now() < lockActiveUntilMs) {
return;
}
var currentId = GetCurrentHeadingId();
if (!currentId || currentId === activeId) {
return;
}
activeId = currentId;
SetActiveTocLink(toc, activeId, true);
}
function OnScrollOrResize() {
if (ticking) {
return;
}
ticking = true;
window.requestAnimationFrame(Update);
}
function OnTocClick(evt) {
var target = evt.target;
if (!target || !target.classList || !target.classList.contains('page-toc__link')) {
return;
}
var href = target.getAttribute('href') || '';
if (href.charAt(0) !== '#') {
return;
}
var id = href.substring(1);
if (!id) {
return;
}
/* Prevent scrollspy from immediately overriding the clicked section */
lockActiveUntilMs = Date.now() + 800;
activeId = id;
SetActiveTocLink(toc, activeId, true);
}
window.addEventListener('scroll', OnScrollOrResize, { passive: true });
window.addEventListener('resize', OnScrollOrResize);
toc.addEventListener('click', OnTocClick);
Update();
return function DisposeScrollSpy() {
window.removeEventListener('scroll', OnScrollOrResize);
window.removeEventListener('resize', OnScrollOrResize);
toc.removeEventListener('click', OnTocClick);
};
}
function InitRightToc() {
if (!IsRightTocEnabled()) {
RemoveExistingToc();
return;
}
var main = document.querySelector('.main-content main');
if (!main) {
return;
}
var headings = GetHeadings(main);
if (headings.length < 2) {
RemoveExistingToc();
return;
}
if (IsDesktopViewport()) {
RemoveExistingToc();
var wrap = document.querySelector('.main-content-wrap');
if (!wrap) {
return;
}
wrap.classList.add('has-page-toc');
var toc = BuildToc(headings, { variant: 'right' });
wrap.appendChild(toc);
scrollSpyDispose = SetupScrollSpy(main, toc, headings);
return;
}
/* Narrow viewports: place TOC at the top of the article (Learn-like) */
RemoveExistingToc();
var inlineToc = BuildToc(headings, { variant: 'inline', maxVisible: inlineMaxVisibleItems });
InsertInlineToc(main, inlineToc);
}
function OnViewportResize() {
if (null !== resizeReinitTimerId) {
window.clearTimeout(resizeReinitTimerId);
}
resizeReinitTimerId = window.setTimeout(InitRightToc, 150);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
InitRightToc();
window.addEventListener('resize', OnViewportResize);
});
return;
}
InitRightToc();
window.addEventListener('resize', OnViewportResize);
})();
File diff suppressed because one or more lines are too long
+917
View File
@@ -0,0 +1,917 @@
---
title: Build
nav_order: 10
prev_url: /drivers.html
prev_label: Drivers
next_url: /monitor.html
next_label: Monitor
parent: UI Overview
---
# Build
![1776379303045](image/build/1776379303045.png)
The Build tab is where the magic happens
## FFU Development Path
The FFU Development path (`$FFUDevelopmentPath`) is the root path of where most other paths are derived. The default is `$PSScriptRoot`, which is the location the script is currently running from and can be changed to another location from within the UI.
If you want to download and test new releases, or want to create a new FFUDevelopment folder without modifying your existing one, you can always download the source files and put them in another location.
The recommendation is to run from `C:\FFUDevelopment` and in most cases the path shouldn't need to be changed.
## Custom FFU Name Template
Controls the `-CustomFFUNameTemplate` parameter. This allows you to define a custom naming convention for the captured FFU file using placeholders that are replaced at build time.
If left blank, the default FFU naming convention is used.
### Available Placeholders
| Placeholder | Description | Example |
| -------------------- | ---------------------- | ------------------------------------------------------------------------------ |
| `{WindowsRelease}` | Windows release number | `10`, `11`, `2016`, `2019`, `2022`, `2025` |
| `{WindowsVersion}` | Windows version | `1607`, `1809`, `21h2`, `22h2`, `23h2`, `24h2` |
| `{SKU}` | Windows edition | `Home`, `Pro`, `Enterprise`, `Education`, `Standard`, `Datacenter` |
| `{BuildDate}` | Month and year | `Nov2025` |
| `{yyyy}` | 4-digit year | `2025` |
| `{MM}` | 2-digit month | `11` (for November) |
| `{dd}` | 2-digit day | `28` |
| `{HH}` | Hour in 24-hour format | `14` (for 2 PM) |
| `{hh}` | Hour in 12-hour format | `02` (for 2 PM) |
| `{mm}` | 2-digit minute | `09` |
| `{tt}` | AM/PM designator | `AM` or `PM` |
### Examples
**Basic template with date and time:**
```
{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}
```
Result: `Win11_24h2_Pro_2025-11-28_1425.ffu`
**Template with static text (e.g., indicating Office is installed):**
```
{WindowsRelease}_{WindowsVersion}_{SKU}_Office_{yyyy}-{MM}-{dd}_{HH}{mm}
```
Result: `Win11_24h2_Pro_Office_2025-11-28_1425.ffu`
**Simple template with build date:**
```
{WindowsRelease}_{WindowsVersion}_{SKU}_{BuildDate}
```
Result: `Win11_24h2_Pro_Nov2025.ffu`
{: .note-title}
> Note
>
> The `.ffu` extension is automatically appended if not included in the template.
## FFU Capture Location
The FFU Capture Location sets the `-FFUCaptureLocation` parameter that determines where completed `.ffu` images are written. By default it points to `$FFUDevelopmentPath\FFU`, and the build script creates the folder automatically if it does not already exist.
When apps are installed in a VM, the build still uses the VM for application installs and sysprep, but the actual FFU capture now happens on the host after the VHDX is optimized and remounted. That means completed images are written directly to this folder without creating a temporary SMB share, temporary local account, or capture ISO.
Choose a path on fast storage with plenty of free space—the directory must be local to the host running `BuildFFUVM.ps1`, and large captures can easily exceed 2530 GB. This location also feeds other options such as **Copy Additional FFU Files**, **Build USB Drive**, and **Remove FFU**, so keeping all finished images here keeps those workflows simple.
## Threads
Controls the `-Threads` parameter, which sets the number of parallel threads used for concurrent operations throughout FFU Builder. The default value is **5**.
### Operations Affected by Threads
The Threads value applies to the following parallel operations:
| Operation | Description |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| **Winget Application Downloads** | When downloading multiple Winget applications, each application download runs as a parallel task |
| **BYO Application Copy** | When copying multiple Bring Your Own (BYO) applications to the Apps folder, each copy operation runs in parallel |
| **Driver Downloads** | When downloading drivers for multiple device models, each driver download and extraction runs as a parallel task |
### Recommended Values
| Threads | Use Case |
| -------------- | ------------------------------------------------------------------------- |
| **1** | Minimal system impact; useful for troubleshooting or low-resource systems |
| **5** | Default; balanced performance for most systems |
| **8-10** | Higher concurrency for systems with fast storage and network connections |
{: .note-title}
> Note
>
> Setting a higher thread count may improve download times but will increase resource utilization. If you experience stability issues or resource constraints, try reducing the thread count.
### Validation
The UI validates that the Threads value is a valid integer greater than or equal to 1. If an invalid value is entered, it automatically resets to **1**.
## BITS Priority
Controls the `-BitsPriority` parameter, which determines the priority level for Background Intelligent Transfer Service (BITS) downloads. The default value is **Normal**.
If you want faster downloads, change the priority to Foreground. Normal priority will significantly slow down downloads since BITS treats non-Foreground downloads as synchronous and queues each download. This means multiple driver or winget application downloads will go much slower than using Foreground. Normal is default as per Microsoft best practice guidance for using BITS.
## General Build Options Expander
This expander groups the core build behaviors that affect how the FFU is created, optimized, cached, and prepared for deployment.
### Compact OS
Controls the `-CompactOS` parameter. When checked, the Windows image is applied using compressed files. The default is **checked**.
#### How It Works
When enabled, the build script uses the `-Compact` switch with `Expand-WindowsImage` when applying the Windows image to the OS partition. This compresses Windows system files using Compact OS compression, which reduces the disk footprint of the operating system. On an x64 image, space savings is ~3.5-4GB.
#### Benefits
| Benefit | Description |
| ------------------------------- | ---------------------------------------------------------------------------------------------------- |
| **Reduced Disk Space** | Windows files are stored in a compressed state, saving several gigabytes of storage |
| **Smaller FFU Size** | The captured FFU file is smaller because the OS partition contains compressed files |
| **Faster Deployment** | Smaller FFU files transfer more quickly to USB drives and deploy faster to target devices |
| **No Performance Impact** | Modern CPUs decompress files faster than they can be read from storage, so performance is maintained |
#### When to Disable
You may want to disable Compact OS in the following scenarios:
- **Windows Server builds**: The script automatically disables Compact OS for Windows Server operating systems because the Windows Overlay Filter (wof.sys) is not included in Server SKUs
- **Troubleshooting**: If you experience issues with specific applications that are incompatible with compressed files
- **Maximum performance requirements**: In rare cases where every CPU cycle matters
{: .note-title}
> Note
>
> Compact OS is automatically disabled when building Windows Server images, regardless of this setting. The script detects Server operating systems and applies the Windows image without compression.
### Update ADK
Controls the `-UpdateADK` parameter. When checked, the script checks for and installs or updates to the latest Windows ADK and WinPE add-on before starting the build. The default is **checked**.
#### How It Works
When enabled, the build process performs the following checks before starting:
1. **Version Check**: Queries the [Microsoft ADK installation page](https://learn.microsoft.com/en-us/windows-hardware/get-started/adk-install) to determine the latest available ADK version
2. **Compare Versions**: Compares the installed ADK and WinPE add-on versions (if present) against the latest available version
3. **Update if Needed**: If an older version is detected:
- Uninstalls the existing Windows ADK
- Uninstalls the existing WinPE add-on
- Downloads and installs the latest Windows ADK with Deployment Tools feature
- Downloads and installs the latest WinPE add-on
#### Features Installed
When installing or updating the ADK, the following features are included:
| Component | Feature ID | Description |
| ---------------------------------- | ---------------------------------------------- | --------------------------------------------------------------- |
| **Windows Deployment Tools** | `OptionId.DeploymentTools` | Includes DISM, Oscdimg, and other deployment-related tools |
| **WinPE Environment** | `OptionId.WindowsPreinstallationEnvironment` | Windows Preinstallation Environment used for capture and deploy |
#### Installation Location
The ADK is installed to the default location: `C:\Program Files (x86)\Windows Kits\10`
#### When to Disable
You may want to disable Update ADK in the following scenarios:
- **Offline or air-gapped environments**: When internet access is not available to download the latest ADK
- **Controlled ADK versions**: When you need to maintain a specific ADK version for compatibility or compliance reasons
- **Faster builds**: When you have already verified you are running the latest ADK version and want to skip the version check
{: .warning-title}
> Warning
>
> If Update ADK is disabled and the Windows ADK or WinPE add-on is not installed, the build will fail. Ensure you have manually installed the required components before disabling this option.
#### Manual ADK Installation
If you prefer to manually install the ADK, visit:
[Download and install the Windows ADK](https://learn.microsoft.com/en-us/windows-hardware/get-started/adk-install)
You must install both:
- Windows Assessment and Deployment Kit (with Deployment Tools feature)
- Windows PE add-on for the Windows ADK
### Optimize
Controls the `-Optimize` parameter. When enabled, FFU Builder runs the Windows ADK version of DISM to optimize the captured `.ffu` file:
- `DISM /Optimize-FFU /ImageFile:<path-to-ffu>`
This post-processing step typically takes a few minutes and is intended to make FFU images faster to deploy and easier to deploy to differently-sized disks (by allowing the Windows partition to expand or shrink during apply).
**Default:** Enabled (`-Optimize $true`)
#### When to Disable
You may want to disable Optimize (`-Optimize $false`) if you are troubleshooting, or if you want to skip the extra post-processing time.
{: .warning-title}
> Warning
>
> If you plan to deploy the same FFU to devices with different storage sizes (especially smaller disks), keep `-Optimize` enabled. Non-optimized FFUs are more likely to require additional partition management during deployment.
{: .note-title}
> Note
>
> FFU Builder also performs a separate “optimize VHDX before capture” step. That VHDX optimization is independent of `-Optimize`, so you may still see “Optimizing VHDX before capture…” even when `-Optimize` is disabled.
### Allow VHDX Caching
Controls the `-AllowVHDXCaching` parameter. When enabled, FFU Builder caches the base VHDX it creates in `$FFUDevelopmentPath\VHDXCache` and writes a matching `*_config.json` file alongside it. On later builds, if a cached VHDX exists that matches your selected Windows settings and update set, the script reuses it to avoid re-applying the base image and integrating updates again.
**Default:** Disabled (`-AllowVHDXCaching $false`)
#### Cache Matching
A cached VHDX is reused only when the cache metadata matches your current build inputs, including:
- Windows release, version, and SKU
- Logical sector size (512 vs 4096)
- Optional features selection
- The exact set of update payload file names downloaded for that run (SSU/CU/.NET/etc.)
#### Disk Usage and Cleanup
VHDX caching trades disk space for speed. The `VHDXCache` folder can grow over time as you build different combinations. Periodically check the folder and remove old cached vhdx and config json files as necessary.
{: .note-title}
> Note
>
> To force a full rebuild, delete the contents of `$FFUDevelopmentPath\VHDXCache` (or disable **Allow VHDX Caching**) and run the build again.
### Create Deployment Media
Controls the `-CreateDeploymentMedia` parameter.
When enabled, FFU Builder creates WinPE deployment media that is used to deploy an FFU image to a physical device. This media contains the WinPE environment and deployment scripts needed to boot a target machine and apply the FFU image.
The deployment media is saved as an ISO file at `$FFUDevelopmentPath\WinPE_FFU_Deploy_x64.iso` (or `WinPE_FFU_Deploy_arm64.iso` for ARM64 builds). This ISO can then be used with the **Build USB Drive** option to create bootable USB media for physical deployments.
**Default:** Enabled (`-CreateDeploymentMedia $true`)
{: .note-title}
> Note
>
> If you only need to capture FFUs from VMs and do not plan to deploy to physical devices, you can disable this option to save time during the build process. However, most scenarios require deployment media for the final step of applying the FFU to target hardware.
{: .tip-title}
> Tip
>
> If you just need to re-create deployment media, you can use the `Create-PEMedia.ps1` script to regenerate the deploy ISO without running a full build.
### Verbose
Controls the `-Verbose` common parameter. When checked, enables detailed verbose output during the build process. The default is **unchecked**.
In prior builds it was necessary to enable `-verbose` output to track in real-time the build process if you didn't have `cmtrace.exe` or some other log-monitoring tool. With the UI, you can now watch the build in real-time using the monitor tab. Enabling verbose shouldn't be necessary but is available for those who wish to use it.
## Unattend.xml Options Expander
Use the **Unattend.xml Options** expander to choose how unattend content is staged and which source XML file FFU Builder should use for x64 and arm64 builds.
### x64 Unattend File Path
Use **x64 Unattend File Path** to browse to the source XML file for x64 builds. The default path is `.\FFUDevelopment\unattend\unattend_x64.xml`.
### arm64 Unattend File Path
Use **arm64 Unattend File Path** to browse to the source XML file for arm64 builds. The default path is `.\FFUDevelopment\unattend\unattend_arm64.xml`.
### Inject Unattend.xml
Controls the `-InjectUnattend` parameter. When checked, stages the XML file selected for the current architecture in **Unattend.xml Options** into the Apps ISO so it's baked into the FFU during the VM build process. The default is **unchecked**.
This option is used only when **Install Apps** is checked.
`Copy Unattend.xml` and `Inject Unattend.xml` are mutually exclusive. Select only one.
#### How It Works
When enabled, the build process:
1. Uses the x64 or arm64 source file selected in **Unattend.xml Options** for the current build architecture
2. Creates an `Unattend` folder inside `.\FFUDevelopment\Apps` if it doesn't exist
3. Copies that file to `.\FFUDevelopment\Apps\Unattend\Unattend.xml`
4. Includes the unattend file in the Apps ISO, making it available to sysprep during the VM build
The unattend file is then used by sysprep during the specialize phase and/or other OOBE phases when the FFU is deployed.
#### Creating Your Unattend Files
You can keep the default architecture-specific files in the `.\FFUDevelopment\unattend` folder or browse to another XML file in the UI:
| File | Description |
| ---------------------------- | ----------------------------------- |
| **unattend_x64.xml** | Unattend file used for x64 builds |
| **unattend_arm64.xml** | Unattend file used for arm64 builds |
{: .warning-title}
> Important
>
> The default paths use the architecture suffix file names shown above. FFU Builder still renames the selected file to `Unattend.xml` when it stages it into the Apps folder.
#### When to Use This Option
This option is primarily intended for scenarios where:
* You are **not using the USB drive** to deploy the FFU and use other deployment methods (e.g., network deployment, disk cloning, etc)
* You want the unattend configuration **baked directly into the FFU** rather than applied at deployment time
#### Limitations
| Limitation | Description |
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **No prefixes.txt, SerialComputerNames.csv, or %serial% support** | Unlike**Copy Unattend.xml**, this method does not support `prefixes.txt`, `SerialComputerNames.csv`, or the `%serial%` variable for deployment-time device naming |
| **Fixed configuration** | The unattend settings are baked into the FFU at build time and cannot be changed at deployment time |
| **Requires VM to be built** | This option only works when**Install Apps** is `$true` because the unattend file is included in the Apps ISO |
{: .note-title}
> Note
>
> Most users should continue using the **Copy Unattend** option via the USB drive, which provides more flexibility including support for `prefixes.txt`, `SerialComputerNames.csv`, and `%serial%` device naming. Use **Inject Unattend.xml** only when you won't be using the USB drive for deployment.
{: .tip-title}
> Tip
>
> If you're using this option, you can disable **Build Deploy ISO** to save time during the build process since the deployment ISO is not needed when you're not using the USB drive method.
### Copy Unattend.xml
Controls the `-CopyUnattend` parameter. When checked, stages the XML file selected for the current architecture in **Unattend.xml Options** to an `Unattend` folder on the Deployment partition of the USB drive. The default is **unchecked**.
Use this option when you plan to build deployment USB media.
When enabled, the build process copies:
- The selected x64 or arm64 unattend XML file -> renamed to **Unattend.xml** on the USB drive
- **prefixes.txt** -> created from the **Device Naming** prefixes list when that mode is selected
- **SerialComputerNames.csv** -> created from the **Device Naming** serial mapping list when that mode is selected
If you keep the default file paths in place, FFU Builder uses `unattend_x64.xml` for x64 builds and `unattend_arm64.xml` for arm64 builds.
During deployment, `ApplyFFU.ps1` applies `Unattend.xml` whenever it is present. Device naming only happens when the **Device Naming** setting requires it, or when older media still uses the legacy prompt-based workflow.
See **Device Naming Expander** below for the available computer-name modes and naming-file behavior.
## Device Naming Expander
Use the **Device Naming** expander to decide whether `ComputerName` should be set at deployment time when unattend is applied. There are some major benefits to doing this:
1. Total deployment time is reduced if naming is set at FFU deployment time since there is no additional reboot done during OOBE.
2. Reduces the need for multiple provisioning packages or autopilot profiles. This means you can use a single PPKG or autopilot profile.
### No Device Name
This is the default radio selection in the UI.
- If you leave device naming untouched, FFU Builder does not write `DeviceNamingMode` to the generated config. This preserves the script's `Legacy` default, so an existing `FFUDevelopment\Unattend\prefixes.txt` file is still copied to deployment media when present.
- If you explicitly select this option, FFU Builder sets `DeviceNamingMode = None`. The unattend file is still applied, but Windows generates a random computer name instead of forcing a prompt or a fixed name.
The active `unattend_*.xml` files in `FFUDevelopment\Unattend` use `<ComputerName>*</ComputerName>` in the current sample files.
### Prompt for Device Name
Use this option when you want the technician to enter the computer name during deployment.
- FFU Builder sets `DeviceNamingMode = Prompt`.
- This option requires **Copy Unattend.xml**.
- The source `unattend_*.xml` files can stay at `<ComputerName>*</ComputerName>`.
- During the build, FFU Builder rewrites only the staged deployment copy of `Unattend.xml` to the legacy prompt placeholder that `ApplyFFU.ps1` already recognizes.
- **Inject Unattend.xml** is not supported with this option.
### Specify Device Name
Use this option when you want a static device name or a template such as `Comp-%serial%`.
- FFU Builder sets `DeviceNamingMode = Template`.
- With **Copy Unattend.xml**, `%serial%` is resolved during deployment in PE.
- With **Inject Unattend.xml**, only static names are supported.
- **Copy Unattend.xml** and **Inject Unattend.xml** are mutually exclusive. Select only one.
### Specify a list of Prefixes
This option writes `prefixes.txt` from the list in the UI. Enter one prefix per line in the multiline prefixes box. If there is a single prefix, deployment uses it automatically. If there are multiple prefixes, the technician is prompted to select one. The selected prefix is combined with the device serial number to create the computer name.
- FFU Builder sets `DeviceNamingMode = Prefixes`.
For example, with a prefix of `CORP-` and a serial number of `ABC123`, the resulting computer name would be `CORP-ABC123` (truncated to 15 characters if necessary).
Sample `prefixes.txt` content:
```plaintext
CORP-
STORE-
KIOSK-
```
{: .note-title}
> Note
>
> If the technician skips prefix selection when multiple prefixes are available, `ApplyFFU.ps1` leaves the existing unattend `ComputerName` value unchanged. With the current unattend samples set to `<ComputerName>*</ComputerName>`, Windows falls back to its default random computer-name behavior, typically resulting in a name such as `WIN-*`.
### Prefixes File Path
Use **Prefixes File Path** to point the UI at the source text file for the prefixes list. The file can use any name. When you browse to a prefixes file in the UI, or when a saved configuration references a valid prefixes path, the UI loads that file and populates the multiline prefixes box from its contents.
### Save Prefixes
Use **Save Prefixes** to write the current multiline prefixes list back to the file specified in **Prefixes File Path**.
### Specify Serial to Device Name Mapping
This option writes `SerialComputerNames.csv` from the CSV content in the UI. Use `SerialNumber,ComputerName` as the header row, then add one row per device. During deployment, `ApplyFFU.ps1` compares the current BIOS serial number to the `SerialNumber` column and uses the matching `ComputerName` value.
- FFU Builder sets `DeviceNamingMode = SerialComputerNames`.
Sample `SerialComputerNames.csv` content:
```plaintext
SerialNumber,ComputerName
ABC12345,CORP-001
DEF67890,KIOSK-010
XYZ24680,STORE-015
```
- This option requires **Copy Unattend.xml**.
- **Inject Unattend.xml** is not supported with this option.
- If no matching serial number is found during deployment, `ApplyFFU.ps1` falls back to a random `FFU-*` computer name so setup can finish.
{: .note-title}
> Note
>
> If `prefixes.txt` and `SerialComputerNames.csv` are both staged manually on the same deployment media, `ApplyFFU.ps1` checks `prefixes.txt` first. FFU Builder avoids this conflict by only staging the naming file for the selected device-naming mode.
### Serial Computer Names CSV Mapping File Path
Use **Serial Computer Names CSV Mapping File Path** to point the UI at the source CSV file for the serial-to-device-name mapping. The file can use any name. When you browse to a mapping file in the UI, or when a saved configuration references a valid CSV path, the UI loads that file and populates the multiline CSV box from its contents.
### Save Serial Mapping
Use **Save Serial Mapping** to write the current CSV content back to the file specified in **Serial Computer Names CSV Mapping File Path**.
### Deployment Prompt Compatibility
Older deployment media that already has an unattend file with `ComputerName` set to the legacy placeholder value and no `prefixes.txt` file will still prompt for a device name during deployment.
{: .warning-title}
> Warning
>
> If using a provisioning package or autopilot json file, DO NOT specify a name in either of these. They will overwrite the name you have specified in the unattend.xml.
### Creating Your Unattend Files
The `.\FFUDevelopment\Unattend` folder includes sample files you can customize:
| File | Description |
| --------------------------------------- | ------------------------------------------ |
| **SampleUnattend_x64.xml** | Example unattend file for x64 systems |
| **unattend_x64.xml** | Active unattend file used for x64 builds |
| **unattend_arm64.xml** | Active unattend file used for arm64 builds |
| **SamplePrefixes.txt** | Example prefixes file for device naming |
| **SampleSerialComputerNames.csv** | Example serial-to-device-name CSV file |
Copy and customize the sample files to create your own `unattend_x64.xml`, `unattend_arm64.xml`, `prefixes.txt`, and `SerialComputerNames.csv` files.
{: .note-title}
> Note
>
> The unattend file must contain a `<ComputerName>` element in the `Microsoft-Windows-Shell-Setup` component for device naming to work. See the sample files for the correct structure.
## Build USB Drive Options Expander
This expander groups the settings used to create deployment USB drives after the FFU and deployment media are ready.
### Build USB Drive
Controls the `-BuildUSBDrive` parameter. When checked, FFU Builder partitions and formats selected USB drives and copies the captured FFU plus the enabled deployment assets to them. The default is **unchecked**.
The remaining settings in this expander apply only when **Build USB Drive** is enabled.
### Allow External Hard Disk Media
Controls the `-AllowExternalHardDiskMedia` parameter. When checked, allows the use of drives identified as "External hard disk media" via the WMI class `Win32_DiskDrive`. The default is **unchecked**.
Most USB thumb drives are identified by Windows as "Removable Media" and work with the default settings. However, faster USB drives—such as portable SSDs or high-speed USB 3.x drives—may be identified as "External hard disk media" instead. If you want to use these faster drives for imaging, enable this option.
{: .warning-title}
> Warning
>
> Enabling this option may expose external hard drives attached to your machine to the USB imaging process. To prevent accidental data loss, use the **Prompt for External Hard Disk Media** option (enabled by default when this option is checked) to confirm which drive to use before formatting.
### Prompt for External Hard Disk Media
Controls the `-PromptExternalHardDiskMedia` parameter. When checked, prompts for user confirmation before using any drive identified as "External hard disk media". The default is **checked** when **Allow External Hard Disk Media** is enabled.
This option is only available when **Allow External Hard Disk Media** is checked.
When enabled, the build process will:
1. Display a table listing all detected external hard disk media drives, including drive name, serial number, partition style, and status.
2. Prompt you to select which drive to use for imaging.
3. Only create a USB drive on the selected drive.
When disabled, the script will not prompt and can use multiple external hard disk drives simultaneously, similar to how removable USB drives function. This is useful for automated or batch imaging scenarios but increases the risk of accidental data loss.
{: .note-title}
> Note
>
> If you do not want to be prompted each time, you can disable this option after verifying that only your intended imaging drives are connected.
### Select Specific USB Drives
When checked, enables manual selection of specific USB drives for imaging. The default is **unchecked**.
This option is only available when **Build USB Drive** is checked.
When enabled, a **Check USB drives** button and a list view appear. Click **Check USB drives** to scan for connected USB drives. The list displays all detected drives with the following information:
| Column | Description |
| ------------------- | ----------------------------------------------------- |
| **Select** | Checkbox to include or exclude the drive from imaging |
| **Model** | The model name of the USB drive |
| **Unique ID** | A unique identifier for the drive |
| **Size (GB)** | The total capacity of the drive in gigabytes |
Select one or more drives by checking the checkbox in the **Select** column. Only selected drives will be formatted and used for imaging when the build completes.
Use the **Select All** checkbox in the column header to quickly select or deselect all drives.
{: .note-title}
> Note
>
> If **Select Specific USB Drives** is unchecked, the build process will automatically use all discovered USB drives.
### Copy Autopilot Profile
Controls the `-CopyAutopilot` parameter. When checked, copies the contents of `.\FFUDevelopment\Autopilot` to the `Autopilot` folder on the Deployment partition of the USB drive. The default is **unchecked**.
This option is only available when **Build USB Drive** is checked.
This leverages the Autopilot for existing devices json file. It's not recommended to use this method any longer as devices enrolled via this method are enrolled as personal instead of corporate.
### Copy Provisioning Package
Controls the `-CopyPPKG` parameter. When checked, copies the contents of `.\FFUDevelopment\PPKG` to the `PPKG` folder on the Deployment partition of the USB drive. The default is **unchecked**.
This option is only available when **Build USB Drive** is checked.
#### How It Works
1. **During Build**: The build process copies all `.ppkg` files from `.\FFUDevelopment\PPKG` to the USB drive.
2. **During Deployment**: When `ApplyFFU.ps1` runs, it detects the `PPKG` folder and the provisioning packages within it.
- If **multiple** `.ppkg` files are found, the technician is prompted to select which package to apply.
- If **one** `.ppkg` file is found, it is automatically selected.
3. **Application**: The selected provisioning package is copied to the root of the USB drive, where Windows picks it up during OOBE and applies the settings.
### Copy Additional FFU Files
Controls the `-CopyAdditionalFFUFiles` parameter. When checked, allows you to select existing FFU files from the FFU Capture Location to copy to the USB drive alongside the newly built FFU. The default is **unchecked**.
This option is only available when **Build USB Drive** is checked.
#### How It Works
When enabled, an **Additional FFU Files** panel appears below the checkbox with the following controls:
| Control | Description |
| ----------------------- | ----------------------------------------------------------------------------------------------- |
| **Refresh** | Scans the FFU Capture Location folder for existing `.ffu` files and populates the list |
| **FFU Name** | The filename of the FFU file |
| **Last Modified** | The date and time the FFU file was last modified, useful for identifying the most recent builds |
The list displays all `.ffu` files found in the FFU Capture Location (default `.\FFUDevelopment\FFU`). Click on individual rows to select which FFU files you want to include on the USB drive. Selected files are highlighted in the list.
#### Use Cases
- **Multiple device configurations**: Copy different FFU files for different windows/application configurations (e.g., different versions of windows, different application stacks) to a single USB drive, allowing technicians to choose during deployment.
- **Previous builds**: Include a known-good FFU from a previous build alongside the new build as a fallback option.
- **Multi-architecture imaging**: Include both x64 and arm64 FFU files on the same USB drive for mixed-architecture environments.
#### Command Line Usage
When running `BuildFFUVM.ps1` from the command line with `-CopyAdditionalFFUFiles $true` and no `-AdditionalFFUFiles` parameter specified, the script displays an interactive prompt listing all available FFU files in the capture folder. You can:
- Enter numbers separated by commas (e.g., `1,3,5`) to select specific files
- Enter `A` to select all available files
- Press **Enter** to skip and not include any additional files
Example command line usage with pre-selected files:
```powershell
.\BuildFFUVM.ps1 -configFile .\config\FFUConfig.json -CopyAdditionalFFUFiles $true -AdditionalFFUFiles @("C:\FFUDevelopment\FFU\Win11_24h2_Pro_Nov2025.ffu", "C:\FFUDevelopment\FFU\Win11_24h2_Enterprise_Nov2025.ffu")
```
{: .note-title}
> Note
>
> The newly captured FFU from the current build is always copied to the USB drive. Additional FFU files selected here are copied in addition to the new FFU.
### Max USB Drives
Controls the `-MaxUSBDrives` parameter, which sets the maximum number of USB drives to build in parallel. The default value is **5**.
This option is only available when **Build USB Drive** is checked.
When building USB drives, the script processes multiple drives concurrently to speed up imaging. This setting controls how many drives are formatted and copied to simultaneously.
## Post-Build Cleanup Expander
This expander groups the cleanup settings that run after a successful build completes.
### Cleanup Apps ISO
Controls the `-CleanupAppsISO` parameter. When checked, the Apps ISO file is automatically deleted after the FFU has been successfully captured. The default is **checked**.
During the build process, when apps are being installed, the script creates an `Apps.iso` file in the FFU Development Path (for example, `.\FFUDevelopment\Apps.iso`). This ISO contains the contents of the `.\FFUDevelopment\Apps` folder, including application installers, Office deployment files, and orchestration scripts, and is mounted to the VM during the build to install applications.
#### When to Disable
You may want to disable Cleanup Apps ISO in the following scenarios:
- **Debugging app installations**: When troubleshooting application installation issues and you want to manually inspect the ISO contents
- **Multiple builds with same apps**: If you're running consecutive builds with identical app configurations and want to reuse the existing ISO to save time (the script will recreate it if missing)
- **Archival purposes**: When you need to retain a copy of the exact Apps ISO used for a specific FFU build
{: .note-title}
> Note
>
> The Apps ISO is only created when applications are configured for installation. If no apps are being installed in the FFU, this option has no effect. Keeping this option enabled helps conserve disk space by removing temporary build artifacts.
### Cleanup Deploy ISO
Controls the `-CleanupDeployISO` parameter. When checked, the WinPE deployment ISO file is automatically deleted after the FFU has been successfully captured. The default is **checked**.
During the build process, when **Build Deploy ISO** is enabled, the script creates a `WinPE_FFU_Deploy.iso` file (for example, `.\FFUDevelopment\WinPE_FFU_Deploy.iso`). This ISO contains a customized Windows PE environment used to deploy captured FFU images to target devices. The deployment ISO is typically copied to a bootable USB drive along with the FFU files for field deployment.
#### When to Disable
You may want to disable Cleanup Deploy ISO in the following scenarios:
- **Creating deployment media separately**: When you want to create USB deployment drives at a later time, see [USB Imaging Tool Creator](/FFU/usb_imaging_tool_creator.html) for a staged workflow using `USBImagingToolCreator.ps1` with a deploy ISO, `FFU`, and `Drivers` folder (local path or network share)
- **Testing in Hyper-V**: When deploying FFU images to Hyper-V VMs for testing, you can attach the deploy ISO directly to a VM as a DVD drive
### Cleanup Drivers
Controls the `-CleanupDrivers` parameter. When checked, the contents of the Drivers folder are automatically deleted after the FFU has been successfully captured. The default is **unchecked**.
During the build process, when drivers are configured for installation, the script downloads and extracts driver packages into manufacturer-specific subfolders within the Drivers folder (for example, `.\FFUDevelopment\Drivers\HP`, `.\FFUDevelopment\Drivers\Dell`). These drivers are then injected into the FFU during the build.
#### When to Enable
You may want to enable Cleanup Drivers in the following scenarios:
- **Conserving disk space**: Driver packages can be large (several gigabytes per manufacturer), and removing them after a successful build frees up storage
- **Ensuring fresh drivers**: When you want each build to download the latest available drivers rather than reusing previously downloaded versions
- **Single-use builds**: When building an FFU for a one-time deployment and you don't need to retain the driver files
#### When to Disable
You may want to keep Cleanup Drivers disabled in the following scenarios:
- **Multiple builds with same drivers**: If you're running consecutive builds targeting the same hardware models, keeping drivers avoids re-downloading them each time
- **Debugging driver issues**: When troubleshooting driver injection problems and you want to manually inspect the downloaded driver contents
- **Offline builds**: When building in an environment with limited or no internet access, retaining drivers allows reuse across builds
- **Bring Your Own Drivers**: When you download and bring your own set of drivers from another source and don't want FFU Builder to remove them
{: .note-title}
> Note
>
> Only the contents within the Drivers folder are removed. The folder itself is preserved. If no drivers were downloaded during the build, this option has no effect.
### Remove FFU
Controls the `-RemoveFFU` parameter. When checked, all FFU files in the FFU Capture Location are automatically deleted after the build completes successfully. The default is **unchecked**.
During the build process, the captured FFU image is written to the FFU Capture Location (for example, `.\FFUDevelopment\FFU`). This option removes all `.ffu` files from that folder after the build finishes, including any previously captured FFU files that may exist in the folder.
#### When to Enable
You may want to enable Remove FFU in the following scenarios:
- **USB-only workflow**: When you're using **Build USB Drive** to copy the FFU directly to a USB drive and don't need to retain the FFU file on the host machine
- **Conserving disk space**: FFU files can be very large depending on what you're installing, and removing them after copying to USB frees up storage
- **Automated build pipelines**: When running automated builds where the FFU is immediately transferred to another location (such as a network share or deployment server) and no longer needed locally
#### When to Disable
You may want to keep Remove FFU disabled in the following scenarios:
- **Archival purposes**: When you want to retain captured FFU images for future deployments or as a backup
- **Multiple USB drives**: When you need to create additional USB deployment drives at a later time
- **Testing and validation**: When you want to test the FFU in Hyper-V or other environments before deploying to physical hardware
{: .warning-title}
> Warning
>
> This option removes **all** FFU files in the FFU Capture Location folder, not just the FFU from the current build. If you have previously captured FFU files stored in this folder that you want to keep, do not enable this option or move those files to a different location before building.
### Remove Apps Folder Content
Controls the `-RemoveApps` parameter. When checked, application content in the Apps folder is automatically deleted after the FFU has been successfully captured. The default is **unchecked**.
During the build process, application content accumulates in several subfolders within the Apps folder (for example, `.\FFUDevelopment\Apps`):
| Folder | Contents |
| ----------- | ------------------------------------------------------------------------------------------------------------------------ |
| `Win32` | Winget source applications and Bring Your Own Apps content copied using the**Copy Apps** button or manually copied |
| `MSStore` | Microsoft Store applications downloaded via Winget |
| `Office` | Microsoft 365 Apps installer files downloaded by the Office Deployment Tool |
Additionally, the `WinGetWin32Apps.json` orchestration file in `.\FFUDevelopment\Apps\Orchestration` is removed. This file is automatically regenerated at build time based on downloaded applications.
When this option is enabled, the cleanup process removes:
- The entire `Win32` folder and its contents
- The entire `MSStore` folder and its contents
- The Office download subfolder (`Office\Office`) and the `setup.exe` file within the `Office` folder
#### When to Enable
You may want to keep Remove Apps Folder Content enabled in the following scenarios:
- **Conserving disk space**: Downloaded application installers can consume significant storage, and removing them after a successful build frees up space
- **Ensuring fresh downloads**: When you want each build to download the latest available application versions rather than reusing previously downloaded content
- **Single-use builds**: When building an FFU for a one-time deployment and you don't need to retain the application files
#### When to Disable
You may want to disable Remove Apps Folder Content in the following scenarios:
- **Multiple builds with same apps**: If you're running consecutive builds with identical application configurations, keeping the downloaded content avoids re-downloading applications each time
- **Debugging app installations**: When troubleshooting application installation issues and you want to manually inspect the downloaded content
- **Offline builds**: When building in an environment with limited or no internet access, retaining downloaded applications allows reuse across builds
- **Preserving Bring Your Own Apps**: When you've manually copied application content into the `Win32` folder and don't want FFU Builder to remove it
{: .note-title}
> Note
>
> Only the application content subfolders are removed. The `Apps` folder itself and configuration files such as `AppList.json` and `UserAppList.json` are preserved. If no applications were configured for the build, this option has no effect.
### Remove Downloaded Update Files
Controls the `-RemoveUpdates` parameter. When checked, downloaded Windows updates and application update payloads are automatically deleted after the FFU has been successfully captured. The default is **unchecked**.
During the build process, update files are downloaded to specific locations within the `FFUDevelopment` folder:
| Folder | Contents |
| ----------------- | ---------------------------------------------------------- |
| `KB` | Windows Cumulative Updates (CU) and .NET Framework updates |
| `Apps\Defender` | Microsoft Defender definition updates |
| `Apps\Edge` | Microsoft Edge browser installer |
| `Apps\MSRT` | Malicious Software Removal Tool updates |
| `Apps\OneDrive` | Microsoft OneDrive installer |
When this option is enabled, the cleanup process removes the entire `KB` folder and the specific update subfolders within the `Apps` directory.
#### When to Enable
You may want to keep Remove Downloaded Update Files enabled in the following scenarios:
- **Conserving disk space**: Windows Cumulative Updates can be several gigabytes in size, and removing them after a successful build frees up significant storage
- **Ensuring latest updates**: When you want each build to download the absolute latest available updates rather than potentially reusing older cached versions
#### When to Disable
You may want to disable Remove Downloaded Update Files in the following scenarios:
- **Multiple builds**: If you're running consecutive builds, keeping the downloaded updates avoids re-downloading large Cumulative Update files each time
- **Offline builds**: When building in an environment with limited or no internet access, retaining downloaded updates allows reuse across builds
- **Testing and validation**: When you want to manually inspect the update files that were included in the build
{: .note-title}
> Note
>
> Only the update-specific subfolders are removed. The `Apps` folder itself and other application content, unless **Remove Apps Folder Content** is also selected, are preserved.
### Remove Downloaded ESD file(s)
Controls the `-RemoveDownloadedESD` parameter. When checked, downloaded Windows ESD files are automatically deleted after they have been applied. The default is **checked**.
This setting applies to builds that use downloaded Windows ESD media instead of a provided ISO. When enabled, the build removes the downloaded `.esd` file after it has been used. When disabled, the downloaded `.esd` is kept for reuse on later builds.
#### When to Enable
You may want to keep Remove Downloaded ESD file(s) enabled in the following scenarios:
- **Conserving disk space**: Downloaded ESD files can be large, and removing them after a successful build frees up storage
- **Ensuring fresh media**: When you want each build to download the latest available ESD for the selected release and version
- **Single-use builds**: When you do not expect to reuse the same downloaded source media again
#### When to Disable
You may want to disable Remove Downloaded ESD file(s) in the following scenarios:
- **Multiple builds with the same source media**: Keeping the ESD avoids re-downloading it each time
- **Offline or bandwidth-constrained environments**: Retaining the ESD allows reuse across builds
- **Troubleshooting source-media issues**: When you want to preserve the downloaded ESD for inspection or repeat testing
{: .note-title}
> Note
>
> This option only applies when the build used a downloaded `.esd` file. If you provide a Windows ISO instead, this setting has no effect.
## Build Page Actions
These buttons sit below the Build tab expanders and operate on the overall page state rather than a single expander.
### Restore Defaults
Use this to restore FFU Builder to its default state. When clicked:
- A confirmation dialog lists what will be removed before anything is deleted.
- Generated JSON files are removed (`config\FFUConfig.json`, `Apps\AppList.json`, `Apps\UserAppList.json`, `Drivers\Drivers.json`).
- Capture, Deploy, and Apps ISO files are deleted.
- Downloaded artifacts are cleared: Apps payloads (Win32, MSStore, Office downloads), update folders under Apps (Defender, Edge, MSRT, OneDrive), driver downloads, and all `.ffu` files in the FFU capture folder.
- UI list views (drivers, apps, Winget search results, AppScript variables) are cleared and all controls are reset to their default values.
{: .note-title}
> Note
>
> VHDX cache and any custom config files in the `FFUDevelopment\config` folder, and `Drivers\DriverMapping.json` will remain. DriverMapping.json is retained because you may have made custom changes to it and we want to retain those.
>
> If you want to keep any content prior to restoring defaults, copy it out first.
### Save Config File
Saves all current UI selections to a JSON file so you can reload the same settings later or run `BuildFFUVM.ps1` from the command line with `-configFile` (e.g. `BuildFFUVM.ps1 -configFile C:\FFUDevelopment\config\FFUConfig.json`)
### How it works
- Collects the full UI state (paths, toggles, driver/app selections, build options) into a single JSON.
- Defaults the save location to `FFUDevelopmentPath\config` and suggests `FFUConfig.json` as the file name. You can browse and pick a different file name or folder.
- Creates the `config` folder if it does not exist and confirms the save when finished.
### Load Config File
Loads a previously saved configuration JSON and repopulates the UI.
### How it works
- Click **Load Config File** to browse for a JSON file (for example, `FFUDevelopment\config\FFUConfig.json`).
- The UI updates with everything from the file: paths, checkboxes, build options, driver/app selections, and USB settings.
- Supplemental files referenced in the config (Winget `AppList.json`, BYO `UserAppList.json`, `Drivers.json`) are also imported if they exist. Missing helper files are treated as optional and noted for you.
- If the file is empty, unreadable, or invalid JSON, the load is stopped and an error message is shown.
### Build FFU
Use **Build FFU** to run `BuildFFUVM.ps1` with the current UI selections.
### What happens when you click Build FFU
- The UI gathers all current settings and saves them to `FFUDevelopment\config\FFUConfig.json`, and launches `BuildFFUVM.ps1 -configFile` pointing to that file in a background job. `FFUConfig.json` persists between builds and is read on each opening of `BuildFFUVM_UI.ps1` so you can continue where you left off on each new run.
- The window switches to the **Monitor** tab so you can watch progress in real time.
- The progress bar shows overall completion
- When the job finishes, the button returns to **Build FFU** and the UI is ready for the next run.
### Cancelling a Build
The Build FFU button will change to Cancel while a build is running. Cancelling will do the following:
- The UI stops the background build job and kills any child processes so DISM, downloads, and other tools exit.
- The in-progress download is always removed to avoid partial or corrupt content.
- Youre prompted to decide whether to remove other items downloaded during this run. Selecting **Yes** removes only this runs downloads. Any previously downloaded content stays in place.
- When cleanup is finished, the Cancel button reverts to Build FFU and a new build can begin
{% include page_nav.html %}
+98
View File
@@ -0,0 +1,98 @@
---
title: Bring Your Own Applications
nav_order: 6
prev_url: /winget.html
prev_label: Install Winget Applications
next_url: /appsscriptvariables.html
next_label: Apps Script Variables
parent: Applications
grand_parent: UI Overview
---
# Bring Your Own Applications
![1776378975827](image/byoapps/1776378975827.png)
Bring Your Own Applications allows you to run any command line you want in the virtual machine to include in your FFU to install an application, run a script, etc. As the name implies, you'll provide the content, command line, arguments, and additional exit codes.
All applications are stored in the `$AppsPath` parent folder which defaults to `C:\FFUDevelopment\Apps`. Winget source applications and BYO Apps that you select Copy Apps are stored in `$AppsPath\Win32`. MSStore source apps from Winget are stored in `$AppsPath\MSStore`.
At build time, an `Apps.iso` file is created of the `$AppsPath` folder. This ISO gets mounted to the VM. It shows up in the VM as the `D:\` drive. When creating your command line or arguments, you must make sure to reference `D:\`.
## Name
The name of the application. The name is also used when selecting **Copy Apps** to copy apps from a source location to the `$AppsPath\Win32\<Name>` folder (e.g. `C:\FFUDevelopment\Apps\Win32\Google Chrome`)
## Command Line
This is the full path to the command line to install the application, script, or to run a command. If the content was included in the `$AppsPath` this should start with `D:\` (e.g. `D:\Win32\Mozilla Firefox\Mozilla Firefox_136.0.3_Machine_X64_exe_en-US.exe`)
For MSI applications, this should only include msiexec. The rest of the command line will be specified in arguments.
## Arguments
These are the command line arguments for the application. Using the Mozilla Firefox example above, the arguments would be `/S /PreventRebootRequired=true`.
For MSI applications, this will include `/i` and the full-path to the MSI file plus any additional command line parameters (e.g. `/i "D:\Win32\Google Chrome\Google Chrome_134.0.6998.178_Machine_X64_wix_en-US.msi" /quiet /norestart`)
## Source
This is an optional parameter. This is the local source to the content. It is used by the Copy Apps button to copy from the source location to the `$AppsPath\Win32\<Name>` folder. If you don't use the **Copy Apps** button, then you must put the conent in the `$AppsPath` folder manually.
## Additional Exit Codes
This is an optional parameter. Enter a comma-separated list of additional success exit codes if necessary.
## Ignore all non-zero exit codes
If checked, any non-zero exit code will be considered a success.
## Save UserAppList.json
When you're done adding your apps, you must save the `UserAppList.json` file to your `$AppsPath` folder. If you click **Copy Apps**, the `UserAppList.json` file is also saved. The `UserAppList.json` is used by the FFU Builder Orchestrator in the VM to know what to install and when based on the priority of the application.
Below is the `UserAppList.json` of Chrome and Firefox using the example above.
```json
[
{
"Priority": 1,
"Name": "Google Chrome",
"CommandLine": "msiexec",
"Arguments": "/i \"D:\\Win32\\Google Chrome\\Google Chrome_134.0.6998.178_Machine_X64_wix_en-US.msi\" /quiet /norestart",
"Source": "C:\\temp\\source\\Google Chrome",
"AdditionalExitCodes": "",
"IgnoreNonZeroExitCodes": false
},
{
"Priority": 2,
"Name": "Mozilla Firefox",
"CommandLine": "D:\\Win32\\Mozilla Firefox\\Mozilla Firefox_136.0.3_Machine_X64_exe_en-US.exe",
"Arguments": "/S /PreventRebootRequired=true",
"Source": "C:\\temp\\source\\Mozilla Firefox",
"AdditionalExitCodes": "",
"IgnoreNonZeroExitCodes": false
}
]
```
## Import UserAppList.json
You can import a saved `UserAppList.json`
## Edit Application
When you select a single application you can select the **Edit Application** button. This allows you to edit the application information and update the application.
## Copy Apps
If the application source is provided, click **Copy Apps** to copy the application content to the `$AppsPath\Win32` folder (e.g. `C:\FFUDevelopment\Apps\Win32\<Name>`). Network shares are supported. When clicking **Copy Apps** the `UserAppList.json` file is automatically created.
## Remove Selected
Removes the selected applications from the list view. Click **Save UserAppList.json** to save the application list.
## Clear List
The **Clear List** button will clear the list view of whats currently in it. It will not clear the `UserAppList.json` file if it exists.
{% include page_nav.html %}
+85
View File
@@ -0,0 +1,85 @@
---
title: Create PE Media
nav_order: 1
prev_url: /helper_scripts.html
prev_label: Helper Scripts
next_url: /usb_imaging_tool_creator.html
next_label: USB Imaging Tool Creator
parent: Helper Scripts
---
# Create PE Media
`Create-PEMedia.ps1` is a standalone helper script that creates WinPE deployment ISO files outside the main build flow.
This is useful when admins need to quickly generate a deploy ISO for a share (or local staging folder) that technicians will use with `USBImagingToolCreator.ps1`.
## Common use case
If your staging location does not already have a deployment ISO, run `Create-PEMedia.ps1` to generate one, then copy that ISO to the staging folder used by your technicians.
## Prerequisites
- Run from an elevated PowerShell session.
- Windows ADK + WinPE add-on must be installed (default path: `C:\Program Files (x86)\Windows Kits\10\`).
- Script should be run from the `FFUDevelopment` folder (or provide explicit paths via parameters).
## Quick start (deploy ISO)
From `FFUDevelopment`, this creates a deploy ISO by default:
```powershell
.\Create-PEMedia.ps1
```
Default output file:
- `.\WinPE_FFU_Deploy_x64.iso`
## Useful commands
Create deploy ISO for x64:
```powershell
.\Create-PEMedia.ps1 -WindowsArch 'x64'
```
Create deploy ISO for ARM64:
```powershell
.\Create-PEMedia.ps1 -WindowsArch 'arm64' -DeployISO "$PSScriptRoot\WinPE_FFU_Deploy_arm64.iso"
```
Create deploy ISO and include PE drivers from `.\PEDrivers`:
```powershell
.\Create-PEMedia.ps1 -CopyPEDrivers $true
```
## Stage output for USB imaging
After creating the deploy ISO, place it in the same staging root used for USB media creation.
Example:
```text
\\Server\FFUStaging\
WinPE_FFU_Deploy_x64.iso
FFU\
<image files>.ffu
Drivers\
<optional driver content>
```
Then technicians can run:
```powershell
.\USBImagingToolCreator.ps1 -DeployISOPath "\\Server\FFUStaging\WinPE_FFU_Deploy_x64.iso" -DisableAutoPlay
```
## Logging
`Create-PEMedia.ps1` writes log output to:
- `.\Create-PEMedia.log` (or custom path via `-LogFile`)
{% include page_nav.html %}
+410
View File
@@ -0,0 +1,410 @@
---
title: Drivers
nav_order: 9
prev_url: /M365appsoffice.html
prev_label: M365 Apps Office
next_url: /build.html
next_label: Build
parent: UI Overview
---
# Drivers
![1776379108391](image/drivers/1776379108391.png)
FFU Builder supports adding drivers directly to the FFU file at build time, or adding them as folders on your USB drive which can be serviced offline after the FFU has been applied to your device.
The UI allows you to download the drivers prior to build and/or create a `Drivers.json` file which can be used to automatically download the drivers at build time. This allows for flexibility in downloading drivers whenever you need them. It supports downloading multiple driver models at once in parallel.
## Drivers Folder
This is the location where drivers are downloaded to, or where you'll manually copy drivers to. The default is `.\FFUDevelopment\Drivers`
## PE Drivers Folder
Path to the folder containing drivers to be injected into the WinPE deployment media. Default is `.\FFUDevelopment\PEDrivers`.
## Drivers.json Path
Path to a JSON file that specifies which drivers to download. Default is `.\FFUDevelopment\Drivers\Drivers.json`
## Download Drivers
FFU Builder can download drivers from the following OEMs:
* Dell
* HP
* Lenovo
* Microsoft
Clicking the **Download Drivers** exposes a **Make:** drop down which lists the above four OEMs and a **Get Models** button
Clicking **Get Models** downloads the list of models from the selected OEM.
The **Model Filter** box allows you to type in a string to filter on the model. The filter should match on any portion of text in the model name.
The model column lists the model name and the System ID (for Dell and HP) or the Machine Type (for Lenovo) in parenthesis. The SystemID/Machine Type values are required to know exactly which set of drivers to download for your model. There typically is a lot of overlap, and sometimes the drivers for the various SystemID/MachineTypes for the same model might be exactly the same, it's still best to grab the SystemID/MachineType before downloading drivers.
To get the System ID:
**HP**
* BIOS/UEFI: Either under Main or System Information (it's going to be different depending on the model) you're looking for the **System Board ID** and it should be a four-character code.
* PowerShell:`(Get-CimInstance -Namespace 'root\WMI' -Class MS_SystemInformation).BaseboardProduct`
**Dell**
* BIOS/UEFI: I'm not sure if it's possible to get the System ID from the BIOS/UEFI. I seem to recall in some BIOS screenshots that System SKU is listed in some BIOS/UEFI implementations, but it may not be consistent.
* PowerShell: `(Get-CIMInstance -ClassName "MS_SystemInformation" -NameSpace "root\WMI").SystemSku`
or
```
[string]$OEMString = Get-WmiObject -Class "Win32_ComputerSystem" | Select-Object -ExpandProperty OEMStringArray
$ComputerDetails.FallbackSKU = [regex]::Matches($OEMString, '\[\S*]')[0].Value.TrimStart("[").TrimEnd("]")
```
**Lenovo**
To find the Machine Type for Lenovo devices, check the bottom/back of the device for the MTM field and capture the first four characters.
* BIOS/UEFI: Look for MTM and grab the first four characters
* PowerShell: `(Get-CIMInstance -ClassName "MS_SystemInformation" -NameSpace "root\WMI").SystemProductName`
You can multi-select different models within the same make, or mix and match different makes. The screenshot below shows different Dell, HP, Lenovo, and Microsoft models selected
![1776379241359](image/drivers/1776379241359.png)
## Save Drivers.json
After selecting the drivers you want to download, clicking **Save Drivers.json** will prompt you for a location to save the `Drivers.json` file to. The `Drivers.json` file is responsible for telling `BuildFFUVM.ps1` what drivers to download during the build process.
Below is an example of `Drivers.json`:
```
{
"HP": {
"Models": [
{
"Name": "HP EliteBook 865 16 inch G11 Notebook PC",
"SystemId": "8d03"
}
]
},
"Dell": {
"Models": [
{
"Name": "Dell Pro Max Desktops Dell Pro Max Micro FCM2250,Dell Pro Max Micro XE FCM2250",
"CabUrl": "https://downloads.dell.com/FOLDER13898125M/1/Dell_Pro_Max_Desktops_0D14.cab",
"SystemId": "0D14"
}
]
},
"Lenovo": {
"Models": [
{
"Name": "Lenovo 300w Yoga Gen 4",
"MachineType": "82VN"
}
]
},
"Microsoft": {
"Models": [
{
"Name": "Surface Pro for Business (11th Edition)",
"Link": "https://www.microsoft.com/download/details.aspx?id=108013"
}
]
}
}
```
## Import Drivers.json
Import Drivers.json allows you to import a previously saved Drivers.json file. The models in the Drivers.json file will show up in the list view pre-selected. This will allow you to select additional models and save an updated version of Drivers.json, or to download the selected models by clicking Download Selected.
## Download Selected
Download Selected will download the selected models to the Drivers Folder path (default .\FFUDevelopment\Drivers). Drivers will download the the .\FFUDevelopment\Drivers\Make\Model folder. Download select also interacts with the Compress Driver Model Folder to WIM checkbox which will download and compress the drivers to WIM.
If you've previously downloaded a driver model and want to compress it to a WIM, you can check he Compress Driver Model Folder to WIM checkbox and click Download Selected again. This will skip the download and compress the driver folder to a WIM file.
Download Selected leverages BITS and the BITS Priority can be controlled by the BITS Priority drop down on the Build tab. If driver downloads via the UI feel slow, change BITS Priority to Foreground to speed them up.
## Clear List
Clears the list view of the previous model list
## Install Drivers to FFU
Install Drivers to FFU will recursively add the drivers in the FFUDevelopment\Drivers folder to the FFU file.
It's recommended to only include a single model's drivers in the FFU. This is because dism will add the drivers to the drivers store in the FFU and any additional models that aren't necessary will bloat the drivers store, using up disk space.
If you're dealing with multiple models, it's recommended to select Copy Drivers to USB drive instead.
## Copy Drivers to USB drive
Copy Drivers to USB drive will copy the drivers to the .\Drivers folder on the deploy partition of the USB drive (e.g. D:\Drivers\Make\Model)
If you're manually copying drivers to the .\FFUDevelopment\Drivers folder, you must copy them to the FFUDevelopment\Drivers\Make\Model folder (e.g. FFUDevelopment\Drivers\Lenovo\Lenovo 300w). Prior releases referenced using just .\FFUDevelopment\Drivers\Model, however for better organization and consistency, the code has been updated to require the make folder.
## Compress Driver Model Folder to WIM
Enabling this checkbox compresses the driver model folder to a WIM file after each model finishes downloading (or when an existing model is detected). Every `Drivers\<Make>\<Model>` directory is captured into a single `<Drivers folder>\<Make>\<Model>.wim` using DISM with `Compress:Max`, which dramatically reduces the space required on your USB drive.
1. Select the models you need, check **Compress Driver Model Folder to WIM**, then click **Download Selected**. Fresh downloads are extracted as usual and immediately compressed into their companion `.wim`.
2. If the model already exists, the download phase is skipped and only the compression runs, so you can rebuild the `.wim` whenever you refresh the folder contents.
By default the extracted folder is deleted after a successful capture so that the `.wim` becomes the canonical artifact. When **Use Drivers Folder as PE Drivers Source** is also checked, the UI keeps the folder in place, writes a `__PreservedForPEDrivers.txt` marker, and lets WinPE driver harvesting reuse the loose INF set.
Additional guidance:
- `DriverMapping.json` is updated to reference the `.wim`, so `Copy Drivers to USB drive`, `BuildFFUVM.ps1 -CopyDrivers`, and the WinPE `ApplyFFU.ps1` flow mount the compressed archive automatically.
- Watch the Drivers tab status column or `FFUDevelopment_UI.log` for DISM progress and troubleshooting details per model.
- Ensure the volume hosting `FFUDevelopment\Drivers` has enough free space for both the source folder and the resulting `.wim`.
- Only applies to drivers from Dell, HP, Lenovo, or Microsoft that are specified in the Drivers.json file. It will not compress models you manually copy to the Drivers folder.
## Copy PE Drivers
When **Copy PE Drivers** is enabled, drivers will be injected into the WinPE deployment media. This ensures that WinPE has the necessary drivers to recognize hardware components like storage controllers, network adapters, and input devices during FFU deployment.
By default, drivers are sourced from the **PE Drivers Folder** (default `.\FFUDevelopment\PEDrivers`). You can manually place drivers in this folder, and they will be injected into the WinPE media during the build process.
### Use Drivers Folder as PE Drivers Source
When **Copy PE Drivers** is checked, an additional sub-option becomes visible: **Use Drivers Folder as PE Drivers Source** .
When this option is enabled, the script bypasses the PE Drivers Folder and instead dynamically builds the WinPE driver set from the main **Drivers Folder**. The script scans all available drivers in the Drivers folder, parses their INF files, and copies only the essential driver types needed for WinPE, including:
* System devices
* SCSI, RAID, and NVMe controllers
* Keyboards
* Mice and other pointing devices
* Human Interface Devices (HID) for touch support
This eliminates the need to maintain a separate, manually curated `PEDrivers` folder and ensures that WinPE has the necessary drivers based on what you've already downloaded for your target devices.
{: .note-title}
> Note
>
> If the PE Drivers folder already contains content when using this option, it will be cleared before the new driver set is copied in.
>
> Some drivers may fail to be added during injection, which is expected behavior and can be safely ignored.
>
> Network adapters are not included when using the drivers folder as PE drivers source, so if you're using WDS or another network-based solution to copy your FFU and you've modified ApplyFFU.ps1, it's best to not use the **Use Drivers Folder as PE Driver Source** option and just copy in your required PE Drivers to the PE Drivers folder.
## DriverMapping.json
`DriverMapping.json` is an automatically generated file that maps hardware identifiers (like System IDs or Machine Types) to specific driver packages. This file enables the WinPE deployment script (`ApplyFFU.ps1`) to automatically detect your device hardware and apply the correct drivers without manual intervention.
### How it gets created
`DriverMapping.json` is created and updated automatically when you download drivers using the **Download Selected** button on the Drivers tab of the UI, or when drivers are downloaded during the FFU build. Each time you successfully download drivers for a model, the file is updated with the mapping information for that model.
### Automatic Driver Selection During Deployment
When you deploy an FFU using the WinPE media, `ApplyFFU.ps1` looks for `DriverMapping.json` on the USB drive at `D:\Drivers\DriverMapping.json` (where D: is your USB deploy partition). If found, the script:
1. Detects the hardware identifiers of the current device (System ID, Machine Type, etc.)
2. Searches `DriverMapping.json` for a matching entry
3. Automatically selects and applies the correct driver package
4. Falls back to manual driver selection if no match is found
### Required Fields by Manufacturer
Each entry in `DriverMapping.json` contains different required fields depending on the manufacturer:
**All Manufacturers:**
* **Manufacturer** The OEM name (e.g., "Dell", "HP", "Lenovo", "Microsoft")
* **Model** The full model name as it appears in the driver download catalog
* **DriverPath** The relative path to the driver folder or WIM file on the USB drive under the Drivers folder (e.g., "Dell\\\Dell Latitude 7490" or "HP\\\HP EliteBook 865 16 inch G11 Notebook PC.wim").
Relative paths are used since we don't know the drive letter of the USB drive when the `DriverMapping.json` file is created. And since this uses json, the double backslash is intentional since the first slash is an escape character.
**Dell:**
* **SystemId** The System SKU identifier (e.g., "0819", "0D14"). This is the primary matching field used during deployment. To find your Dell System SKU via PowerShell, run: `(Get-CIMInstance -ClassName "MS_SystemInformation" -NameSpace "root\WMI").SystemSku`
**HP:**
* **SystemId** The System Board ID, a four-character code (e.g., "8d03", "83D2"). This is the primary matching field used during deployment. To find your HP System Board ID via PowerShell, run: `(Get-CimInstance -Namespace 'root\WMI' -Class MS_SystemInformation).BaseboardProduct`
**Lenovo:**
* **MachineType** The first four characters of the MTM (Machine Type Model) field (e.g., "82VN", "21JD"). This is the primary matching field used during deployment. To find your Lenovo Machine Type via PowerShell, run: `(Get-CIMInstance -ClassName "MS_SystemInformation" -NameSpace "root\WMI").SystemProductName`
**Microsoft:**
* No additional fields required beyond Manufacturer, Model, and DriverPath. Matching is performed based on the normalized model name.
### Example DriverMapping.json
Below is an example of `DriverMapping.json` with entries for multiple manufacturers:
```
[
{
"Manufacturer": "Dell",
"Model": "Dell Latitude 7490",
"DriverPath": "Dell\\Dell Latitude 7490",
"SystemId": "0819"
},
{
"Manufacturer": "Dell",
"Model": "Dell Pro Max Desktops Dell Pro Max Micro FCM2250,Dell Pro Max Micro XE FCM2250",
"DriverPath": "Dell\\Dell Pro Max Desktops Dell Pro Max Micro FCM2250,Dell Pro Max Micro XE FCM2250.wim",
"SystemId": "0D14"
},
{
"Manufacturer": "HP",
"Model": "HP EliteBook 865 16 inch G11 Notebook PC",
"DriverPath": "HP\\HP EliteBook 865 16 inch G11 Notebook PC.wim",
"SystemId": "8D03"
},
{
"Manufacturer": "Lenovo",
"Model": "Lenovo 300w Yoga Gen 4",
"DriverPath": "Lenovo\\Lenovo 300w Yoga Gen 4",
"MachineType": "82VN"
},
{
"Manufacturer": "Microsoft",
"Model": "Surface Pro for Business (11th Edition)",
"DriverPath": "Microsoft\\Surface Pro for Business (11th Edition)"
}
]
```
## Bring Your Own Drivers
If you manage models that aren't from Dell, HP, Lenovo or Microsoft, or you want to use different drivers from what FFU Builder downloads, you can copy your own drivers to the `.\FFUDevelopment\Drivers` folder using the `.\FFUDevelopment\Drivers\Make\Model` format, or simply change the Drivers Folder path to the location of your drivers content.
You can also manually create your own DriverMapping.json file for the following makes/manufacturers
| Manufacturer | Match Field | WMI Class | Property |
| ------------------------------- | ----------- | ------------------------------------------- | ----------------------------------------------- |
| **Dell** | SystemId | `MS_SystemInformation` (via `root\WMI`) | `SystemSku` |
| **Dell** (fallback) | SystemId | `Win32_ComputerSystem` | `OEMStringArray` (parsed for bracketed value) |
| **HP** | SystemId | `MS_SystemInformation` (via `root\WMI`) | `BaseBoardProduct` |
| **Lenovo** | MachineType | `Win32_ComputerSystem` | `Model` |
| **Microsoft** | Model | `Win32_ComputerSystem` | `Model` |
| **Panasonic Corporation** | SystemId | `MS_SystemInformation` (via `root\WMI`) | `BaseBoardProduct` |
| **Viglen** | SystemId | `Win32_BaseBoard` | `SKU` |
| **AZW** | SystemId | `MS_SystemInformation` (via `root\WMI`) | `BaseBoardProduct` |
| **Fujitsu** | SystemId | `Win32_BaseBoard` | `SKU` |
| **Getac** | SystemId | `MS_SystemInformation` (via `root\WMI`) | `BaseBoardProduct` |
| **Intel** | Model | `Win32_ComputerSystem` | `Model` |
| **ByteSpeed** | Model | `Win32_ComputerSystem` | `Model` |
| **Other** (default) | Model | `Win32_ComputerSystem` | `Model` |
**Notes:**
* Match Field is the name of the field in the `DriverMapping.json` file (e.g. SystemID, MachineType, Model)
* SystemId is a catch-all term for a unique identifier, however each manufacturer calls this something different and stores them in different places within WMI
* The Dell (fallback) is used for models where the systemSKU isn't available and the OEMStringArray is parsed via Win32_ComputerSystem
* The `MS_SystemInformation` class is queried from the `root\WMI` namespace
* Unless noted, the other WMI classes use the `root\cimv2` namespace
* All identifiers are normalized to uppercase for matching
* ByteSpeed systems with "NUC" in the model name are re-mapped to Intel and use `BaseBoardProduct` instead
* For manufacturers that aren't listed, the default behavior is to use the `Win32_ComputerSystem` `model` string
Below is an example `DriverMapping.json` that includes the additional manufacturers. Note that the model and systemID information is made up and is used only as an example to show how to format the file. You'll need to collect the model or system ID from the locations in the table above and include it in your custom `DriverMapping.json` file. Each entry includes both a WIM and drivers folder for each manufacturer. If you want to include driver WIM files for manufacturers other than Dell, HP, Lenovo, or Microsoft, you'll need to manually compress the drivers folder to a WIM file.
```
[
{
"Manufacturer": "Panasonic",
"Model": "Toughbook CF-33",
"SystemId": "CF-33LEHAGT1",
"DriverPath": "Panasonic\\CF-33.wim"
},
{
"Manufacturer": "Panasonic",
"Model": "Toughbook FZ-55",
"SystemId": "FZ-55DZ0KVM",
"DriverPath": "Panasonic\\FZ-55"
},
{
"Manufacturer": "Viglen",
"Model": "Genie Desktop Pro",
"SystemId": "VGN-GDP-2024",
"DriverPath": "Viglen\\GeniePro"
},
{
"Manufacturer": "Viglen",
"Model": "Omnino Mini",
"SystemId": "VGN-OMN-M1",
"DriverPath": "Viglen\\OmninoMini.wim"
},
{
"Manufacturer": "AZW",
"Model": "SER5 Pro",
"SystemId": "SER5-5800H",
"DriverPath": "AZW\\SER5Pro.wim"
},
{
"Manufacturer": "AZW",
"Model": "U59 Mini PC",
"SystemId": "U59-N5095",
"DriverPath": "AZW\\U59"
},
{
"Manufacturer": "Fujitsu",
"Model": "LIFEBOOK U9312",
"SystemId": "FPCM52921",
"DriverPath": "Fujitsu\\LIFEBOOK-U9312"
},
{
"Manufacturer": "Fujitsu",
"Model": "ESPRIMO D7010",
"SystemId": "D3644-A1",
"DriverPath": "Fujitsu\\D7010.wim"
},
{
"Manufacturer": "Getac",
"Model": "F110 G6",
"SystemId": "F110G6",
"DriverPath": "Getac\\F110G6.wim"
},
{
"Manufacturer": "Getac",
"Model": "B360 Pro",
"SystemId": "B360PRO",
"DriverPath": "Getac\\B360Pro"
},
{
"Manufacturer": "Intel",
"Model": "NUC13ANHi7",
"DriverPath": "Intel\\NUC13"
},
{
"Manufacturer": "Intel",
"Model": "NUC12WSHi5",
"DriverPath": "Intel\\NUC12.wim"
},
{
"Manufacturer": "ByteSpeed",
"Model": "Tera 2450",
"DriverPath": "ByteSpeed\\Tera2450.wim"
},
{
"Manufacturer": "ByteSpeed",
"Model": "Celeritas X1",
"DriverPath": "ByteSpeed\\CeleritasX1"
},
{
"Manufacturer": "Acer",
"Model": "TravelMate P214-53",
"DriverPath": "Acer\\TMP214"
},
{
"Manufacturer": "ASUS",
"Model": "ExpertBook B5402CVA",
"DriverPath": "ASUS\\B5402.wim"
}
]
```
{% include page_nav.html %}
+20
View File
@@ -0,0 +1,20 @@
---
title: Helper Scripts
nav_order: 4
prev_url: /ui_overview.html
prev_label: UI Overview
next_url: /create_pemedia.html
next_label: Create PE Media
has_children: true
has_toc: false
---
# Helper Scripts
This section documents standalone helper scripts used outside the primary UI-driven build workflow.
## Available helper scripts
- [Create PE Media](/create_pemedia.html)
- [USB Imaging Tool Creator](/usb_imaging_tool_creator.html)
{% include page_nav.html %}
+52
View File
@@ -0,0 +1,52 @@
---
title: Hyper-V Settings
nav_order: 1
prev_url: /ui_overview.html
prev_label: UI Overview
next_url: /windows_settings.html
next_label: Windows Settings
parent: UI Overview
---
# Hyper-V Settings
![1776378277110](image/hyperv_settings/1776378277110.png)
## Enable VM Networking (Experimental)
Controls whether the build VM is connected to a Hyper-V switch during provisioning.
Leave this off for the default offline build path. Turn it on only if you want to test internet-connected builds and understand there may be Sysprep or capture issues.
## VM Switch Name
Drop down of detected VM Switches. There's also an **Other** option which allows you to specify a VM Switch Name. The other option is useful in scenarios where the machine you're running the UI from isn't going to be the machine where you plan to build the FFU from.
This setting is only used when **Enable VM Networking (Experimental)** is turned on. VM-based builds still capture from the host-side VHDX after the VM shuts down, so you only need a switch when the VM requires network connectivity during provisioning.
## Disk Size (GB)
Size of the virtual hard disk for the virtual machine. Default is a 50GB dynamic disk. You may want to increase the size if you're installing many apps.
## Memory (GB)
Amount of memory to allocate for the virtual machine. Recommended to use 8GB if possible, especially for Windows 11. Default is 4GB.
## Processors
Number of virtual processors for the virtual machine. Recommended to use at least 4. Default is 4.
## VM Location
Default is `$FFUDevelopmentPath\VM`. This is the location of the VHDX that gets created where Windows will be installed to.
## VM Name Prefix
Prefix for the generated VM. Default is _FFU.
## Logical Sector Size
Uint32 value of 512 or 4096. Useful for 4Kn drives or devices shipping with UFS drives. Default is 512.
There is some error-handling in the script that will call out mismatch issues with logical sector size. Unfortunately you will need to create a new FFU with the correct logical sector size as you can't convert a previously created FFU. Most should be fine with 512, but lower-end devices that used to ship with eMMC drives have now shifted to using UFS.
{% include page_nav.html %}
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

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