Compare commits

...

108 Commits

Author SHA1 Message Date
rbalsleyMSFT 56a2597818 Consolidate WinGet version detection
Add a shared WinGet component status helper that uses Get-WinGetVersion
through Microsoft.WinGet.Client, and route both the CLI and UI status checks
through it. This removes the UI-only winget.exe --version parsing path and
adds clearer logging for missing modules, missing cmdlets, parse failures,
and caught WinGet version errors.
2026-06-05 13:35:07 -07:00
rbalsleyMSFT 895728ebe8 Prefer DeviceGroup arch for Dell client drivers
Use DeviceGroup OperatingSystem osArch when selecting Dell client driver packages, falling back to SupportedOperatingSystems when DeviceGroup metadata is absent. This keeps existing server catalog behavior unchanged.
2026-06-05 10:41:00 -07:00
rbalsleyMSFT 9fb9e81701 Add configurable FFU build partition drive letters
Add System, Windows, and Recovery partition drive-letter settings to the Hyper-V Settings UI, config save/load, BuildFFUVM parameters, and sample/docs. Defaults remain S, W, and R.

Validate selected letters early, log validation failures for the UI, include drive-letter metadata in VHDX cache matching, and normalize legacy malformed cache values. This only affects FFU build-time partitioning; ApplyFFU deployment letters remain unchanged.
2026-06-05 09:46:00 -07:00
rbalsleyMSFT c32cb93434 Fix stale WinGetWin32Apps.json handling
Treat WinGetWin32Apps.json as generated app orchestration output so failed or cancelled builds do not restore stale install metadata. Reuse Apps.iso when app inputs have not changed, and refresh it only when current app content or orchestration inputs are newer.
2026-06-04 16:54:16 -07:00
rbalsleyMSFT 7decd8f1ba Merge pull request #495 from ConfigMatt/HyperV-Deployment-Instructions
Added HyperV Deployment instructions to quickstart documentation
2026-05-29 15:43:28 -07:00
rbalsleyMSFT 75f4025cf1 Merge pull request #378 from iambdud/add-try-catch-for-creating-vm
adding a simple try/catch for cases where the VM fails to start
2026-05-29 15:39:06 -07:00
rbalsleyMSFT f750cd872a Merge pull request #380 from dodexahedron/fix-winget-check-fail-if-multiple-versions-present
Fix failure on winget check if multiple versions exist
2026-05-29 14:55:18 -07:00
Matt Atkinson 9de2b30c93 Added HyperV Deployment instructions to quickstart documentation 2026-05-22 22:33:55 -07:00
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 e1c6259021 Processes unattend from a temp copy
Copies the source answer file to local temp storage before use, preserving the original media file and failing fast if the working copy cannot be prepared.
2026-04-28 13:10: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
Brandon Thetford d0e17eb0f7 No need to double-assign 🫣 2026-01-15 15:21:21 -07:00
Brandon Thetford e8aa1b5b4a Get instead of import, as an array, and get the best available 2026-01-15 15:19:12 -07:00
Brandon Thetford b21cb0bbf6 Actually include the global parameter 2026-01-15 14:58:11 -07:00
Brandon Thetford 372806e5aa Fix failure on winget check if multiple versions exist
Also imports the module at this step, globally
2026-01-15 14:53:30 -07:00
iambdud fafe28baf7 adding a simple try/catch for cases where the VM fails to start 2026-01-15 14:52:45 -06: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
85 changed files with 8214 additions and 2993 deletions
+144
View File
@@ -1,5 +1,149 @@
# Change Log # 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 # 2602.1 UI Preview
## What's Changed ## 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 { function Invoke-Process {
[CmdletBinding(SupportsShouldProcess)] [CmdletBinding(SupportsShouldProcess)]
param 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 # Initialize empty arrays for apps from each source
$wingetApps = @() $wingetApps = @()
$userApps = @() $userApps = @()
@@ -286,9 +289,9 @@ if ($wingetApps.Count -gt 0) {
Install-Applications -apps $wingetApps Install-Applications -apps $wingetApps
} }
# Read the UserAppList.json file if it exists # Read the configured BYO app list file if it exists
if (Test-Path -Path $userAppsJsonFile) { if (Test-Path -Path $userAppsJsonFile) {
Write-Host "Processing UserAppList.json..." Write-Host "Processing $(Split-Path -Path $userAppsJsonFile -Leaf)..."
try { try {
$userContent = Get-Content -Path $userAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json $userContent = Get-Content -Path $userAppsJsonFile -Raw -ErrorAction Stop | ConvertFrom-Json
if ($userContent -is [array]) { if ($userContent -is [array]) {
@@ -296,19 +299,19 @@ if (Test-Path -Path $userAppsJsonFile) {
Write-Host "Found $(($userApps | Measure-Object).Count) user-defined apps." Write-Host "Found $(($userApps | Measure-Object).Count) user-defined apps."
} }
elseif ($userContent) { elseif ($userContent) {
$userApps = @($userContent) # Ensure it's an array $userApps = @($userContent)
Write-Host "Found 1 user-defined app." Write-Host "Found 1 user-defined app."
} }
else { else {
Write-Host "UserAppList.json is empty or invalid." Write-Host "$(Split-Path -Path $userAppsJsonFile -Leaf) is empty or invalid."
} }
} }
catch { catch {
Write-Error "Failed to read or parse UserAppList.json file: $_" Write-Error "Failed to read or parse BYO app list file '$userAppsJsonFile': $_"
} }
} }
else { else {
Write-Host "UserAppList.json file not found. Skipping." Write-Host "BYO app list file not found at $userAppsJsonFile. Skipping."
} }
# Install User apps if any were found # Install User apps if any were found
@@ -28,8 +28,26 @@ Write-Host "---------------------------------------------------" -ForegroundColo
# Define the path to the scripts # Define the path to the scripts
$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition $scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
# Define the list of scripts to run, order doesn't matter - if you have a custom script, add it here # Resolve the configured BYO app list path for runtime orchestration.
$appInstallConfigPath = Join-Path -Path $scriptPath -ChildPath "AppInstallConfig.json"
$userAppsJsonFile = Join-Path -Path (Split-Path -Parent $scriptPath) -ChildPath "UserAppList.json"
if (Test-Path -Path $appInstallConfigPath) {
try {
$appInstallConfig = Get-Content -Path $appInstallConfigPath -Raw | ConvertFrom-Json
if ($null -ne $appInstallConfig -and $appInstallConfig.PSObject.Properties.Match('UserAppListPath').Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($appInstallConfig.UserAppListPath)) {
$userAppsJsonFile = $appInstallConfig.UserAppListPath
Write-Host "Using BYO app list path from AppInstallConfig.json: $userAppsJsonFile"
}
}
catch {
Write-Host "Failed to parse AppInstallConfig.json. Falling back to default BYO app list path."
}
}
# Define the list of scripts to run
$scriptList = @( $scriptList = @(
"Install-LTSCUpdate.ps1",
"Update-Defender.ps1", "Update-Defender.ps1",
"Install-Office.ps1", "Install-Office.ps1",
"Update-MSRT.ps1", "Update-MSRT.ps1",
@@ -50,7 +68,6 @@ foreach ($script in $scriptList) {
switch ($script) { switch ($script) {
"Install-Win32Apps.ps1" { "Install-Win32Apps.ps1" {
$wingetAppsJsonFile = Join-Path -Path $scriptPath -ChildPath "WinGetWin32Apps.json" $wingetAppsJsonFile = Join-Path -Path $scriptPath -ChildPath "WinGetWin32Apps.json"
$userAppsJsonFile = Join-Path -Path (Split-Path -Parent $scriptPath) -ChildPath "UserAppList.json"
if (-not (Test-Path -Path $wingetAppsJsonFile) -and -not (Test-Path -Path $userAppsJsonFile)) { if (-not (Test-Path -Path $wingetAppsJsonFile) -and -not (Test-Path -Path $userAppsJsonFile)) {
$shouldRun = $false $shouldRun = $false
} }
@@ -68,8 +85,13 @@ foreach ($script in $scriptList) {
Write-Host "---------------------------------------------------" -ForegroundColor Yellow Write-Host "---------------------------------------------------" -ForegroundColor Yellow
Write-Host " Running script: $script " -ForegroundColor Yellow Write-Host " Running script: $script " -ForegroundColor Yellow
Write-Host "---------------------------------------------------" -ForegroundColor Yellow Write-Host "---------------------------------------------------" -ForegroundColor Yellow
# Run script and wait for it to finish # Run script and wait for it to finish.
& $scriptFile if ($script -eq "Install-Win32Apps.ps1") {
& $scriptFile -UserAppsJsonFile $userAppsJsonFile
}
else {
& $scriptFile
}
} }
} }
File diff suppressed because it is too large Load Diff
+166 -20
View File
@@ -46,7 +46,8 @@ $script:uiState = [PSCustomObject]@{
logStreamReader = $null; logStreamReader = $null;
pollTimer = $null; pollTimer = $null;
currentBuildProcess = $null; currentBuildProcess = $null;
lastConfigFilePath = $null lastConfigFilePath = $null;
loadedDeviceNamingMode = $null
}; };
Flags = @{ Flags = @{
installAppsForcedByUpdates = $false; installAppsForcedByUpdates = $false;
@@ -55,7 +56,10 @@ $script:uiState = [PSCustomObject]@{
lastSortProperty = $null; lastSortProperty = $null;
lastSortAscending = $true; lastSortAscending = $true;
isBuilding = $false; isBuilding = $false;
isCleanupRunning = $false isCleanupRunning = $false;
isFluentSupported = $false;
deviceNamingModeWasExplicitlyChanged = $false;
suppressDeviceNamingChangeTracking = $false
}; };
Defaults = @{}; Defaults = @{};
LogFilePath = "$FFUDevelopmentPath\FFUDevelopment_UI.log" LogFilePath = "$FFUDevelopmentPath\FFUDevelopment_UI.log"
@@ -120,6 +124,9 @@ $reader = New-Object System.IO.StringReader($xamlString)
$xmlReader = [System.Xml.XmlReader]::Create($reader) $xmlReader = [System.Xml.XmlReader]::Create($reader)
$window = [Windows.Markup.XamlReader]::Load($xmlReader) $window = [Windows.Markup.XamlReader]::Load($xmlReader)
# Apply Fluent theme before the window renders (requires PowerShell 7.5+ / .NET 9+)
Initialize-FluentTheme -Window $window -ThemeMode "System" -State $script:uiState
$window.Add_Loaded({ $window.Add_Loaded({
# Pass the state object to all initialization functions # Pass the state object to all initialization functions
$script:uiState.Window = $window $script:uiState.Window = $window
@@ -129,6 +136,9 @@ $window.Add_Loaded({
Initialize-DynamicUIElements -State $script:uiState Initialize-DynamicUIElements -State $script:uiState
Register-EventHandlers -State $script:uiState Register-EventHandlers -State $script:uiState
# Populate the Home page build and release status after the window initializes
Start-HomeStatusRefresh -State $script:uiState
# Attempt automatic load of previous environment (silent) # Attempt automatic load of previous environment (silent)
try { try {
Invoke-AutoLoadPreviousEnvironment -State $script:uiState Invoke-AutoLoadPreviousEnvironment -State $script:uiState
@@ -293,9 +303,10 @@ $script:uiState.Controls.btnRun.Add_Click({
) )
$startCleanupParams = @{ $startCleanupParams = @{
FilePath = $pwshPath FilePath = $pwshPath
ArgumentList = $cleanupArgs ArgumentList = $cleanupArgs
PassThru = $true WorkingDirectory = $ffuDevPath
PassThru = $true
} }
if ($Host.Name -eq 'ConsoleHost') { if ($Host.Name -eq 'ConsoleHost') {
$startCleanupParams['NoNewWindow'] = $true $startCleanupParams['NoNewWindow'] = $true
@@ -327,7 +338,6 @@ $script:uiState.Controls.btnRun.Add_Click({
$script:uiState.Flags.isCleanupRunning = $true $script:uiState.Flags.isCleanupRunning = $true
$script:uiState.Data.pollTimer.Add_Tick({ $script:uiState.Data.pollTimer.Add_Tick({
param($sender, $e)
$currentProcess = $script:uiState.Data.currentBuildProcess $currentProcess = $script:uiState.Data.currentBuildProcess
# Read new lines from log # Read new lines from log
@@ -342,13 +352,13 @@ $script:uiState.Controls.btnRun.Add_Click({
} }
if ($null -eq $currentProcess -or $null -eq $script:uiState.Data.pollTimer) { if ($null -eq $currentProcess -or $null -eq $script:uiState.Data.pollTimer) {
if ($null -ne $sender) { $sender.Stop() } if ($null -ne $script:uiState.Data.pollTimer) { $script:uiState.Data.pollTimer.Stop() }
$script:uiState.Data.pollTimer = $null $script:uiState.Data.pollTimer = $null
return return
} }
if ($currentProcess.HasExited) { if ($currentProcess.HasExited) {
if ($null -ne $sender) { $sender.Stop() } if ($null -ne $script:uiState.Data.pollTimer) { $script:uiState.Data.pollTimer.Stop() }
$script:uiState.Data.pollTimer = $null $script:uiState.Data.pollTimer = $null
if ($null -ne $script:uiState.Data.logStreamReader) { if ($null -ne $script:uiState.Data.logStreamReader) {
@@ -389,8 +399,11 @@ $script:uiState.Controls.btnRun.Add_Click({
# Not currently building: start a new build # Not currently building: start a new build
$btnRun.IsEnabled = $false $btnRun.IsEnabled = $false
# Switch to Monitor Tab # Switch to Monitor page via navigation
$script:uiState.Controls.MainTabControl.SelectedItem = $script:uiState.Controls.MonitorTab $monitorIndex = 8 # Monitor is the 9th item (index 8) in the navigation list
if ($null -ne $script:uiState.Controls.lstNavigation) {
$script:uiState.Controls.lstNavigation.SelectedIndex = $monitorIndex
}
# Clear previous log data and reset autoscroll # Clear previous log data and reset autoscroll
if ($null -ne $script:uiState.Data.logData) { if ($null -ne $script:uiState.Data.logData) {
@@ -414,6 +427,123 @@ $script:uiState.Controls.btnRun.Add_Click({
return return
} }
if ($config.EnableVMNetworking -and $config.InstallApps -and [string]::IsNullOrWhiteSpace([string]$config.VMSwitchName)) {
[System.Windows.MessageBox]::Show("Select or enter a VM Switch Name before enabling VM networking.", "VM Switch Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: VM switch required for experimental networking."
return
}
if ($config.CopyUnattend -and $config.InjectUnattend) {
[System.Windows.MessageBox]::Show("Copy Unattend.xml and Inject Unattend.xml cannot both be selected. Choose only one unattend delivery method.", "Unattend Selection Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: choose only one unattend delivery method."
return
}
if ($config.CopyUnattend -or $config.InjectUnattend) {
$selectedUnattendArch = if ($config.WindowsArch -ieq 'arm64') { 'arm64' } else { 'x64' }
$selectedUnattendSourcePath = if ($selectedUnattendArch -eq 'arm64') {
[string]$config.UnattendArm64FilePath
}
else {
[string]$config.UnattendX64FilePath
}
if ([string]::IsNullOrWhiteSpace($selectedUnattendSourcePath)) {
[System.Windows.MessageBox]::Show("Select a valid $selectedUnattendArch unattend XML file before using Copy Unattend.xml or Inject Unattend.xml.", "Unattend File Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: unattend file path required."
return
}
if (-not (Test-Path -Path $selectedUnattendSourcePath -PathType Leaf)) {
[System.Windows.MessageBox]::Show("The selected $selectedUnattendArch unattend XML file was not found:`n$selectedUnattendSourcePath", "Unattend File Missing", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: unattend file missing."
return
}
$selectedUnattendFileInfo = Get-Item -Path $selectedUnattendSourcePath -ErrorAction SilentlyContinue
if (($null -eq $selectedUnattendFileInfo) -or ($selectedUnattendFileInfo.Length -le 0)) {
[System.Windows.MessageBox]::Show("The selected $selectedUnattendArch unattend XML file is empty:`n$selectedUnattendSourcePath", "Unattend File Empty", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: unattend file empty."
return
}
}
if ($config.DeviceNamingMode -eq 'Prompt') {
if (-not $config.CopyUnattend) {
[System.Windows.MessageBox]::Show("Select Copy Unattend.xml before using 'Prompt for Device Name'.", "Copy Unattend Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: prompt naming requires Copy Unattend.xml."
return
}
}
elseif ($config.DeviceNamingMode -eq 'Template') {
if ([string]::IsNullOrWhiteSpace([string]$config.DeviceNameTemplate)) {
[System.Windows.MessageBox]::Show("Specify a device name before using 'Specify Device Name'.", "Device Name Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: device name required."
return
}
if (-not ($config.CopyUnattend -or $config.InjectUnattend)) {
[System.Windows.MessageBox]::Show("Select Copy Unattend.xml or Inject Unattend.xml before using 'Specify Device Name'.", "Unattend Selection Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: unattend delivery method required for device naming."
return
}
$templateWithoutSupportedVariables = ([string]$config.DeviceNameTemplate) -replace '(?i)%serial%', ''
if ($templateWithoutSupportedVariables -match '%') {
[System.Windows.MessageBox]::Show("Only the %serial% device name variable is supported.", "Unsupported Device Name Variable", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: unsupported device name variable."
return
}
if ($config.InjectUnattend -and (-not $config.CopyUnattend) -and ([string]$config.DeviceNameTemplate -match '(?i)%serial%')) {
[System.Windows.MessageBox]::Show("The %serial% device name variable is only supported when Copy Unattend.xml is selected.", "Unsupported Inject Unattend Setting", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: %serial% requires Copy Unattend.xml."
return
}
}
elseif ($config.DeviceNamingMode -eq 'Prefixes') {
if (-not $config.CopyUnattend) {
[System.Windows.MessageBox]::Show("Select Copy Unattend.xml before using 'Specify a list of Prefixes'.", "Copy Unattend Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: prefixes require Copy Unattend.xml."
return
}
$hasSavedPrefixesPath = -not [string]::IsNullOrWhiteSpace([string]$config.DeviceNamePrefixesPath) -and (Test-Path -Path $config.DeviceNamePrefixesPath -PathType Leaf)
if ((($null -eq $config.DeviceNamePrefixes) -or ($config.DeviceNamePrefixes.Count -eq 0)) -and -not $hasSavedPrefixesPath) {
[System.Windows.MessageBox]::Show("Enter at least one prefix or choose a valid prefixes file before using 'Specify a list of Prefixes'.", "Prefixes Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: prefixes required."
return
}
}
elseif ($config.DeviceNamingMode -eq 'SerialComputerNames') {
if (-not $config.CopyUnattend) {
[System.Windows.MessageBox]::Show("Select Copy Unattend.xml before using 'Specify Serial to Device Name Mapping'.", "Copy Unattend Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: serial computer-name mapping requires Copy Unattend.xml."
return
}
$hasSavedSerialComputerNamesPath = -not [string]::IsNullOrWhiteSpace([string]$config.DeviceNameSerialComputerNamesPath) -and (Test-Path -Path $config.DeviceNameSerialComputerNamesPath -PathType Leaf)
if ((($null -eq $config.DeviceNameSerialComputerNames) -or ($config.DeviceNameSerialComputerNames.Count -eq 0)) -and -not $hasSavedSerialComputerNamesPath) {
[System.Windows.MessageBox]::Show("Enter CSV content or choose a valid Serial Computer Names CSV Mapping File Path before using 'Specify Serial to Device Name Mapping'.", "Serial Mapping Required", "OK", "Warning") | Out-Null
$btnRun.IsEnabled = $true
$script:uiState.Controls.txtStatus.Text = "Build canceled: serial computer-name mapping required."
return
}
}
$configFilePath = Join-Path $config.FFUDevelopmentPath "\config\FFUConfig.json" $configFilePath = Join-Path $config.FFUDevelopmentPath "\config\FFUConfig.json"
# Sort top-level keys alphabetically for consistent output # Sort top-level keys alphabetically for consistent output
$sortedConfig = [ordered]@{} $sortedConfig = [ordered]@{}
@@ -455,9 +585,10 @@ $script:uiState.Controls.btnRun.Add_Click({
} }
$startBuildParams = @{ $startBuildParams = @{
FilePath = $pwshPath FilePath = $pwshPath
ArgumentList = $pwshArgs ArgumentList = $pwshArgs
PassThru = $true WorkingDirectory = $config.FFUDevelopmentPath
PassThru = $true
} }
if ($Host.Name -eq 'ConsoleHost') { if ($Host.Name -eq 'ConsoleHost') {
$startBuildParams['NoNewWindow'] = $true $startBuildParams['NoNewWindow'] = $true
@@ -489,7 +620,6 @@ $script:uiState.Controls.btnRun.Add_Click({
# Add the Tick event handler # Add the Tick event handler
$script:uiState.Data.pollTimer.Add_Tick({ $script:uiState.Data.pollTimer.Add_Tick({
param($sender, $e)
# This scriptblock runs on the UI thread, so it can safely access script-scoped variables # This scriptblock runs on the UI thread, so it can safely access script-scoped variables
$currentProcess = $script:uiState.Data.currentBuildProcess $currentProcess = $script:uiState.Data.currentBuildProcess
@@ -517,8 +647,8 @@ $script:uiState.Controls.btnRun.Add_Click({
# If process is somehow null or the timer has been nulled out, stop the timer # If process is somehow null or the timer has been nulled out, stop the timer
if ($null -eq $currentProcess -or $null -eq $script:uiState.Data.pollTimer) { if ($null -eq $currentProcess -or $null -eq $script:uiState.Data.pollTimer) {
if ($null -ne $sender) { if ($null -ne $script:uiState.Data.pollTimer) {
$sender.Stop() $script:uiState.Data.pollTimer.Stop()
} }
$script:uiState.Data.pollTimer = $null $script:uiState.Data.pollTimer = $null
return return
@@ -527,8 +657,8 @@ $script:uiState.Controls.btnRun.Add_Click({
# Check if the build process has exited # Check if the build process has exited
if ($currentProcess.HasExited) { if ($currentProcess.HasExited) {
# Stop the timer, we're done polling # Stop the timer, we're done polling
if ($null -ne $sender) { if ($null -ne $script:uiState.Data.pollTimer) {
$sender.Stop() $script:uiState.Data.pollTimer.Stop()
} }
$script:uiState.Data.pollTimer = $null $script:uiState.Data.pollTimer = $null
@@ -566,9 +696,25 @@ $script:uiState.Controls.btnRun.Add_Click({
# Determine final status based on process exit code # Determine final status based on process exit code
$finalStatusText = "FFU build completed successfully." $finalStatusText = "FFU build completed successfully."
if ($exitCode -ne 0) { if ($exitCode -ne 0) {
$finalStatusText = "FFU build failed. Check FFUDevelopment.log for details." $failureDetail = $null
if ($null -ne $script:uiState.Data.logData) {
for ($logIndex = $script:uiState.Data.logData.Count - 1; $logIndex -ge 0; $logIndex--) {
$logLine = [string]$script:uiState.Data.logData[$logIndex]
if ($logLine -match '(?i)(Build validation failed|Exception|ERROR|already assigned|must be a single drive letter|must be unique)') {
$failureDetail = $logLine
break
}
}
}
$finalStatusText = if ($failureDetail) { "FFU build failed. $failureDetail" } else { "FFU build failed. Check FFUDevelopment.log for details." }
WriteLog "BuildFFUVM.ps1 process failed with exit code: $exitCode" 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 $buildErrorMessage = "The build process failed. Please check the $FFUDevelopmentPath\FFUDevelopment.log file for details."
if ($failureDetail) {
$buildErrorMessage += "`n`n$failureDetail"
}
$buildErrorMessage += "`n`nExit code: $exitCode"
[System.Windows.MessageBox]::Show($buildErrorMessage, "Build Error", "OK", "Error") | Out-Null
$script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed' $script:uiState.Controls.pbOverallProgress.Visibility = 'Collapsed'
} }
else { else {
File diff suppressed because it is too large Load Diff
+18 -57
View File
@@ -3,11 +3,8 @@ param (
[string]$adkPath = 'C:\Program Files (x86)\Windows Kits\10\', [string]$adkPath = 'C:\Program Files (x86)\Windows Kits\10\',
[string]$WindowsArch = 'x64', [string]$WindowsArch = 'x64',
[bool]$CopyPEDrivers = $false, [bool]$CopyPEDrivers = $false,
[string]$CaptureISO = "$PSScriptRoot\WinPE_FFU_Capture_x64.iso",
[string]$DeployISO = "$PSScriptRoot\WinPE_FFU_Deploy_x64.iso", [string]$DeployISO = "$PSScriptRoot\WinPE_FFU_Deploy_x64.iso",
[string]$LogFile = "$PSScriptRoot\Create-PEMedia.log", [string]$LogFile = "$PSScriptRoot\Create-PEMedia.log"
[bool]$Capture,
[bool]$Deploy = $true
) )
function WriteLog($LogText) { function WriteLog($LogText) {
@@ -77,12 +74,7 @@ function Invoke-Process {
} }
function New-PEMedia { function New-PEMedia {
param ( param ()
[Parameter()]
[bool]$Capture,
[Parameter()]
[bool]$Deploy
)
#Need to use the Demployment and Imaging tools environment to create winPE media #Need to use the Demployment and Imaging tools environment to create winPE media
$DandIEnv = "$adkPath`Assessment and Deployment Kit\Deployment Tools\DandISetEnv.bat" $DandIEnv = "$adkPath`Assessment and Deployment Kit\Deployment Tools\DandISetEnv.bat"
$WinPEFFUPath = "$FFUDevelopmentPath\WinPE" $WinPEFFUPath = "$FFUDevelopmentPath\WinPE"
@@ -135,36 +127,21 @@ function New-PEMedia {
Add-WindowsPackage -Path "$WinPEFFUPath\mount" -PackagePath $PackagePath | Out-Null Add-WindowsPackage -Path "$WinPEFFUPath\mount" -PackagePath $PackagePath | Out-Null
WriteLog "Adding package complete" WriteLog "Adding package complete"
} }
If ($Capture) { WriteLog "Copying $FFUDevelopmentPath\WinPEDeployFFUFiles\* to WinPE deploy media"
WriteLog "Copying $FFUDevelopmentPath\WinPECaptureFFUFiles\* to WinPE capture media" Copy-Item -Path "$FFUDevelopmentPath\WinPEDeployFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | Out-Null
Copy-Item -Path "$FFUDevelopmentPath\WinPECaptureFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | out-null WriteLog 'Copy complete'
WriteLog "Copy complete" #If $CopyPEDrivers = $true, add drivers to WinPE media using dism
#Remove Bootfix.bin - for BIOS systems, shouldn't be needed, but doesn't hurt to remove for our purposes if ($CopyPEDrivers) {
#Remove-Item -Path "$WinPEFFUPath\media\boot\bootfix.bin" -Force | Out-null WriteLog "Adding drivers to WinPE media"
# $WinPEISOName = 'WinPE_FFU_Capture.iso' try {
$WinPEISOFile = $CaptureISO Add-WindowsDriver -Path "$WinPEFFUPath\Mount" -Driver "$FFUDevelopmentPath\PEDrivers" -Recurse -ErrorAction SilentlyContinue | Out-null
# $Capture = $false
}
If ($Deploy) {
WriteLog "Copying $FFUDevelopmentPath\WinPEDeployFFUFiles\* to WinPE deploy media"
Copy-Item -Path "$FFUDevelopmentPath\WinPEDeployFFUFiles\*" -Destination "$WinPEFFUPath\mount" -Recurse -Force | Out-Null
WriteLog 'Copy complete'
#If $CopyPEDrivers = $true, add drivers to WinPE media using dism
if ($CopyPEDrivers) {
WriteLog "Adding drivers to WinPE media"
try {
Add-WindowsDriver -Path "$WinPEFFUPath\Mount" -Driver "$FFUDevelopmentPath\PEDrivers" -Recurse -ErrorAction SilentlyContinue | Out-null
}
catch {
WriteLog 'Some drivers failed to be added to the FFU. This can be expected. Continuing.'
}
WriteLog "Adding drivers complete"
} }
# $WinPEISOName = 'WinPE_FFU_Deploy.iso' catch {
$WinPEISOFile = $DeployISO WriteLog 'Some drivers failed to be added to the FFU. This can be expected. Continuing.'
}
# $Deploy = $false WriteLog "Adding drivers complete"
} }
$WinPEISOFile = $DeployISO
WriteLog 'Dismounting WinPE media' WriteLog 'Dismounting WinPE media'
Dismount-WindowsImage -Path "$WinPEFFUPath\mount" -Save | Out-Null Dismount-WindowsImage -Path "$WinPEFFUPath\mount" -Save | Out-Null
WriteLog 'Dismount complete' WriteLog 'Dismount complete'
@@ -179,21 +156,10 @@ function New-PEMedia {
WriteLog "Creating WinPE ISO at $WinPEISOFile" WriteLog "Creating WinPE ISO at $WinPEISOFile"
# & "$OSCDIMG" -m -o -u2 -udfver102 -bootdata:2`#p0,e,b$OSCDIMGPath\etfsboot.com`#pEF,e,b$OSCDIMGPath\Efisys_noprompt.bin $WinPEFFUPath\media $FFUDevelopmentPath\$WinPEISOName | Out-null # & "$OSCDIMG" -m -o -u2 -udfver102 -bootdata:2`#p0,e,b$OSCDIMGPath\etfsboot.com`#pEF,e,b$OSCDIMGPath\Efisys_noprompt.bin $WinPEFFUPath\media $FFUDevelopmentPath\$WinPEISOName | Out-null
if($WindowsArch -eq 'x64'){ if($WindowsArch -eq 'x64'){
if($Capture){ $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys_noprompt.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
}
if($Deploy){
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:2`#p0,e,b`"$OSCDIMGPath\etfsboot.com`"`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
}
} }
elseif($WindowsArch -eq 'arm64'){ elseif($WindowsArch -eq 'arm64'){
if($Capture){ $OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys_noprompt.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
}
if($Deploy){
$OSCDIMGArgs = "-m -o -u2 -udfver102 -bootdata:1`#pEF,e,b`"$OSCDIMGPath\Efisys.bin`" `"$WinPEFFUPath\media`" `"$WinPEISOFile`""
}
} }
Invoke-Process $OSCDIMG $OSCDIMGArgs Invoke-Process $OSCDIMG $OSCDIMGArgs
WriteLog "ISO created successfully" WriteLog "ISO created successfully"
@@ -201,9 +167,4 @@ function New-PEMedia {
Remove-Item -Path "$WinPEFFUPath" -Recurse -Force Remove-Item -Path "$WinPEFFUPath" -Recurse -Force
WriteLog 'Cleanup complete' WriteLog 'Cleanup complete'
} }
if($Capture){ New-PEMedia
New-PEMedia -Capture $Capture
}
if($Deploy){
New-PEMedia -Deploy $Deploy
}
+3 -5
View File
@@ -85,12 +85,10 @@ graph TD
subgraph "VM-Based Capture (-InstallApps)" subgraph "VM-Based Capture (-InstallApps)"
direction LR direction LR
BB[Create Hyper-V VM from VHDX]; BB[Create Hyper-V VM from VHDX];
BB --> BC["Create WinPE Capture Media iso"]; BB --> BE["Start VM: Boots to Audit Mode"];
BC --> BD[Configure network share for capture];
BD --> BE["Start VM: Boots to Audit Mode"];
BE --> BF[Orchestrator runs: Installs apps, syspreps, shuts down]; BE --> BF[Orchestrator runs: Installs apps, syspreps, shuts down];
BF --> BG[VM reboots from Capture Media]; BF --> BG[Host optimizes and remounts VHDX];
BG --> BH["CaptureFFU.ps1 runs, saves FFU to share, shuts down"]; BG --> BH["DISM captures FFU directly from host-mounted VHDX"];
end end
subgraph "Direct VHDX Capture" subgraph "Direct VHDX Capture"
@@ -6,28 +6,23 @@ function Invoke-FFUPostBuildCleanup {
[string]$AppsPath, [string]$AppsPath,
[string]$DriversPath, [string]$DriversPath,
[string]$FFUCapturePath, [string]$FFUCapturePath,
[string]$CaptureISOPath,
[string]$DeployISOPath, [string]$DeployISOPath,
[string]$AppsISOPath, [string]$AppsISOPath,
[string]$KBPath, [string]$KBPath,
[bool]$RemoveCaptureISO = $false,
[bool]$RemoveDeployISO = $false, [bool]$RemoveDeployISO = $false,
[bool]$RemoveAppsISO = $false, [bool]$RemoveAppsISO = $false,
[bool]$RemoveDrivers = $false, [bool]$RemoveDrivers = $false,
[bool]$RemoveFFU = $false, [bool]$RemoveFFU = $false,
[bool]$RemoveApps = $false, [bool]$RemoveApps = $false,
[bool]$RemoveUpdates = $false [bool]$RemoveUpdates = $false,
[bool]$RemoveDownloadedESD = $false
) )
$originalProgressPreference = $ProgressPreference $originalProgressPreference = $ProgressPreference
$ProgressPreference = 'SilentlyContinue' $ProgressPreference = 'SilentlyContinue'
try { 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) # 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)) { if ($RemoveDeployISO -and -not [string]::IsNullOrWhiteSpace($DeployISOPath) -and (Test-Path -LiteralPath $DeployISOPath)) {
WriteLog "CommonCleanup: Removing $DeployISOPath" WriteLog "CommonCleanup: Removing $DeployISOPath"
try { Remove-Item -LiteralPath $DeployISOPath -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $DeployISOPath : $($_.Exception.Message)" } 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) # 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) { if ($RemoveDeployISO) {
Get-ChildItem -LiteralPath $RootPath -Filter 'WinPE_FFU_Deploy*.iso' -ErrorAction SilentlyContinue | ForEach-Object { 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)" } 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)" }
@@ -79,6 +69,11 @@ function Invoke-FFUPostBuildCleanup {
WriteLog "CommonCleanup: Removing $store" WriteLog "CommonCleanup: Removing $store"
try { Remove-Item -LiteralPath $store -Recurse -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $store : $($_.Exception.Message)" } try { Remove-Item -LiteralPath $store -Recurse -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $store : $($_.Exception.Message)" }
} }
$wingetWin32AppsJson = Join-Path (Join-Path $AppsPath 'Orchestration') 'WinGetWin32Apps.json'
if (Test-Path -LiteralPath $wingetWin32AppsJson) {
WriteLog "CommonCleanup: Removing $wingetWin32AppsJson"
try { Remove-Item -LiteralPath $wingetWin32AppsJson -Force -ErrorAction Stop } catch { WriteLog "CommonCleanup: Failed removing $wingetWin32AppsJson : $($_.Exception.Message)" }
}
$office = Join-Path $AppsPath 'Office' $office = Join-Path $AppsPath 'Office'
if ((Test-Path -LiteralPath $office) -and $InstallOffice) { if ((Test-Path -LiteralPath $office) -and $InstallOffice) {
WriteLog "CommonCleanup: Checking for Office artifacts in $office" WriteLog "CommonCleanup: Checking for Office artifacts in $office"
@@ -95,6 +90,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 ($RemoveUpdates) {
if (-not [string]::IsNullOrWhiteSpace($AppsPath) -and (Test-Path -LiteralPath $AppsPath)) { if (-not [string]::IsNullOrWhiteSpace($AppsPath) -and (Test-Path -LiteralPath $AppsPath)) {
# Remove per-run app update payloads stored under Apps # Remove per-run app update payloads stored under Apps
@@ -114,6 +118,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." WriteLog "CommonCleanup: Completed."
} }
catch { catch {
@@ -157,6 +157,79 @@ function Invoke-Process {
return $cmd return $cmd
} }
function Get-RunManifestPathForDownloadTarget {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Destination
)
try {
$currentPath = Split-Path -Path $Destination -Parent
if ([string]::IsNullOrWhiteSpace($currentPath)) { return $null }
while ($currentPath) {
$manifestPath = Join-Path -Path $currentPath -ChildPath '.session\currentRun.json'
if (Test-Path -LiteralPath $manifestPath -PathType Leaf) {
return $manifestPath
}
$parentPath = Split-Path -Path $currentPath -Parent
if ([string]::IsNullOrWhiteSpace($parentPath) -or $parentPath -eq $currentPath) {
break
}
$currentPath = $parentPath
}
}
catch {
WriteLog "Get-RunManifestPathForDownloadTarget failed for '$Destination': $($_.Exception.Message)"
}
return $null
}
function Register-CurrentRunDownloadTarget {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Destination
)
if ([string]::IsNullOrWhiteSpace($Destination)) { return }
$manifestPath = Get-RunManifestPathForDownloadTarget -Destination $Destination
if ([string]::IsNullOrWhiteSpace($manifestPath)) { return }
$mutexName = 'Global\FFUCurrentRunDownloadTargetsMutex'
$mutex = New-Object System.Threading.Mutex($false, $mutexName)
try {
$null = $mutex.WaitOne()
$manifest = Get-Content -LiteralPath $manifestPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
if ($null -eq $manifest) { return }
if ($null -eq $manifest.PSObject.Properties['DownloadTargets']) {
Add-Member -InputObject $manifest -MemberType NoteProperty -Name DownloadTargets -Value @()
}
$downloadTargets = @($manifest.DownloadTargets)
if ($Destination -notin $downloadTargets) {
$downloadTargets += $Destination
$manifest.DownloadTargets = $downloadTargets
$manifest | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $manifestPath -Encoding UTF8
WriteLog "Registered current-run download target: $Destination"
}
}
catch {
WriteLog "Register-CurrentRunDownloadTarget failed for '$Destination': $($_.Exception.Message)"
}
finally {
try { $mutex.ReleaseMutex() | Out-Null } catch {}
$mutex.Dispose()
}
}
# Function to download a file using BITS with retry and error handling # Function to download a file using BITS with retry and error handling
function Start-BitsTransferWithRetry { function Start-BitsTransferWithRetry {
param ( param (
@@ -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 $attempt = 0
$lastError = $null $lastError = $null
$notLoggedOnHResult = [int]0x800704dd $notLoggedOnHResult = [int]0x800704dd
@@ -26,6 +26,26 @@ function Compare-DellVendorVersion {
return 0 return 0
} }
function Test-DellDriverComponentOsArch {
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][System.Xml.XmlElement]$Component,
[Parameter(Mandatory=$true)][ValidateSet('x64', 'x86', 'ARM64')][string]$WindowsArch
)
$deviceGroupOsNodes = @($Component.SelectNodes("*[local-name()='DeviceGroup']/*[local-name()='OperatingSystem']"))
if ($deviceGroupOsNodes.Count -gt 0) {
$validDeviceGroupOs = $deviceGroupOsNodes | Where-Object { $_.GetAttribute('osArch') -eq $WindowsArch } | Select-Object -First 1
return $null -ne $validDeviceGroupOs
}
$supportedOsNodes = @($Component.SelectNodes("*[local-name()='SupportedOperatingSystems']/*[local-name()='OperatingSystem']"))
if ($supportedOsNodes.Count -eq 0) { return $false }
$validSupportedOs = $supportedOsNodes | Where-Object { $_.GetAttribute('osArch') -eq $WindowsArch } | Select-Object -First 1
return $null -ne $validSupportedOs
}
function Get-DellCatalogIndex { function Get-DellCatalogIndex {
[CmdletBinding()] [CmdletBinding()]
param( param(
@@ -155,10 +175,7 @@ function Get-DellLatestDriverPackages {
if ($ctype.GetAttribute('value') -ne 'DRVR') { continue } if ($ctype.GetAttribute('value') -ne 'DRVR') { continue }
# OS filtering (arch only release filtering intentionally minimal for now) # OS filtering (arch only release filtering intentionally minimal for now)
$osNodes = @($comp.SelectNodes("*[local-name()='SupportedOperatingSystems']/*[local-name()='OperatingSystem']")) if (-not (Test-DellDriverComponentOsArch -Component $comp -WindowsArch $WindowsArch)) { continue }
if (-not $osNodes) { continue }
$validOS = $osNodes | Where-Object { $_.GetAttribute('osArch') -eq $WindowsArch } | Select-Object -First 1
if (-not $validOS) { continue }
$path = $comp.GetAttribute('path') $path = $comp.GetAttribute('path')
if (-not $path) { continue } if (-not $path) { continue }
@@ -161,12 +161,181 @@ function ConvertTo-SurfaceComparableName {
# Cleanup: remove orphaned "with" left behind by earlier removals (e.g., "Surface Pro 9 with Intel Processor") # 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 '(?i)\bwith\b', ''
$value = $value -replace '\s+', ' ' $value = $value -replace '\s+', ' '
return $value.Trim().ToUpperInvariant() return $value.Trim().ToUpperInvariant()
} }
function Get-SurfaceSystemSkuReferenceIndex { 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()] [CmdletBinding()]
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
@@ -586,6 +755,9 @@ Export-ModuleMember -Function `
Import-SurfaceDriverIndexCache, ` Import-SurfaceDriverIndexCache, `
Save-SurfaceDriverIndexCache, ` Save-SurfaceDriverIndexCache, `
ConvertTo-SurfaceComparableName, ` ConvertTo-SurfaceComparableName, `
ConvertTo-SurfaceHtmlText, `
ConvertTo-SurfaceDownloadCenterLink, `
Get-SurfaceDriverModelIndex, `
Get-SurfaceSystemSkuReferenceIndex, ` Get-SurfaceSystemSkuReferenceIndex, `
Get-SurfaceDownloadCenterDetails, ` Get-SurfaceDownloadCenterDetails, `
Get-SurfaceSystemSkuListForMicrosoftDriver Get-SurfaceSystemSkuListForMicrosoftDriver
@@ -161,6 +161,7 @@ function Invoke-ParallelProcessing {
ApplicationItemData = $currentItem ApplicationItemData = $currentItem
AppListJsonPath = $localJobArgs['AppListJsonPath'] AppListJsonPath = $localJobArgs['AppListJsonPath']
AppsPath = $localJobArgs['AppsPath'] AppsPath = $localJobArgs['AppsPath']
UserAppListPath = $localJobArgs['UserAppListPath']
OrchestrationPath = $localJobArgs['OrchestrationPath'] OrchestrationPath = $localJobArgs['OrchestrationPath']
ProgressQueue = $localProgressQueue ProgressQueue = $localProgressQueue
WindowsArch = $localJobArgs['WindowsArch'] WindowsArch = $localJobArgs['WindowsArch']
+126 -15
View File
@@ -358,6 +358,8 @@ function Start-WingetAppDownloadTask {
[string]$AppListJsonPath, [string]$AppListJsonPath,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$AppsPath, [string]$AppsPath,
[Parameter()]
[string]$UserAppListPath,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$OrchestrationPath, [string]$OrchestrationPath,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
@@ -379,11 +381,11 @@ function Start-WingetAppDownloadTask {
WriteLog "Starting download task for $($appName) with ID $($appId) from source $($source)." WriteLog "Starting download task for $($appName) with ID $($appId) from source $($source)."
try { try {
# Define paths # Resolve the BYO app list path so duplicate checks honor custom file names.
$userAppListPath = Join-Path -Path $AppsPath -ChildPath "UserAppList.json" $userAppListPath = if (-not [string]::IsNullOrWhiteSpace($UserAppListPath)) { $UserAppListPath } else { Join-Path -Path $AppsPath -ChildPath "UserAppList.json" }
$appFound = $false $appFound = $false
# 1. Check UserAppList.json and content # 1. Check the configured BYO app list and content
if (Test-Path -Path $userAppListPath) { if (Test-Path -Path $userAppListPath) {
try { try {
$userAppListContent = Get-Content -Path $userAppListPath -Raw | ConvertFrom-Json $userAppListContent = Get-Content -Path $userAppListPath -Raw | ConvertFrom-Json
@@ -724,6 +726,8 @@ function Get-Apps {
[string]$AppList, [string]$AppList,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$AppsPath, [string]$AppsPath,
[Parameter()]
[string]$UserAppListPath,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$WindowsArch, [string]$WindowsArch,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
@@ -787,6 +791,7 @@ function Get-Apps {
# CLI builds should create WinGetWin32Apps.json, so SkipWin32Json is false # CLI builds should create WinGetWin32Apps.json, so SkipWin32Json is false
$taskArguments = @{ $taskArguments = @{
AppsPath = $AppsPath AppsPath = $AppsPath
UserAppListPath = $UserAppListPath
AppListJsonPath = $AppList AppListJsonPath = $AppList
OrchestrationPath = $OrchestrationPath OrchestrationPath = $OrchestrationPath
WindowsArch = $WindowsArch WindowsArch = $WindowsArch
@@ -1018,6 +1023,94 @@ function Install-WinGet {
} }
WriteLog "WinGet installation complete." WriteLog "WinGet installation complete."
} }
function Get-WinGetComponentStatus {
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[version]$MinimumVersion = [version]"1.8.1911"
)
$moduleName = 'Microsoft.WinGet.Client'
$status = [PSCustomObject]@{
Success = $false
NeedsUpdate = $true
WinGetInstalled = $false
WinGetNeedsUpdate = $true
WinGetVersion = "Unknown"
WinGetVersionObject = $null
WinGetStatus = "Unknown"
ModuleInstalled = $false
ModuleNeedsUpdate = $true
ModuleVersion = "Not installed"
ModuleVersionObject = $null
CmdletAvailable = $false
ErrorMessage = ""
}
try {
$installedModule = @(Get-InstalledModule -Name $moduleName -ErrorAction SilentlyContinue) | Sort-Object -Property Version -Descending | Select-Object -First 1
$availableModule = @(Get-Module -ListAvailable -Name $moduleName -ErrorAction SilentlyContinue) | Sort-Object -Property Version -Descending | Select-Object -First 1
$wingetModule = if ($null -ne $installedModule) { $installedModule } else { $availableModule }
if ($null -eq $wingetModule) {
$status.WinGetStatus = "$moduleName module is not installed."
WriteLog $status.WinGetStatus
return $status
}
$status.ModuleInstalled = $true
$status.ModuleVersion = $wingetModule.Version.ToString()
$status.ModuleVersionObject = [version]$wingetModule.Version
$status.ModuleNeedsUpdate = $status.ModuleVersionObject -lt $MinimumVersion
WriteLog "$moduleName module version detected: $($status.ModuleVersion)"
Import-Module -Name $moduleName -Force -ErrorAction Stop
$wingetVersionCommand = Get-Command -Name Get-WinGetVersion -ErrorAction SilentlyContinue
if ($null -eq $wingetVersionCommand) {
$status.WinGetStatus = "Get-WinGetVersion cmdlet is not available."
$status.ErrorMessage = $status.WinGetStatus
WriteLog $status.WinGetStatus
return $status
}
$status.CmdletAvailable = $true
$wingetVersion = Get-WinGetVersion -ErrorAction Stop
$wingetVersionText = [string]$wingetVersion
WriteLog "Get-WinGetVersion returned: $wingetVersionText"
if ([string]::IsNullOrWhiteSpace($wingetVersionText)) {
$status.WinGetVersion = "Not installed"
$status.WinGetStatus = "WinGet is not installed."
WriteLog $status.WinGetStatus
return $status
}
if ($wingetVersionText -match 'v?(\d+\.\d+\.\d+)') {
$parsedVersion = [version]$matches[1]
$status.WinGetInstalled = $true
$status.WinGetVersion = $parsedVersion.ToString()
$status.WinGetVersionObject = $parsedVersion
$status.WinGetNeedsUpdate = $parsedVersion -lt $MinimumVersion
$status.WinGetStatus = if ($status.WinGetNeedsUpdate) { "Update required" } else { $parsedVersion.ToString() }
$status.NeedsUpdate = $status.ModuleNeedsUpdate -or $status.WinGetNeedsUpdate
$status.Success = -not $status.NeedsUpdate
return $status
}
$status.WinGetStatus = "Version check failed."
$status.ErrorMessage = "Could not parse Get-WinGetVersion output: $wingetVersionText"
WriteLog $status.ErrorMessage
return $status
}
catch {
$status.ErrorMessage = $_.Exception.Message
$status.WinGetStatus = "Get-WinGetVersion failed."
WriteLog "Get-WinGetVersion failed: $($status.ErrorMessage)"
return $status
}
}
function Confirm-WinGetInstallation { function Confirm-WinGetInstallation {
[CmdletBinding()] [CmdletBinding()]
param( param(
@@ -1027,12 +1120,11 @@ function Confirm-WinGetInstallation {
WriteLog 'Checking if WinGet is installed...' WriteLog 'Checking if WinGet is installed...'
$minVersion = [version]"1.8.1911" $minVersion = [version]"1.8.1911"
$wingetStatus = Get-WinGetComponentStatus -MinimumVersion $minVersion
# Check WinGet PowerShell module # Check WinGet PowerShell module
$wingetModule = Get-InstalledModule -Name Microsoft.Winget.Client -ErrorAction SilentlyContinue if ($wingetStatus.ModuleNeedsUpdate) {
$wingetModuleVersion = [version]$wingetModule.Version WriteLog 'Microsoft.WinGet.Client module is not installed or is an older version. Installing the latest version...'
if ($wingetModuleVersion -lt $minVersion -or -not $wingetModule) {
WriteLog 'Microsoft.Winget.Client module is not installed or is an older version. Installing the latest version...'
# Handle PSGallery trust settings # Handle PSGallery trust settings
$PSGalleryTrust = (Get-PSRepository -Name 'PSGallery').InstallationPolicy $PSGalleryTrust = (Get-PSRepository -Name 'PSGallery').InstallationPolicy
@@ -1041,30 +1133,49 @@ function Confirm-WinGetInstallation {
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted Set-PSRepository -Name 'PSGallery' -InstallationPolicy Trusted
} }
Install-Module -Name Microsoft.Winget.Client -Force -Repository 'PSGallery' Install-Module -Name Microsoft.WinGet.Client -Force -Repository 'PSGallery'
if ($PSGalleryTrust -eq 'Untrusted') { if ($PSGalleryTrust -eq 'Untrusted') {
WriteLog 'Setting PSGallery back to untrusted repository...' WriteLog 'Setting PSGallery back to untrusted repository...'
Set-PSRepository -Name 'PSGallery' -InstallationPolicy Untrusted Set-PSRepository -Name 'PSGallery' -InstallationPolicy Untrusted
WriteLog 'Done' WriteLog 'Done'
} }
$wingetStatus = Get-WinGetComponentStatus -MinimumVersion $minVersion
} }
else { else {
WriteLog "Installed Microsoft.Winget.Client module version: $($wingetModule.Version)" WriteLog "Installed Microsoft.WinGet.Client module version: $($wingetStatus.ModuleVersion)"
} }
if ($wingetStatus.ModuleNeedsUpdate) {
$message = "Microsoft.WinGet.Client module version $($wingetStatus.ModuleVersion) does not meet the minimum required version $minVersion."
WriteLog $message
throw $message
}
if (-not $wingetStatus.CmdletAvailable) {
$message = "Get-WinGetVersion cmdlet is not available from Microsoft.WinGet.Client. $($wingetStatus.ErrorMessage)"
WriteLog $message
throw $message
}
if (-not [string]::IsNullOrWhiteSpace($wingetStatus.ErrorMessage)) {
$message = "Unable to determine WinGet version by using Get-WinGetVersion. $($wingetStatus.ErrorMessage)"
WriteLog $message
throw $message
}
# Check WinGet CLI # Check WinGet CLI
$wingetVersion = Get-WinGetVersion if (-not $wingetStatus.WinGetInstalled) {
if (-not $wingetVersion) {
WriteLog "WinGet is not installed. Installing WinGet..." WriteLog "WinGet is not installed. Installing WinGet..."
Install-WinGet -Architecture $WindowsArch Install-WinGet -Architecture $WindowsArch
} }
elseif ($wingetVersion -match 'v?(\d+\.\d+\.\d+)' -and [version]$matches[1] -lt $minVersion) { elseif ($wingetStatus.WinGetNeedsUpdate) {
WriteLog "The installed version of WinGet $($matches[1]) does not support downloading MSStore apps. Installing the latest version of WinGet..." WriteLog "The installed version of WinGet $($wingetStatus.WinGetVersion) does not support downloading MSStore apps. Installing the latest version of WinGet..."
Install-WinGet -Architecture $WindowsArch Install-WinGet -Architecture $WindowsArch
} }
else { else {
WriteLog "Installed WinGet version: $wingetVersion" WriteLog "Installed WinGet version: $($wingetStatus.WinGetVersion)"
} }
} }
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -1556,4 +1667,4 @@ function Add-Win32SilentInstallCommand {
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Export functions needed by both BuildFFUVM and the UI Core module # Export functions needed by both BuildFFUVM and the UI Core module
Export-ModuleMember -Function Get-Application, Get-Apps, Start-WingetAppDownloadTask, Confirm-WinGetInstallation, Add-Win32SilentInstallCommand, Install-Winget Export-ModuleMember -Function Get-Application, Get-Apps, Start-WingetAppDownloadTask, Confirm-WinGetInstallation, Get-WinGetComponentStatus, Add-Win32SilentInstallCommand, Install-Winget
@@ -27,6 +27,22 @@ function Update-BYOAppsActionButtonsState {
} }
} }
# Function to resolve the configured BYO app list path
function Get-BYOApplicationListPath {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[psobject]$State
)
# Fall back to the legacy default path when the textbox is empty.
if (-not [string]::IsNullOrWhiteSpace($State.Controls.txtUserAppListPath.Text)) {
return $State.Controls.txtUserAppListPath.Text
}
return (Join-Path -Path $State.Controls.txtApplicationPath.Text -ChildPath 'UserAppList.json')
}
# Function to remove all selected BYO applications # Function to remove all selected BYO applications
function Remove-SelectedBYOApplications { function Remove-SelectedBYOApplications {
[CmdletBinding()] [CmdletBinding()]
@@ -76,10 +92,10 @@ function Remove-SelectedBYOApplications {
} }
# Ask user if they want to save the changes # Ask user if they want to save the changes
$result = [System.Windows.MessageBox]::Show("The selected applications have been removed from the list. Do you want to save these changes to UserAppList.json now?", "Save Changes", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question) $result = [System.Windows.MessageBox]::Show("The selected applications have been removed from the list. Do you want to save these changes to the configured BYO app list now?", "Save Changes", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question)
if ($result -eq 'Yes') { if ($result -eq 'Yes') {
$userAppListPath = Join-Path -Path $State.Controls.txtApplicationPath.Text -ChildPath 'UserAppList.json' $userAppListPath = Get-BYOApplicationListPath -State $State
Save-BYOApplicationList -Path $userAppListPath -State $State Save-BYOApplicationList -Path $userAppListPath -State $State
} }
} }
@@ -166,6 +182,7 @@ function Add-BYOApplication {
# Refresh the ListView to show the changes # Refresh the ListView to show the changes
$listView.Items.Refresh() $listView.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $listView
# Reset state # Reset state
$State.Data.editingBYOApplication = $null $State.Data.editingBYOApplication = $null
@@ -196,6 +213,7 @@ function Add-BYOApplication {
CopyStatus = "" CopyStatus = ""
} }
$listView.Items.Add($application) $listView.Items.Add($application)
Request-ListViewColumnAutoResize -ListView $listView
} }
# Clear form and update button states for both add and update operations # Clear form and update button states for both add and update operations
@@ -269,6 +287,7 @@ function Add-AppsScriptVariable {
} }
$State.Data.appsScriptVariablesDataList.Add($newItem) $State.Data.appsScriptVariablesDataList.Add($newItem)
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray() $State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables
$State.Controls.txtAppsScriptKey.Clear() $State.Controls.txtAppsScriptKey.Clear()
$State.Controls.txtAppsScriptValue.Clear() $State.Controls.txtAppsScriptValue.Clear()
# Update the header checkbox state # Update the header checkbox state
@@ -295,6 +314,7 @@ function Remove-SelectedAppsScriptVariable {
$State.Data.appsScriptVariablesDataList.Remove($itemToRemove) $State.Data.appsScriptVariablesDataList.Remove($itemToRemove)
} }
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray() $State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables
# Update the header checkbox state # Update the header checkbox state
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) { if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
@@ -391,18 +411,24 @@ function Invoke-CopyBYOApps {
) )
$localAppsPath = $State.Controls.txtApplicationPath.Text $localAppsPath = $State.Controls.txtApplicationPath.Text
$userAppListPath = Join-Path -Path $localAppsPath -ChildPath 'UserAppList.json' $userAppListPath = Get-BYOApplicationListPath -State $State
$listView = $State.Controls.lstApplications $listView = $State.Controls.lstApplications
try { try {
# Ensure items are sorted by current priority before saving # Ensure the configured BYO app list folder exists before writing the manifest.
# Exclude CopyStatus when saving and ensure Priority is an integer; include AdditionalExitCodes and IgnoreNonZeroExitCodes for parity with Save-BYOApplicationList $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 = $listView.Items | Sort-Object Priority | Select-Object @{N = 'Priority'; E = { [int]$_.Priority } }, Name, CommandLine, Arguments, Source, AdditionalExitCodes, IgnoreNonZeroExitCodes
$applications | ConvertTo-Json -Depth 5 | Set-Content -Path $userAppListPath -Force -Encoding UTF8 $applications | ConvertTo-Json -Depth 5 | Set-Content -Path $userAppListPath -Force -Encoding UTF8
WriteLog "Successfully updated UserAppList.json with all applications from the UI." WriteLog "Successfully updated BYO app list at $userAppListPath with all applications from the UI."
} }
catch { catch {
$errorMessage = "Failed to update UserAppList.json: $_" $errorMessage = "Failed to update BYO app list at $($userAppListPath): $_"
WriteLog $errorMessage WriteLog $errorMessage
[System.Windows.MessageBox]::Show($errorMessage, "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error) [System.Windows.MessageBox]::Show($errorMessage, "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error)
return return
@@ -25,7 +25,6 @@ function Get-UIConfig {
else { $null } else { $null }
BuildUSBDrive = $State.Controls.chkBuildUSBDriveEnable.IsChecked BuildUSBDrive = $State.Controls.chkBuildUSBDriveEnable.IsChecked
CleanupAppsISO = $State.Controls.chkCleanupAppsISO.IsChecked CleanupAppsISO = $State.Controls.chkCleanupAppsISO.IsChecked
CleanupCaptureISO = $State.Controls.chkCleanupCaptureISO.IsChecked
CleanupDeployISO = $State.Controls.chkCleanupDeployISO.IsChecked CleanupDeployISO = $State.Controls.chkCleanupDeployISO.IsChecked
CleanupDrivers = $State.Controls.chkCleanupDrivers.IsChecked CleanupDrivers = $State.Controls.chkCleanupDrivers.IsChecked
CompactOS = $State.Controls.chkCompactOS.IsChecked CompactOS = $State.Controls.chkCompactOS.IsChecked
@@ -37,15 +36,23 @@ function Get-UIConfig {
UseDriversAsPEDrivers = $State.Controls.chkUseDriversAsPEDrivers.IsChecked UseDriversAsPEDrivers = $State.Controls.chkUseDriversAsPEDrivers.IsChecked
CopyPPKG = $State.Controls.chkCopyPPKG.IsChecked CopyPPKG = $State.Controls.chkCopyPPKG.IsChecked
CopyUnattend = $State.Controls.chkCopyUnattend.IsChecked CopyUnattend = $State.Controls.chkCopyUnattend.IsChecked
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 CopyAdditionalFFUFiles = $State.Controls.chkCopyAdditionalFFUFiles.IsChecked
CreateCaptureMedia = $State.Controls.chkCreateCaptureMedia.IsChecked
CreateDeploymentMedia = $State.Controls.chkCreateDeploymentMedia.IsChecked CreateDeploymentMedia = $State.Controls.chkCreateDeploymentMedia.IsChecked
InjectUnattend = $State.Controls.chkInjectUnattend.IsChecked InjectUnattend = $State.Controls.chkInjectUnattend.IsChecked
UnattendX64FilePath = $State.Controls.txtUnattendX64FilePath.Text
UnattendArm64FilePath = $State.Controls.txtUnattendArm64FilePath.Text
CustomFFUNameTemplate = $State.Controls.txtCustomFFUNameTemplate.Text CustomFFUNameTemplate = $State.Controls.txtCustomFFUNameTemplate.Text
Disksize = [int64]$State.Controls.txtDiskSize.Text * 1GB Disksize = [int64]$State.Controls.txtDiskSize.Text * 1GB
DownloadDrivers = $State.Controls.chkDownloadDrivers.IsChecked DownloadDrivers = $State.Controls.chkDownloadDrivers.IsChecked
DriversFolder = $State.Controls.txtDriversFolder.Text DriversFolder = $State.Controls.txtDriversFolder.Text
DriversJsonPath = $State.Controls.txtDriversJsonPath.Text DriversJsonPath = $State.Controls.txtDriversJsonPath.Text
EnableVMNetworking = $State.Controls.chkEnableVMNetworking.IsChecked
FFUCaptureLocation = $State.Controls.txtFFUCaptureLocation.Text FFUCaptureLocation = $State.Controls.txtFFUCaptureLocation.Text
FFUDevelopmentPath = $State.Controls.txtFFUDevPath.Text FFUDevelopmentPath = $State.Controls.txtFFUDevPath.Text
FFUPrefix = $State.Controls.txtVMNamePrefix.Text FFUPrefix = $State.Controls.txtVMNamePrefix.Text
@@ -54,6 +61,10 @@ function Get-UIConfig {
InstallOffice = $State.Controls.chkInstallOffice.IsChecked InstallOffice = $State.Controls.chkInstallOffice.IsChecked
InstallWingetApps = $State.Controls.chkInstallWingetApps.IsChecked InstallWingetApps = $State.Controls.chkInstallWingetApps.IsChecked
ISOPath = $State.Controls.txtISOPath.Text ISOPath = $State.Controls.txtISOPath.Text
WindowsMediaSource = if ($null -ne $State.Controls.rbProvideISO -and $State.Controls.rbProvideISO.IsChecked) { "Provide Windows ISO" } else { "Download Windows ESD" }
SystemPartitionDriveLetter = $State.Controls.cmbSystemPartitionDriveLetter.SelectedItem.Content
WindowsPartitionDriveLetter = $State.Controls.cmbWindowsPartitionDriveLetter.SelectedItem.Content
RecoveryPartitionDriveLetter = $State.Controls.cmbRecoveryPartitionDriveLetter.SelectedItem.Content
LogicalSectorSizeBytes = [int]$State.Controls.cmbLogicalSectorSize.SelectedItem.Content LogicalSectorSizeBytes = [int]$State.Controls.cmbLogicalSectorSize.SelectedItem.Content
# Make = $null # Make = $null
MediaType = $State.Controls.cmbMediaType.SelectedItem MediaType = $State.Controls.cmbMediaType.SelectedItem
@@ -82,7 +93,7 @@ function Get-UIConfig {
RemoveApps = $State.Controls.chkRemoveApps.IsChecked RemoveApps = $State.Controls.chkRemoveApps.IsChecked
RemoveFFU = $State.Controls.chkRemoveFFU.IsChecked RemoveFFU = $State.Controls.chkRemoveFFU.IsChecked
RemoveUpdates = $State.Controls.chkRemoveUpdates.IsChecked RemoveUpdates = $State.Controls.chkRemoveUpdates.IsChecked
ShareName = $State.Controls.txtShareName.Text RemoveDownloadedESD = $State.Controls.chkRemoveDownloadedESD.IsChecked
UpdateADK = $State.Controls.chkUpdateADK.IsChecked UpdateADK = $State.Controls.chkUpdateADK.IsChecked
UpdateEdge = $State.Controls.chkUpdateEdge.IsChecked UpdateEdge = $State.Controls.chkUpdateEdge.IsChecked
UpdateLatestCU = $State.Controls.chkUpdateLatestCU.IsChecked UpdateLatestCU = $State.Controls.chkUpdateLatestCU.IsChecked
@@ -92,14 +103,13 @@ function Get-UIConfig {
UpdateLatestNet = $State.Controls.chkUpdateLatestNet.IsChecked UpdateLatestNet = $State.Controls.chkUpdateLatestNet.IsChecked
UpdateOneDrive = $State.Controls.chkUpdateOneDrive.IsChecked UpdateOneDrive = $State.Controls.chkUpdateOneDrive.IsChecked
UpdatePreviewCU = $State.Controls.chkUpdatePreviewCU.IsChecked UpdatePreviewCU = $State.Controls.chkUpdatePreviewCU.IsChecked
UserAppListPath = "$($State.Controls.txtApplicationPath.Text)\UserAppList.json" UserAppListPath = $State.Controls.txtUserAppListPath.Text
USBDriveList = @{} USBDriveList = @{}
Username = $State.Controls.txtUsername.Text
Threads = [int]$State.Controls.txtThreads.Text Threads = [int]$State.Controls.txtThreads.Text
BitsPriority = $State.Controls.cmbBitsPriority.SelectedItem BitsPriority = $State.Controls.cmbBitsPriority.SelectedItem
MaxUSBDrives = [int]$State.Controls.txtMaxUSBDrives.Text MaxUSBDrives = [int]$State.Controls.txtMaxUSBDrives.Text
ThemeMode = if ($null -ne $State.Controls.cmbThemeMode -and $null -ne $State.Controls.cmbThemeMode.SelectedItem) { $State.Controls.cmbThemeMode.SelectedItem } else { "System" }
Verbose = $State.Controls.chkVerbose.IsChecked Verbose = $State.Controls.chkVerbose.IsChecked
VMHostIPAddress = $State.Controls.txtVMHostIPAddress.Text
VMLocation = $State.Controls.txtVMLocation.Text VMLocation = $State.Controls.txtVMLocation.Text
VMSwitchName = if ($State.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') { VMSwitchName = if ($State.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') {
$State.Controls.txtCustomVMSwitchName.Text $State.Controls.txtCustomVMSwitchName.Text
@@ -411,7 +421,6 @@ function Select-VMSwitchFromConfig {
$State.Controls.txtCustomVMSwitchName.Visibility = 'Visible' $State.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
$State.Controls.txtCustomVMSwitchName.Text = $configSwitch $State.Controls.txtCustomVMSwitchName.Text = $configSwitch
$State.Data.customVMSwitchName = $configSwitch $State.Data.customVMSwitchName = $configSwitch
$State.Data.customVMHostIP = $ConfigContent.VMHostIPAddress
WriteLog "LoadConfig: VMSwitchName '$configSwitch' not found. Selected 'Other' and populated custom VM Switch Name textbox." WriteLog "LoadConfig: VMSwitchName '$configSwitch' not found. Selected 'Other' and populated custom VM Switch Name textbox."
} }
} }
@@ -426,12 +435,19 @@ function Update-UIFromConfig {
WriteLog "Applying loaded configuration to the UI." WriteLog "Applying loaded configuration to the UI."
# Apply theme mode from config (must be done before other controls load for proper styling)
if ($null -ne $ConfigContent.PSObject.Properties.Item('ThemeMode') -and $State.Flags.isFluentSupported) {
$configTheme = $ConfigContent.ThemeMode
if ($configTheme -in @("Light", "Dark", "System")) {
Initialize-FluentTheme -Window $State.Window -ThemeMode $configTheme -State $State
Set-UIValue -ControlName 'cmbThemeMode' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'ThemeMode' -State $State
}
}
# Update Build tab values # Update Build tab values
Set-UIValue -ControlName 'txtFFUDevPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUDevelopmentPath' -State $State Set-UIValue -ControlName 'txtFFUDevPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUDevelopmentPath' -State $State
Set-UIValue -ControlName 'txtCustomFFUNameTemplate' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'CustomFFUNameTemplate' -State $State Set-UIValue -ControlName 'txtCustomFFUNameTemplate' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'CustomFFUNameTemplate' -State $State
Set-UIValue -ControlName 'txtFFUCaptureLocation' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUCaptureLocation' -State $State Set-UIValue -ControlName 'txtFFUCaptureLocation' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUCaptureLocation' -State $State
Set-UIValue -ControlName 'txtShareName' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ShareName' -State $State
Set-UIValue -ControlName 'txtUsername' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Username' -State $State
Set-UIValue -ControlName 'txtThreads' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Threads' -State $State Set-UIValue -ControlName 'txtThreads' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Threads' -State $State
Set-UIValue -ControlName 'cmbBitsPriority' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'BitsPriority' -State $State Set-UIValue -ControlName 'cmbBitsPriority' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'BitsPriority' -State $State
Set-UIValue -ControlName 'txtMaxUSBDrives' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'MaxUSBDrives' -State $State Set-UIValue -ControlName 'txtMaxUSBDrives' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'MaxUSBDrives' -State $State
@@ -443,37 +459,93 @@ function Update-UIFromConfig {
Set-UIValue -ControlName 'chkAllowExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'AllowExternalHardDiskMedia' -State $State Set-UIValue -ControlName 'chkAllowExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'AllowExternalHardDiskMedia' -State $State
Set-UIValue -ControlName 'chkPromptExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'PromptExternalHardDiskMedia' -State $State Set-UIValue -ControlName 'chkPromptExternalHardDiskMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'PromptExternalHardDiskMedia' -State $State
Set-UIValue -ControlName 'chkCopyAdditionalFFUFiles' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyAdditionalFFUFiles' -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 'chkCreateDeploymentMedia' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CreateDeploymentMedia' -State $State
Set-UIValue -ControlName 'chkInjectUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'InjectUnattend' -State $State Set-UIValue -ControlName 'chkInjectUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'InjectUnattend' -State $State
Set-UIValue -ControlName 'txtUnattendX64FilePath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'UnattendX64FilePath' -State $State
Set-UIValue -ControlName 'txtUnattendArm64FilePath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'UnattendArm64FilePath' -State $State
Set-UIValue -ControlName 'chkVerbose' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'Verbose' -State $State Set-UIValue -ControlName 'chkVerbose' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'Verbose' -State $State
if ([string]::IsNullOrWhiteSpace($State.Controls.txtUnattendX64FilePath.Text)) {
$State.Controls.txtUnattendX64FilePath.Text = Get-DefaultUnattendFilePath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text -WindowsArch 'x64'
}
if ([string]::IsNullOrWhiteSpace($State.Controls.txtUnattendArm64FilePath.Text)) {
$State.Controls.txtUnattendArm64FilePath.Text = Get-DefaultUnattendFilePath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text -WindowsArch 'arm64'
}
# USB Drive Modification group (Build Tab) # USB Drive Modification group (Build Tab)
Set-UIValue -ControlName 'chkCopyAutopilot' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyAutopilot' -State $State Set-UIValue -ControlName 'chkCopyAutopilot' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyAutopilot' -State $State
Set-UIValue -ControlName 'chkCopyUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyUnattend' -State $State Set-UIValue -ControlName 'chkCopyUnattend' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyUnattend' -State $State
Set-UIValue -ControlName 'chkCopyPPKG' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyPPKG' -State $State Set-UIValue -ControlName 'chkCopyPPKG' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CopyPPKG' -State $State
Set-UIValue -ControlName 'txtDeviceNamePrefixesPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNamePrefixesPath' -State $State
Set-UIValue -ControlName 'txtDeviceNameSerialComputerNamesPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNameSerialComputerNamesPath' -State $State
Set-UIValue -ControlName 'txtDeviceNameTemplate' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNameTemplate' -State $State
Set-UIValue -ControlName 'txtDeviceNamePrefixes' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNamePrefixes' -TransformValue { param($val) if ($val -is [System.Array]) { $val -join [System.Environment]::NewLine } else { [string]$val } } -State $State
Set-UIValue -ControlName 'txtDeviceNameSerialComputerNames' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'DeviceNameSerialComputerNames' -TransformValue { param($val) if ($val -is [System.Array]) { $val -join [System.Environment]::NewLine } else { [string]$val } } -State $State
if ([string]::IsNullOrWhiteSpace($State.Controls.txtDeviceNamePrefixesPath.Text)) {
$State.Controls.txtDeviceNamePrefixesPath.Text = Get-DefaultDeviceNamePrefixesPath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text
}
if ([string]::IsNullOrWhiteSpace($State.Controls.txtDeviceNameSerialComputerNamesPath.Text)) {
$State.Controls.txtDeviceNameSerialComputerNamesPath.Text = Get-DefaultSerialComputerNamesPath -FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text
}
$loadedDeviceNamingMode = $null
if ($ConfigContent.PSObject.Properties.Name -contains 'DeviceNamingMode') {
$candidateDeviceNamingMode = [string]$ConfigContent.DeviceNamingMode
if ($candidateDeviceNamingMode -in @('Legacy', 'None', 'Prompt', 'Template', 'Prefixes', 'SerialComputerNames')) {
$loadedDeviceNamingMode = $candidateDeviceNamingMode
}
}
$displayDeviceNamingMode = if ($loadedDeviceNamingMode -in @('Prompt', 'Template', 'Prefixes', 'SerialComputerNames')) {
$loadedDeviceNamingMode
}
else {
'None'
}
Set-DeviceNamingModeState -State $State -DisplayMode $displayDeviceNamingMode -LoadedMode $loadedDeviceNamingMode
Import-DeviceNamePrefixesFromConfiguredPath -State $State
Import-SerialComputerNamesFromConfiguredPath -State $State
Update-DeviceNamingControls -State $State
# Post Build Cleanup group (Build Tab) # Post Build Cleanup group (Build Tab)
Set-UIValue -ControlName 'chkCleanupAppsISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupAppsISO' -State $State Set-UIValue -ControlName 'chkCleanupAppsISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupAppsISO' -State $State
Set-UIValue -ControlName 'chkCleanupCaptureISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupCaptureISO' -State $State
Set-UIValue -ControlName 'chkCleanupDeployISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupDeployISO' -State $State Set-UIValue -ControlName 'chkCleanupDeployISO' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupDeployISO' -State $State
Set-UIValue -ControlName 'chkCleanupDrivers' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupDrivers' -State $State Set-UIValue -ControlName 'chkCleanupDrivers' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'CleanupDrivers' -State $State
Set-UIValue -ControlName 'chkRemoveFFU' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveFFU' -State $State Set-UIValue -ControlName 'chkRemoveFFU' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveFFU' -State $State
Set-UIValue -ControlName 'chkRemoveApps' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveApps' -State $State Set-UIValue -ControlName 'chkRemoveApps' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveApps' -State $State
Set-UIValue -ControlName 'chkRemoveUpdates' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveUpdates' -State $State Set-UIValue -ControlName 'chkRemoveUpdates' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveUpdates' -State $State
Set-UIValue -ControlName 'chkRemoveDownloadedESD' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'RemoveDownloadedESD' -State $State
# Hyper-V Settings # Hyper-V Settings
Set-UIValue -ControlName 'chkEnableVMNetworking' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'EnableVMNetworking' -State $State
Select-VMSwitchFromConfig -State $State -ConfigContent $ConfigContent Select-VMSwitchFromConfig -State $State -ConfigContent $ConfigContent
Set-UIValue -ControlName 'txtVMHostIPAddress' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'VMHostIPAddress' -State $State
Set-UIValue -ControlName 'txtDiskSize' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Disksize' -TransformValue { param($val) $val / 1GB } -State $State Set-UIValue -ControlName 'txtDiskSize' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Disksize' -TransformValue { param($val) $val / 1GB } -State $State
Set-UIValue -ControlName 'txtMemory' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Memory' -TransformValue { param($val) $val / 1GB } -State $State Set-UIValue -ControlName 'txtMemory' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Memory' -TransformValue { param($val) $val / 1GB } -State $State
Set-UIValue -ControlName 'txtProcessors' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Processors' -State $State Set-UIValue -ControlName 'txtProcessors' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'Processors' -State $State
Set-UIValue -ControlName 'txtVMLocation' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'VMLocation' -State $State Set-UIValue -ControlName 'txtVMLocation' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'VMLocation' -State $State
Set-UIValue -ControlName 'txtVMNamePrefix' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUPrefix' -State $State Set-UIValue -ControlName 'txtVMNamePrefix' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'FFUPrefix' -State $State
Set-UIValue -ControlName 'cmbSystemPartitionDriveLetter' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'SystemPartitionDriveLetter' -TransformValue { param($val) ([string]$val).Trim().TrimEnd(':').ToUpperInvariant() } -State $State
Set-UIValue -ControlName 'cmbWindowsPartitionDriveLetter' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'WindowsPartitionDriveLetter' -TransformValue { param($val) ([string]$val).Trim().TrimEnd(':').ToUpperInvariant() } -State $State
Set-UIValue -ControlName 'cmbRecoveryPartitionDriveLetter' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'RecoveryPartitionDriveLetter' -TransformValue { param($val) ([string]$val).Trim().TrimEnd(':').ToUpperInvariant() } -State $State
Set-UIValue -ControlName 'cmbLogicalSectorSize' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'LogicalSectorSizeBytes' -TransformValue { param($val) $val.ToString() } -State $State Set-UIValue -ControlName 'cmbLogicalSectorSize' -PropertyName 'SelectedItem' -ConfigObject $ConfigContent -ConfigKey 'LogicalSectorSizeBytes' -TransformValue { param($val) $val.ToString() } -State $State
$State.Controls.spVMNetworkingSettings.IsEnabled = $true -eq $State.Controls.chkEnableVMNetworking.IsChecked
if (-not ($true -eq $State.Controls.chkEnableVMNetworking.IsChecked)) {
$State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
}
# Windows Settings # Windows Settings
Set-UIValue -ControlName 'txtISOPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ISOPath' -State $State Set-UIValue -ControlName 'txtISOPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'ISOPath' -State $State
# Load Windows Media Source setting
if ($null -ne $ConfigContent.PSObject.Properties.Item('WindowsMediaSource')) {
if ($ConfigContent.WindowsMediaSource -eq 'Provide Windows ISO') {
$State.Controls.rbProvideISO.IsChecked = $true
}
else {
$State.Controls.rbDownloadESD.IsChecked = $true
}
}
# Special handling for Windows Release and SKU due to value collision (e.g., 2019 for Server and LTSC) # Special handling for Windows Release and SKU due to value collision (e.g., 2019 for Server and LTSC)
if (($null -ne $ConfigContent.PSObject.Properties.Item('WindowsRelease')) -and ($null -ne $ConfigContent.PSObject.Properties.Item('WindowsSKU'))) { if (($null -ne $ConfigContent.PSObject.Properties.Item('WindowsRelease')) -and ($null -ne $ConfigContent.PSObject.Properties.Item('WindowsSKU'))) {
@@ -583,6 +655,7 @@ function Update-UIFromConfig {
Set-UIValue -ControlName 'chkBringYourOwnApps' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'BringYourOwnApps' -State $State Set-UIValue -ControlName 'chkBringYourOwnApps' -PropertyName 'IsChecked' -ConfigObject $ConfigContent -ConfigKey 'BringYourOwnApps' -State $State
Set-UIValue -ControlName 'txtApplicationPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'AppsPath' -State $State Set-UIValue -ControlName 'txtApplicationPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'AppsPath' -State $State
Set-UIValue -ControlName 'txtAppListJsonPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'AppListPath' -State $State Set-UIValue -ControlName 'txtAppListJsonPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'AppListPath' -State $State
Set-UIValue -ControlName 'txtUserAppListPath' -PropertyName 'Text' -ConfigObject $ConfigContent -ConfigKey 'UserAppListPath' -State $State
# Handle AppsScriptVariables # Handle AppsScriptVariables
$appsScriptVarsKeyExists = $false $appsScriptVarsKeyExists = $false
@@ -646,6 +719,7 @@ function Update-UIFromConfig {
} }
# Update the ListView's ItemsSource after populating the data list # Update the ListView's ItemsSource after populating the data list
$lstAppsScriptVars.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray() $lstAppsScriptVars.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
Request-ListViewColumnAutoResize -ListView $lstAppsScriptVars
# Update the header checkbox state # Update the header checkbox state
if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) { if ($null -ne $State.Controls.chkSelectAllAppsScriptVariables) {
Update-SelectAllHeaderCheckBoxState -ListView $lstAppsScriptVars -HeaderCheckBox $State.Controls.chkSelectAllAppsScriptVariables Update-SelectAllHeaderCheckBoxState -ListView $lstAppsScriptVars -HeaderCheckBox $State.Controls.chkSelectAllAppsScriptVariables
@@ -714,6 +788,7 @@ function Update-UIFromConfig {
} }
} }
$State.Controls.lstUSBDrives.Items.Refresh() $State.Controls.lstUSBDrives.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstUSBDrives
# Update the Select All header checkbox state # Update the Select All header checkbox state
$headerChk = $State.Controls.chkSelectAllUSBDrivesHeader $headerChk = $State.Controls.chkSelectAllUSBDrivesHeader
@@ -774,6 +849,7 @@ function Update-UIFromConfig {
} }
} }
$State.Controls.lstAdditionalFFUs.Items.Refresh() $State.Controls.lstAdditionalFFUs.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstAdditionalFFUs
$headerChk = $State.Controls.chkSelectAllAdditionalFFUs $headerChk = $State.Controls.chkSelectAllAdditionalFFUs
if ($null -ne $headerChk) { if ($null -ne $headerChk) {
Update-SelectAllHeaderCheckBoxState -ListView $State.Controls.lstAdditionalFFUs -HeaderCheckBox $headerChk Update-SelectAllHeaderCheckBoxState -ListView $State.Controls.lstAdditionalFFUs -HeaderCheckBox $headerChk
@@ -833,7 +909,7 @@ function Invoke-RestoreDefaults {
$rootPath = $State.FFUDevelopmentPath $rootPath = $State.FFUDevelopmentPath
# Normalize potential array values to single strings # Normalize potential array values to single strings
function Normalize-PathScalar { function Get-PathScalar {
param([object]$value) param([object]$value)
if ($null -eq $value) { return $null } if ($null -eq $value) { return $null }
if ($value -is [System.Array]) { if ($value -is [System.Array]) {
@@ -848,21 +924,20 @@ function Invoke-RestoreDefaults {
} }
$appsPath = Join-Path $rootPath 'Apps' $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)) { if ([string]::IsNullOrWhiteSpace($driversRaw)) {
$driversPath = Join-Path $rootPath 'Drivers' $driversPath = Join-Path $rootPath 'Drivers'
} }
else { else {
$driversPath = $driversRaw $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 } $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' $deployISOPath = Join-Path $rootPath 'WinPEDeployFFUFiles\WinPE-Deploy.iso'
$appsISOPath = Join-Path $rootPath 'Apps.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") $result = [System.Windows.MessageBox]::Show($msg, "Confirm Restore Defaults", "YesNo", "Warning")
if ($result -ne [System.Windows.MessageBoxResult]::Yes) { if ($result -ne [System.Windows.MessageBoxResult]::Yes) {
WriteLog "RestoreDefaults: User cancelled." WriteLog "RestoreDefaults: User cancelled."
@@ -898,17 +973,16 @@ function Invoke-RestoreDefaults {
-AppsPath $appsPath ` -AppsPath $appsPath `
-DriversPath $driversPath ` -DriversPath $driversPath `
-FFUCapturePath $ffuCapturePath ` -FFUCapturePath $ffuCapturePath `
-CaptureISOPath $captureISOPath `
-DeployISOPath $deployISOPath ` -DeployISOPath $deployISOPath `
-AppsISOPath $appsISOPath ` -AppsISOPath $appsISOPath `
-KBPath (Join-Path $rootPath 'KB') ` -KBPath (Join-Path $rootPath 'KB') `
-RemoveCaptureISO:$true `
-RemoveDeployISO:$true ` -RemoveDeployISO:$true `
-RemoveAppsISO:$true ` -RemoveAppsISO:$true `
-RemoveDrivers:$true ` -RemoveDrivers:$true `
-RemoveFFU:$true ` -RemoveFFU:$true `
-RemoveApps:$true ` -RemoveApps:$true `
-RemoveUpdates:$true -RemoveUpdates:$true `
-RemoveDownloadedESD:$true
# Clear UI lists / state # Clear UI lists / state
if ($null -ne $State.Data.allDriverModels) { $State.Data.allDriverModels.Clear() } if ($null -ne $State.Data.allDriverModels) { $State.Data.allDriverModels.Clear() }
@@ -1036,6 +1110,7 @@ function Import-ConfigSupplementalAssets {
}) })
} }
$State.Controls.lstWingetResults.ItemsSource = $appsBuffer.ToArray() $State.Controls.lstWingetResults.ItemsSource = $appsBuffer.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
$loadedWinget = $true $loadedWinget = $true
if ($null -ne $State.Controls.wingetSearchPanel) { if ($null -ne $State.Controls.wingetSearchPanel) {
$State.Controls.wingetSearchPanel.Visibility = 'Visible' $State.Controls.wingetSearchPanel.Visibility = 'Visible'
@@ -1170,6 +1245,7 @@ function Import-ConfigSupplementalAssets {
} }
} }
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels $State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
if (Get-Command -Name Update-SelectAllHeaderCheckBoxState -ErrorAction SilentlyContinue) { if (Get-Command -Name Update-SelectAllHeaderCheckBoxState -ErrorAction SilentlyContinue) {
$headerChk = $State.Controls.chkSelectAllDriverModels $headerChk = $State.Controls.chkSelectAllDriverModels
if ($null -ne $headerChk) { if ($null -ne $headerChk) {
@@ -212,6 +212,14 @@ function Save-DellDriversTask {
Remove-Item $modelCabPath -Force -ErrorAction SilentlyContinue Remove-Item $modelCabPath -Force -ErrorAction SilentlyContinue
if (-not (Test-Path $modelXmlPath)) { throw "Model XML not found after extraction: $modelXmlPath" } 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...' } if ($null -ne $ProgressQueue) { Invoke-ProgressUpdate -ProgressQueue $ProgressQueue -Identifier $modelDisplay -Status 'Selecting latest drivers...' }
$packages = Get-DellLatestDriverPackages -ModelXmlPath $modelXmlPath -WindowsArch $WindowsArch -WindowsRelease $WindowsRelease $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. # If anyone knows how to reliably get the the model and machine type information from Lenovo, let me know.
# https://download.lenovo.com/cdrt/td/catalogv2.xml only provides a subset of the information available from PSREF (e.g. it's missing 300w, 500w, and other consumer models). # https://download.lenovo.com/cdrt/td/catalogv2.xml only provides a subset of the information available from PSREF (e.g. it's missing 300w, 500w, and other consumer models).
# $lenovoCookie = "X-PSREF-USER-TOKEN=eyJ0eXAiOiJKV1QifQ.bjVTdWk0YklZeUc2WnFzL0lXU0pTeU1JcFo0aExzRXl1UGxHN3lnS1BtckI0ZVU5WEJyVGkvaFE0NmVNU2U1ZjNrK3ZqTEVIZ29nTk1TNS9DQmIwQ0pTN1Q1VytlY1RpNzZTUldXbm4wZ1g2RGJuQWg4MXRkTmxKT2YrOW9LRjBzQUZzV05HM3NpcU92WFVTM0o0blM1SDQyUlVXNThIV1VBS2R0c1B2NjJyQjIrUGxNZ2x6RTRhUjY5UDZWclBX.ZDBmM2EyMWRjZTg2N2JmYWMxZDIxY2NiYjQzMWFhNjg1YjEzZTAxNmU2M2RmN2M5ZjIyZWJhMzZkOWI1OWJhZg" $lenovoCookie = "X-PSREF-USER-TOKEN=eyJ0eXAiOiJKV1QifQ.bjVTdWk0YklZeUc2WnFzL0lXU0pTeU1JcFo0aExzRXl1UGxHN3lnS1BtckI0ZVU5WEJyVGkvaFE0NmVNU2U1ZjNrK3ZqTEVIZ29nTk1TNS9DQmIwQ0pTN1Q1VytlY1RpNzZTUldXbm4wZ1g2RGJuQWg4MXRkTmxKT2YrOW9LRjBzQUZzV05HM3NpcU92WFVTM0o0blM1SDQyUlVXNThIV1VBS2R0c1B2NjJyQjIrUGxNZ2x6RTRhUjY5UDZWclBX.ZDBmM2EyMWRjZTg2N2JmYWMxZDIxY2NiYjQzMWFhNjg1YjEzZTAxNmU2M2RmN2M5ZjIyZWJhMzZkOWI1OWJhZg"
# Wrote a separate function to grab the token. Check the function notes for more details. Keep the above comment for now to see if the cookie ever changes. # Wrote a separate function to grab the token. Check the function notes for more details. Keep the above comment for now to see if the cookie ever changes.
$lenovoCookie = Get-LenovoPSREFToken # 3/25/2026 - The cookie is still the same after 8 months, but we'll keep the retrieval function in case it changes in the future or if we need to get a new one.
# $lenovoCookie = Get-LenovoPSREFToken
# Add the cookie to the headers # Add the cookie to the headers
$Headers["Cookie"] = $lenovoCookie $Headers["Cookie"] = $lenovoCookie
@@ -15,100 +15,8 @@ function Get-MicrosoftDriversModelList {
[string]$DriversFolder [string]$DriversFolder
) )
$url = "https://support.microsoft.com/en-us/surface/download-drivers-and-firmware-for-surface-09bb2e09-2a4b-cb69-0951-078a7739e120" # Keep the UI signature unchanged while using the shared Learn-based source.
$models = @() return @(Get-SurfaceDriverModelIndex -DriversFolder $DriversFolder)
# Load cached model list first (Source B) to keep the UI fast.
# The cache is refreshed automatically when missing or invalid.
try {
$cachePath = Get-SurfaceDriverIndexCachePath -DriversFolder $DriversFolder
if (Test-Path -Path $cachePath -PathType Leaf) {
$cacheAgeDays = ((Get-Date) - (Get-Item -Path $cachePath).LastWriteTime).TotalDays
if ($cacheAgeDays -lt 7) {
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
if ($cache.ModelIndex -and $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 online parse. Error: $($_.Exception.Message)"
}
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."
# Persist model list (Source B) into the local cache for fast UI population.
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 "Error getting Microsoft models: $($_.Exception.Message)"
throw "Failed to retrieve Microsoft Surface models."
}
} }
# Function to download and extract drivers for a specific Microsoft model (Modified for ForEach-Object -Parallel) # Function to download and extract drivers for a specific Microsoft model (Modified for ForEach-Object -Parallel)
function Save-MicrosoftDriversTask { function Save-MicrosoftDriversTask {
@@ -35,10 +35,41 @@ function Get-DriverDisplayName {
return $BaseName.Trim() return $BaseName.Trim()
} }
return "$($BaseName.Trim()) ($($Identifier.Trim()))" 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( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[pscustomobject]$DriverItem [pscustomobject]$DriverItem
@@ -154,8 +185,20 @@ function Convert-DriverItemToJsonModel {
# Get necessary values from UI or script scope # Get necessary values from UI or script scope
$localDriversFolder = $State.Controls.txtDriversFolder.Text $localDriversFolder = $State.Controls.txtDriversFolder.Text
$localWindowsRelease = $null $localWindowsRelease = $null
$localWindowsReleaseDisplay = $null
if ($null -ne $State.Controls.cmbWindowsRelease.SelectedItem) { if ($null -ne $State.Controls.cmbWindowsRelease.SelectedItem) {
$localWindowsRelease = $State.Controls.cmbWindowsRelease.SelectedItem.Value $localWindowsRelease = $State.Controls.cmbWindowsRelease.SelectedItem.Value
$localWindowsReleaseDisplay = $State.Controls.cmbWindowsRelease.SelectedItem.Display
}
# Resolve effective release used specifically for OEM driver operations.
$localWindowsSku = if ($null -ne $State.Controls.cmbWindowsSKU.SelectedItem) { [string]$State.Controls.cmbWindowsSKU.SelectedItem } else { $null }
$localDriverWindowsRelease = $localWindowsRelease
if ($null -ne $localWindowsRelease) {
$localDriverWindowsRelease = Get-EffectiveDriverWindowsRelease -WindowsRelease $localWindowsRelease -WindowsReleaseDisplay $localWindowsReleaseDisplay -WindowsSku $localWindowsSku
if ($localDriverWindowsRelease -ne $localWindowsRelease) {
WriteLog "Normalized WindowsRelease for model retrieval from $localWindowsRelease to $localDriverWindowsRelease (Display='$localWindowsReleaseDisplay', SKU='$localWindowsSku')."
}
} }
# Get headers and user agent from Get-CoreStaticVariables # Get headers and user agent from Get-CoreStaticVariables
@@ -173,7 +216,7 @@ function Convert-DriverItemToJsonModel {
$rawModels = Get-MicrosoftDriversModelList -Headers $Headers -UserAgent $UserAgent -DriversFolder $localDriversFolder $rawModels = Get-MicrosoftDriversModelList -Headers $Headers -UserAgent $UserAgent -DriversFolder $localDriversFolder
} }
'Dell' { 'Dell' {
$rawModels = Get-DellDriversModelList -WindowsRelease $localWindowsRelease -DriversFolder $localDriversFolder -Make $SelectedMake $rawModels = Get-DellDriversModelList -WindowsRelease $localDriverWindowsRelease -DriversFolder $localDriversFolder -Make $SelectedMake
} }
'HP' { 'HP' {
$rawModels = Get-HPDriversModelList -DriversFolder $localDriversFolder -Make $SelectedMake $rawModels = Get-HPDriversModelList -DriversFolder $localDriversFolder -Make $SelectedMake
@@ -330,6 +373,7 @@ function Search-DriverModels {
} }
# The view will automatically refresh. No need to call .Refresh() explicitly for filtering. # The view will automatically refresh. No need to call .Refresh() explicitly for filtering.
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
$filteredCount = 0 $filteredCount = 0
if ($null -ne $collectionView) { if ($null -ne $collectionView) {
foreach ($item in $collectionView) { $filteredCount++ } foreach ($item in $collectionView) { $filteredCount++ }
@@ -344,7 +388,10 @@ function Save-DriversJson {
[psobject]$State [psobject]$State
) )
WriteLog "Save-DriversJson function called." WriteLog "Save-DriversJson function called."
$selectedDrivers = @($State.Controls.lstDriverModels.Items | Where-Object { $_.IsSelected })
# Save from the master model list so filtered-out selected rows are preserved.
$driverSelectionSource = if ($null -ne $State.Data.allDriverModels) { $State.Data.allDriverModels } else { $State.Controls.lstDriverModels.Items }
$selectedDrivers = @($driverSelectionSource | Where-Object { $_.IsSelected })
if (-not $selectedDrivers) { if (-not $selectedDrivers) {
[System.Windows.MessageBox]::Show("No drivers selected to save.", "Save Drivers", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information) [System.Windows.MessageBox]::Show("No drivers selected to save.", "Save Drivers", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information)
@@ -669,6 +716,7 @@ function Import-DriversJson {
# Update the UI and apply any existing filter # Update the UI and apply any existing filter
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels $State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
Search-DriverModels -filterText $State.Controls.txtModelFilter.Text -State $State Search-DriverModels -filterText $State.Controls.txtModelFilter.Text -State $State
$message = "Driver import complete.`nNew models added: $newModelsAdded`nExisting models updated: $existingModelsUpdated" $message = "Driver import complete.`nNew models added: $newModelsAdded`nExisting models updated: $existingModelsUpdated"
@@ -740,6 +788,7 @@ function Invoke-GetModels {
# Update the UI ItemsSource to point to the new list and clear the filter # Update the UI ItemsSource to point to the new list and clear the filter
$State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels $State.Controls.lstDriverModels.ItemsSource = $State.Data.allDriverModels
Request-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels
$State.Controls.txtModelFilter.Text = "" $State.Controls.txtModelFilter.Text = ""
if ($State.Data.allDriverModels.Count -gt 0) { if ($State.Data.allDriverModels.Count -gt 0) {
@@ -831,6 +880,12 @@ function Invoke-DownloadSelectedDrivers {
$localDriversFolder = $State.Controls.txtDriversFolder.Text $localDriversFolder = $State.Controls.txtDriversFolder.Text
$localWindowsRelease = $State.Controls.cmbWindowsRelease.SelectedItem.Value $localWindowsRelease = $State.Controls.cmbWindowsRelease.SelectedItem.Value
$localWindowsReleaseDisplay = $State.Controls.cmbWindowsRelease.SelectedItem.Display
$localWindowsSku = if ($null -ne $State.Controls.cmbWindowsSKU.SelectedItem) { [string]$State.Controls.cmbWindowsSKU.SelectedItem } else { $null }
$localDriverWindowsRelease = Get-EffectiveDriverWindowsRelease -WindowsRelease $localWindowsRelease -WindowsReleaseDisplay $localWindowsReleaseDisplay -WindowsSku $localWindowsSku
if ($localDriverWindowsRelease -ne $localWindowsRelease) {
WriteLog "Normalized WindowsRelease for driver download from $localWindowsRelease to $localDriverWindowsRelease (Display='$localWindowsReleaseDisplay', SKU='$localWindowsSku')."
}
$localWindowsArch = $State.Controls.cmbWindowsArch.SelectedItem $localWindowsArch = $State.Controls.cmbWindowsArch.SelectedItem
$localWindowsVersion = if ($null -ne $State.Controls.cmbWindowsVersion -and $null -ne $State.Controls.cmbWindowsVersion.SelectedItem) { $State.Controls.cmbWindowsVersion.SelectedItem } else { $null } $localWindowsVersion = if ($null -ne $State.Controls.cmbWindowsVersion -and $null -ne $State.Controls.cmbWindowsVersion.SelectedItem) { $State.Controls.cmbWindowsVersion.SelectedItem } else { $null }
$coreStaticVars = Get-CoreStaticVariables $coreStaticVars = Get-CoreStaticVariables
@@ -848,10 +903,10 @@ function Invoke-DownloadSelectedDrivers {
WriteLog "Dell drivers selected. Ensuring Dell Catalog is up-to-date..." WriteLog "Dell drivers selected. Ensuring Dell Catalog is up-to-date..."
try { try {
$dellDriversFolder = Join-Path -Path $localDriversFolder -ChildPath "Dell" $dellDriversFolder = Join-Path -Path $localDriversFolder -ChildPath "Dell"
$catalogBaseName = if ($localWindowsRelease -le 11) { "CatalogIndexPC" } else { "Catalog" } $catalogBaseName = if ($localDriverWindowsRelease -le 11) { "CatalogIndexPC" } else { "Catalog" }
$dellCabFile = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).cab" $dellCabFile = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).cab"
$dellCatalogXML = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).xml" $dellCatalogXML = Join-Path -Path $dellDriversFolder -ChildPath "$($catalogBaseName).xml"
$catalogUrl = if ($localWindowsRelease -le 11) { "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 $downloadCatalog = $true
if (Test-Path -Path $dellCatalogXML -PathType Leaf) { if (Test-Path -Path $dellCatalogXML -PathType Leaf) {
@@ -891,7 +946,7 @@ function Invoke-DownloadSelectedDrivers {
$preserveSource = ($State.Controls.chkUseDriversAsPEDrivers.IsChecked -and $State.Controls.chkCompressDriversToWIM.IsChecked) $preserveSource = ($State.Controls.chkUseDriversAsPEDrivers.IsChecked -and $State.Controls.chkCompressDriversToWIM.IsChecked)
$taskArguments = @{ $taskArguments = @{
DriversFolder = $localDriversFolder DriversFolder = $localDriversFolder
WindowsRelease = $localWindowsRelease WindowsRelease = $localDriverWindowsRelease
WindowsArch = $localWindowsArch WindowsArch = $localWindowsArch
WindowsVersion = $localWindowsVersion WindowsVersion = $localWindowsVersion
Headers = $localHeaders Headers = $localHeaders
@@ -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. 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 { function Register-EventHandlers {
param([PSCustomObject]$State) param([PSCustomObject]$State)
WriteLog "Registering UI event handlers..." WriteLog "Registering UI event handlers..."
@@ -24,7 +359,7 @@ function Register-EventHandlers {
# Define a handler to validate pasted text, ensuring it's only integers # Define a handler to validate pasted text, ensuring it's only integers
$integerPastingHandler = { $integerPastingHandler = {
param($sender, $pastingEventArgs) param($eventSource, $pastingEventArgs)
if ($pastingEventArgs.DataObject.GetDataPresent([string])) { if ($pastingEventArgs.DataObject.GetDataPresent([string])) {
$pastedText = $pastingEventArgs.DataObject.GetData([string]) $pastedText = $pastingEventArgs.DataObject.GetData([string])
# Check if the pasted text consists ONLY of one or more digits. # 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 # Build Tab Event Handlers
$State.Controls.btnBrowseFFUDevPath.Add_Click({ $State.Controls.btnBrowseFFUDevPath.Add_Click({
param($eventSource, $routedEventArgs) param($eventSource, $routedEventArgs)
@@ -94,7 +555,34 @@ function Register-EventHandlers {
$localState = $window.Tag $localState = $window.Tag
$selectedPath = Invoke-BrowseAction -Type 'Folder' -Title "Select FFU Development Path" $selectedPath = Invoke-BrowseAction -Type 'Folder' -Title "Select FFU Development Path"
if ($selectedPath) { 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 $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 # Build USB Drive Settings Event Handlers
# The USB Expander is always visible; the checkbox controls child settings only
$State.Controls.chkBuildUSBDriveEnable.Add_Checked({ $State.Controls.chkBuildUSBDriveEnable.Add_Checked({
param($eventSource, $routedEventArgs) param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource) $window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag $localState = $window.Tag
$localState.Controls.usbSection.Visibility = 'Visible'
$localState.Controls.chkSelectSpecificUSBDrives.IsEnabled = $true $localState.Controls.chkSelectSpecificUSBDrives.IsEnabled = $true
}) })
$State.Controls.chkBuildUSBDriveEnable.Add_Unchecked({ $State.Controls.chkBuildUSBDriveEnable.Add_Unchecked({
param($eventSource, $routedEventArgs) param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource) $window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag $localState = $window.Tag
$localState.Controls.usbSection.Visibility = 'Collapsed'
$localState.Controls.chkSelectSpecificUSBDrives.IsEnabled = $false $localState.Controls.chkSelectSpecificUSBDrives.IsEnabled = $false
$localState.Controls.chkSelectSpecificUSBDrives.IsChecked = $false $localState.Controls.chkSelectSpecificUSBDrives.IsChecked = $false
$localState.Controls.lstUSBDrives.Items.Clear() $localState.Controls.lstUSBDrives.Items.Clear()
@@ -220,6 +935,7 @@ function Register-EventHandlers {
$driveObject | Add-Member -MemberType NoteProperty -Name 'IsSelected' -Value $false -Force $driveObject | Add-Member -MemberType NoteProperty -Name 'IsSelected' -Value $false -Force
$localState.Controls.lstUSBDrives.Items.Add($driveObject) $localState.Controls.lstUSBDrives.Items.Add($driveObject)
} }
Request-ListViewColumnAutoResize -ListView $localState.Controls.lstUSBDrives
if ($localState.Controls.lstUSBDrives.Items.Count -gt 0) { if ($localState.Controls.lstUSBDrives.Items.Count -gt 0) {
$localState.Controls.lstUSBDrives.SelectedIndex = 0 $localState.Controls.lstUSBDrives.SelectedIndex = 0
} }
@@ -253,42 +969,30 @@ function Register-EventHandlers {
}) })
# Hyper-V tab event handlers # 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({ $State.Controls.cmbVMSwitchName.Add_SelectionChanged({
param($eventSource, $selectionChangedEventArgs) param($eventSource, $selectionChangedEventArgs)
# The state object is available via the parent window's Tag property # The state object is available via the parent window's Tag property
$window = [System.Windows.Window]::GetWindow($eventSource) $window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag $localState = $window.Tag
$selectedItem = $eventSource.SelectedItem Update-VMNetworkingControls -State $localState
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
}
}
}) })
# Persist custom VM switch name/IP when user edits them while 'Other' is selected # Persist custom VM switch name when user edits it while 'Other' is selected
$State.Controls.txtVMHostIPAddress.Add_LostFocus({
param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag
if ($localState.Controls.cmbVMSwitchName.SelectedItem -eq 'Other') {
$localState.Data.customVMHostIP = $localState.Controls.txtVMHostIPAddress.Text
}
})
$State.Controls.txtCustomVMSwitchName.Add_LostFocus({ $State.Controls.txtCustomVMSwitchName.Add_LostFocus({
param($eventSource, $routedEventArgs) param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource) $window = [System.Windows.Window]::GetWindow($eventSource)
@@ -299,12 +1003,31 @@ function Register-EventHandlers {
}) })
# Windows Settings tab Event Handlers # Windows Settings tab Event Handlers
$State.Controls.txtISOPath.Add_TextChanged({ # Windows Media Source radio buttons
param($eventSource, $textChangedEventArgs) if ($null -ne $State.Controls.rbProvideISO) {
$window = [System.Windows.Window]::GetWindow($eventSource) $State.Controls.rbProvideISO.Add_Checked({
$localState = $window.Tag param($eventSource, $routedEventArgs)
Get-WindowsSettingsCombos -isoPath $localState.Controls.txtISOPath.Text -State $localState $window = [System.Windows.Window]::GetWindow($eventSource)
}) if ($null -eq $window -or $null -eq $window.Tag) { return }
$localState = $window.Tag
$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({ $State.Controls.cmbWindowsRelease.Add_SelectionChanged({
param($eventSource, $selectionChangedEventArgs) param($eventSource, $selectionChangedEventArgs)
@@ -314,9 +1037,18 @@ function Register-EventHandlers {
if ($null -ne $localState.Controls.cmbWindowsRelease.SelectedItem) { if ($null -ne $localState.Controls.cmbWindowsRelease.SelectedItem) {
$selectedReleaseValue = $localState.Controls.cmbWindowsRelease.SelectedItem.Value $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-WindowsSkuCombo -State $localState
Update-WindowsArchCombo -State $localState Update-WindowsArchCombo -State $localState
# Re-evaluate Install Apps dependency when Windows release changes
Update-InstallAppsState -State $localState
}) })
$State.Controls.cmbWindowsVersion.Add_SelectionChanged({ $State.Controls.cmbWindowsVersion.Add_SelectionChanged({
@@ -369,6 +1101,8 @@ function Register-EventHandlers {
$State.Controls.chkUpdateOneDrive.Add_Unchecked($updateCheckboxHandler) $State.Controls.chkUpdateOneDrive.Add_Unchecked($updateCheckboxHandler)
$State.Controls.chkUpdateLatestMSRT.Add_Checked($updateCheckboxHandler) $State.Controls.chkUpdateLatestMSRT.Add_Checked($updateCheckboxHandler)
$State.Controls.chkUpdateLatestMSRT.Add_Unchecked($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 # Also attach the handler to the Office checkbox
$State.Controls.chkInstallOffice.Add_Checked($updateCheckboxHandler) $State.Controls.chkInstallOffice.Add_Checked($updateCheckboxHandler)
@@ -430,10 +1164,18 @@ function Register-EventHandlers {
param($eventSource, $routedEventArgs) param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource) $window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag $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 } 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({ $State.Controls.btnBrowseAppSource.Add_Click({
param($eventSource, $routedEventArgs) param($eventSource, $routedEventArgs)
$window = [System.Windows.Window]::GetWindow($eventSource) $window = [System.Windows.Window]::GetWindow($eventSource)
@@ -461,17 +1203,23 @@ function Register-EventHandlers {
$window = [System.Windows.Window]::GetWindow($eventSource) $window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag $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 } 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' ` $savePath = Invoke-BrowseAction -Type 'SaveFile' `
-Title "Save Application List" ` -Title "Save BYO App List" `
-Filter "JSON files (*.json)|*.json|All files (*.*)|*.*" ` -Filter "JSON files (*.json)|*.json|All files (*.*)|*.*" `
-InitialDirectory $initialDir ` -InitialDirectory $initialDir `
-FileName "UserAppList.json" ` -FileName $fileName `
-DefaultExt ".json" -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({ $State.Controls.btnLoadBYOApplications.Add_Click({
@@ -479,15 +1227,18 @@ function Register-EventHandlers {
$window = [System.Windows.Window]::GetWindow($eventSource) $window = [System.Windows.Window]::GetWindow($eventSource)
$localState = $window.Tag $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 } if ([string]::IsNullOrWhiteSpace($initialDir) -or -not (Test-Path $initialDir)) { $initialDir = $localState.FFUDevelopmentPath }
$loadPath = Invoke-BrowseAction -Type 'OpenFile' ` $loadPath = Invoke-BrowseAction -Type 'OpenFile' `
-Title "Import Application List" ` -Title "Import BYO App List" `
-Filter "JSON files (*.json)|*.json|All files (*.*)|*.*" ` -Filter "JSON files (*.json)|*.json|All files (*.*)|*.*" `
-InitialDirectory $initialDir -InitialDirectory $initialDir
if ($loadPath) { if ($loadPath) {
$localState.Controls.txtUserAppListPath.Text = $loadPath
Import-BYOApplicationList -Path $loadPath -State $localState Import-BYOApplicationList -Path $loadPath -State $localState
Update-CopyButtonState -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. This module is critical for setting up the initial state of the application window when it first loads.
#> #>
function Initialize-FluentTheme {
param(
[Parameter(Mandatory = $true)]
[System.Windows.Window]$Window,
[Parameter(Mandatory = $false)]
[string]$ThemeMode = "System",
[Parameter(Mandatory = $false)]
[PSCustomObject]$State
)
# Check if the current .NET runtime supports Window.ThemeMode (requires .NET 9+ / PowerShell 7.5+)
$themeModeProperty = [System.Windows.Window].GetProperty("ThemeMode")
if ($null -eq $themeModeProperty) {
WriteLog "Fluent theme not available. Window.ThemeMode requires PowerShell 7.5+ (.NET 9+). Using default Aero2 theme."
if ($null -ne $State) {
$State.Flags.isFluentSupported = $false
}
# Still create tooltip styles for non-Fluent mode so Tag-to-ToolTip binding works
$controlTypes = @(
[System.Windows.Controls.TextBox],
[System.Windows.Controls.TextBlock],
[System.Windows.Controls.CheckBox]
)
foreach ($controlType in $controlTypes) {
$newStyle = New-Object System.Windows.Style($controlType)
$toolTipBinding = New-Object System.Windows.Data.Binding("Tag")
$toolTipBinding.RelativeSource = [System.Windows.Data.RelativeSource]::new([System.Windows.Data.RelativeSourceMode]::Self)
$toolTipSetter = New-Object System.Windows.Setter([System.Windows.FrameworkElement]::ToolTipProperty, $toolTipBinding)
$newStyle.Setters.Add($toolTipSetter)
if ($Window.Resources.Contains($controlType)) {
$Window.Resources.Remove($controlType)
}
$Window.Resources.Add($controlType, $newStyle)
}
WriteLog "Tooltip styles created for non-Fluent mode."
return
}
# Mark Fluent as supported in state
if ($null -ne $State) {
$State.Flags.isFluentSupported = $true
}
# Resolve the ThemeMode enum value using reflection to avoid compile-time experimental attribute issues
$themeModeType = [System.Windows.Window].GetProperty("ThemeMode").PropertyType
$themeModeValue = $null
switch ($ThemeMode) {
"Light" { $themeModeValue = $themeModeType::Light }
"Dark" { $themeModeValue = $themeModeType::Dark }
"System" { $themeModeValue = $themeModeType::System }
default { $themeModeValue = $themeModeType::System }
}
# Apply the Fluent theme mode to the window
$themeModeProperty.SetValue($Window, $themeModeValue)
WriteLog "Applied Fluent theme: $ThemeMode"
# Re-create implicit tooltip styles with BasedOn pointing to the Fluent base style
# This preserves the Tag-to-ToolTip binding while inheriting Fluent visual styling
$controlTypes = @(
[System.Windows.Controls.TextBox],
[System.Windows.Controls.TextBlock],
[System.Windows.Controls.CheckBox]
)
foreach ($controlType in $controlTypes) {
# Get the Fluent base style that was loaded by ThemeMode
$fluentBaseStyle = $Window.TryFindResource($controlType)
# Create a new implicit style with ToolTip binding
$newStyle = New-Object System.Windows.Style($controlType)
if ($null -ne $fluentBaseStyle) {
$newStyle.BasedOn = $fluentBaseStyle
}
# Add the ToolTip setter that binds to the Tag property
$toolTipBinding = New-Object System.Windows.Data.Binding("Tag")
$toolTipBinding.RelativeSource = [System.Windows.Data.RelativeSource]::new([System.Windows.Data.RelativeSourceMode]::Self)
$toolTipSetter = New-Object System.Windows.Setter([System.Windows.FrameworkElement]::ToolTipProperty, $toolTipBinding)
$newStyle.Setters.Add($toolTipSetter)
# Remove any existing implicit style for this type before adding the new one
if ($Window.Resources.Contains($controlType)) {
$Window.Resources.Remove($controlType)
}
$Window.Resources.Add($controlType, $newStyle)
}
WriteLog "Tooltip styles updated with Fluent base styles."
}
function Initialize-UIControls { function Initialize-UIControls {
param([PSCustomObject]$State) param([PSCustomObject]$State)
WriteLog "Initializing UI control references..." WriteLog "Initializing UI control references..."
@@ -21,6 +112,9 @@ function Initialize-UIControls {
$State.Controls.cmbWindowsRelease = $window.FindName('cmbWindowsRelease') $State.Controls.cmbWindowsRelease = $window.FindName('cmbWindowsRelease')
$State.Controls.cmbWindowsVersion = $window.FindName('cmbWindowsVersion') $State.Controls.cmbWindowsVersion = $window.FindName('cmbWindowsVersion')
$State.Controls.txtISOPath = $window.FindName('txtISOPath') $State.Controls.txtISOPath = $window.FindName('txtISOPath')
$State.Controls.rbDownloadESD = $window.FindName('rbDownloadESD')
$State.Controls.rbProvideISO = $window.FindName('rbProvideISO')
$State.Controls.isoPathPanel = $window.FindName('isoPathPanel')
$State.Controls.btnBrowseISO = $window.FindName('btnBrowseISO') $State.Controls.btnBrowseISO = $window.FindName('btnBrowseISO')
$State.Controls.cmbWindowsArch = $window.FindName('cmbWindowsArch') $State.Controls.cmbWindowsArch = $window.FindName('cmbWindowsArch')
$State.Controls.cmbWindowsLang = $window.FindName('cmbWindowsLang') $State.Controls.cmbWindowsLang = $window.FindName('cmbWindowsLang')
@@ -70,8 +164,10 @@ function Initialize-UIControls {
$State.Controls.txtWingetModuleVersion = $window.FindName('txtWingetModuleVersion') $State.Controls.txtWingetModuleVersion = $window.FindName('txtWingetModuleVersion')
$State.Controls.applicationPathPanel = $window.FindName('applicationPathPanel') $State.Controls.applicationPathPanel = $window.FindName('applicationPathPanel')
$State.Controls.appListJsonPathPanel = $window.FindName('appListJsonPathPanel') $State.Controls.appListJsonPathPanel = $window.FindName('appListJsonPathPanel')
$State.Controls.userAppListPathPanel = $window.FindName('userAppListPathPanel')
$State.Controls.btnBrowseApplicationPath = $window.FindName('btnBrowseApplicationPath') $State.Controls.btnBrowseApplicationPath = $window.FindName('btnBrowseApplicationPath')
$State.Controls.btnBrowseAppListJsonPath = $window.FindName('btnBrowseAppListJsonPath') $State.Controls.btnBrowseAppListJsonPath = $window.FindName('btnBrowseAppListJsonPath')
$State.Controls.btnBrowseUserAppListPath = $window.FindName('btnBrowseUserAppListPath')
$State.Controls.chkBringYourOwnApps = $window.FindName('chkBringYourOwnApps') $State.Controls.chkBringYourOwnApps = $window.FindName('chkBringYourOwnApps')
$State.Controls.byoApplicationPanel = $window.FindName('byoApplicationPanel') $State.Controls.byoApplicationPanel = $window.FindName('byoApplicationPanel')
$State.Controls.wingetSearchPanel = $window.FindName('wingetSearchPanel') $State.Controls.wingetSearchPanel = $window.FindName('wingetSearchPanel')
@@ -109,37 +205,59 @@ function Initialize-UIControls {
$State.Controls.txtStatus = $window.FindName('txtStatus') $State.Controls.txtStatus = $window.FindName('txtStatus')
$State.Controls.pbOverallProgress = $window.FindName('progressBar') $State.Controls.pbOverallProgress = $window.FindName('progressBar')
$State.Controls.txtOverallStatus = $window.FindName('txtStatus') $State.Controls.txtOverallStatus = $window.FindName('txtStatus')
$State.Controls.chkEnableVMNetworking = $window.FindName('chkEnableVMNetworking')
$State.Controls.spVMNetworkingSettings = $window.FindName('spVMNetworkingSettings')
$State.Controls.cmbVMSwitchName = $window.FindName('cmbVMSwitchName') $State.Controls.cmbVMSwitchName = $window.FindName('cmbVMSwitchName')
$State.Controls.txtVMHostIPAddress = $window.FindName('txtVMHostIPAddress')
$State.Controls.txtCustomVMSwitchName = $window.FindName('txtCustomVMSwitchName') $State.Controls.txtCustomVMSwitchName = $window.FindName('txtCustomVMSwitchName')
$State.Controls.txtFFUDevPath = $window.FindName('txtFFUDevPath') $State.Controls.txtFFUDevPath = $window.FindName('txtFFUDevPath')
$State.Controls.txtCustomFFUNameTemplate = $window.FindName('txtCustomFFUNameTemplate') $State.Controls.txtCustomFFUNameTemplate = $window.FindName('txtCustomFFUNameTemplate')
$State.Controls.txtFFUCaptureLocation = $window.FindName('txtFFUCaptureLocation') $State.Controls.txtFFUCaptureLocation = $window.FindName('txtFFUCaptureLocation')
$State.Controls.txtShareName = $window.FindName('txtShareName')
$State.Controls.txtUsername = $window.FindName('txtUsername')
$State.Controls.txtThreads = $window.FindName('txtThreads') $State.Controls.txtThreads = $window.FindName('txtThreads')
$State.Controls.cmbBitsPriority = $window.FindName('cmbBitsPriority') $State.Controls.cmbBitsPriority = $window.FindName('cmbBitsPriority')
$State.Controls.txtMaxUSBDrives = $window.FindName('txtMaxUSBDrives') $State.Controls.txtMaxUSBDrives = $window.FindName('txtMaxUSBDrives')
$State.Controls.chkCompactOS = $window.FindName('chkCompactOS') $State.Controls.chkCompactOS = $window.FindName('chkCompactOS')
$State.Controls.chkOptimize = $window.FindName('chkOptimize') $State.Controls.chkOptimize = $window.FindName('chkOptimize')
$State.Controls.chkAllowVHDXCaching = $window.FindName('chkAllowVHDXCaching') $State.Controls.chkAllowVHDXCaching = $window.FindName('chkAllowVHDXCaching')
$State.Controls.chkCreateCaptureMedia = $window.FindName('chkCreateCaptureMedia')
$State.Controls.chkCreateDeploymentMedia = $window.FindName('chkCreateDeploymentMedia') $State.Controls.chkCreateDeploymentMedia = $window.FindName('chkCreateDeploymentMedia')
$State.Controls.chkInjectUnattend = $window.FindName('chkInjectUnattend') $State.Controls.chkInjectUnattend = $window.FindName('chkInjectUnattend')
$State.Controls.txtUnattendX64FilePath = $window.FindName('txtUnattendX64FilePath')
$State.Controls.btnBrowseUnattendX64FilePath = $window.FindName('btnBrowseUnattendX64FilePath')
$State.Controls.txtUnattendArm64FilePath = $window.FindName('txtUnattendArm64FilePath')
$State.Controls.btnBrowseUnattendArm64FilePath = $window.FindName('btnBrowseUnattendArm64FilePath')
$State.Controls.rbDeviceNamingNone = $window.FindName('rbDeviceNamingNone')
$State.Controls.rbDeviceNamingPrompt = $window.FindName('rbDeviceNamingPrompt')
$State.Controls.rbDeviceNamingTemplate = $window.FindName('rbDeviceNamingTemplate')
$State.Controls.rbDeviceNamingPrefixes = $window.FindName('rbDeviceNamingPrefixes')
$State.Controls.rbDeviceNamingSerialComputerNames = $window.FindName('rbDeviceNamingSerialComputerNames')
$State.Controls.deviceNameTemplatePanel = $window.FindName('deviceNameTemplatePanel')
$State.Controls.deviceNamePrefixesPanel = $window.FindName('deviceNamePrefixesPanel')
$State.Controls.deviceNameSerialComputerNamesPanel = $window.FindName('deviceNameSerialComputerNamesPanel')
$State.Controls.txtDeviceNameTemplate = $window.FindName('txtDeviceNameTemplate')
$State.Controls.txtDeviceNamePrefixesPath = $window.FindName('txtDeviceNamePrefixesPath')
$State.Controls.btnBrowseDeviceNamePrefixesPath = $window.FindName('btnBrowseDeviceNamePrefixesPath')
$State.Controls.txtDeviceNamePrefixes = $window.FindName('txtDeviceNamePrefixes')
$State.Controls.btnSaveDeviceNamePrefixes = $window.FindName('btnSaveDeviceNamePrefixes')
$State.Controls.txtDeviceNameSerialComputerNamesPath = $window.FindName('txtDeviceNameSerialComputerNamesPath')
$State.Controls.btnBrowseDeviceNameSerialComputerNamesPath = $window.FindName('btnBrowseDeviceNameSerialComputerNamesPath')
$State.Controls.txtDeviceNameSerialComputerNames = $window.FindName('txtDeviceNameSerialComputerNames')
$State.Controls.btnSaveDeviceNameSerialComputerNames = $window.FindName('btnSaveDeviceNameSerialComputerNames')
$State.Controls.chkVerbose = $window.FindName('chkVerbose') $State.Controls.chkVerbose = $window.FindName('chkVerbose')
$State.Controls.chkCopyAutopilot = $window.FindName('chkCopyAutopilot') $State.Controls.chkCopyAutopilot = $window.FindName('chkCopyAutopilot')
$State.Controls.chkCopyUnattend = $window.FindName('chkCopyUnattend') $State.Controls.chkCopyUnattend = $window.FindName('chkCopyUnattend')
$State.Controls.chkCopyPPKG = $window.FindName('chkCopyPPKG') $State.Controls.chkCopyPPKG = $window.FindName('chkCopyPPKG')
$State.Controls.chkCleanupAppsISO = $window.FindName('chkCleanupAppsISO') $State.Controls.chkCleanupAppsISO = $window.FindName('chkCleanupAppsISO')
$State.Controls.chkCleanupCaptureISO = $window.FindName('chkCleanupCaptureISO')
$State.Controls.chkCleanupDeployISO = $window.FindName('chkCleanupDeployISO') $State.Controls.chkCleanupDeployISO = $window.FindName('chkCleanupDeployISO')
$State.Controls.chkCleanupDrivers = $window.FindName('chkCleanupDrivers') $State.Controls.chkCleanupDrivers = $window.FindName('chkCleanupDrivers')
$State.Controls.chkRemoveFFU = $window.FindName('chkRemoveFFU') $State.Controls.chkRemoveFFU = $window.FindName('chkRemoveFFU')
$State.Controls.chkRemoveDownloadedESD = $window.FindName('chkRemoveDownloadedESD')
$State.Controls.txtDiskSize = $window.FindName('txtDiskSize') $State.Controls.txtDiskSize = $window.FindName('txtDiskSize')
$State.Controls.txtMemory = $window.FindName('txtMemory') $State.Controls.txtMemory = $window.FindName('txtMemory')
$State.Controls.txtProcessors = $window.FindName('txtProcessors') $State.Controls.txtProcessors = $window.FindName('txtProcessors')
$State.Controls.txtVMLocation = $window.FindName('txtVMLocation') $State.Controls.txtVMLocation = $window.FindName('txtVMLocation')
$State.Controls.txtVMNamePrefix = $window.FindName('txtVMNamePrefix') $State.Controls.txtVMNamePrefix = $window.FindName('txtVMNamePrefix')
$State.Controls.cmbSystemPartitionDriveLetter = $window.FindName('cmbSystemPartitionDriveLetter')
$State.Controls.cmbWindowsPartitionDriveLetter = $window.FindName('cmbWindowsPartitionDriveLetter')
$State.Controls.cmbRecoveryPartitionDriveLetter = $window.FindName('cmbRecoveryPartitionDriveLetter')
$State.Controls.cmbLogicalSectorSize = $window.FindName('cmbLogicalSectorSize') $State.Controls.cmbLogicalSectorSize = $window.FindName('cmbLogicalSectorSize')
$State.Controls.txtProductKey = $window.FindName('txtProductKey') $State.Controls.txtProductKey = $window.FindName('txtProductKey')
$State.Controls.txtOfficePath = $window.FindName('txtOfficePath') $State.Controls.txtOfficePath = $window.FindName('txtOfficePath')
@@ -158,6 +276,7 @@ function Initialize-UIControls {
$State.Controls.chkUpdatePreviewCU = $window.FindName('chkUpdatePreviewCU') $State.Controls.chkUpdatePreviewCU = $window.FindName('chkUpdatePreviewCU')
$State.Controls.txtApplicationPath = $window.FindName('txtApplicationPath') $State.Controls.txtApplicationPath = $window.FindName('txtApplicationPath')
$State.Controls.txtAppListJsonPath = $window.FindName('txtAppListJsonPath') $State.Controls.txtAppListJsonPath = $window.FindName('txtAppListJsonPath')
$State.Controls.txtUserAppListPath = $window.FindName('txtUserAppListPath')
$State.Controls.chkInstallDrivers = $window.FindName('chkInstallDrivers') $State.Controls.chkInstallDrivers = $window.FindName('chkInstallDrivers')
$State.Controls.chkCopyDrivers = $window.FindName('chkCopyDrivers') $State.Controls.chkCopyDrivers = $window.FindName('chkCopyDrivers')
$State.Controls.chkCompressDriversToWIM = $window.FindName('chkCompressDriversToWIM') $State.Controls.chkCompressDriversToWIM = $window.FindName('chkCompressDriversToWIM')
@@ -179,11 +298,59 @@ function Initialize-UIControls {
$State.Controls.btnRestoreDefaults = $window.FindName('btnRestoreDefaults') $State.Controls.btnRestoreDefaults = $window.FindName('btnRestoreDefaults')
$State.Controls.btnBuildConfig = $window.FindName('btnBuildConfig') $State.Controls.btnBuildConfig = $window.FindName('btnBuildConfig')
# Monitor Tab # Home page
$State.Controls.MainTabControl = $window.FindName('MainTabControl') $State.Controls.txtHomeCurrentBuildValue = $window.FindName('txtHomeCurrentBuildValue')
$State.Controls.MonitorTab = $window.FindName('MonitorTab') $State.Controls.txtHomeLatestReleaseValue = $window.FindName('txtHomeLatestReleaseValue')
$State.Controls.txtHomeReleaseStatusValue = $window.FindName('txtHomeReleaseStatusValue')
$State.Controls.spHomeReleaseNotesSections = $window.FindName('spHomeReleaseNotesSections')
$State.Controls.ellipseHomeDiskSpaceStatus = $window.FindName('ellipseHomeDiskSpaceStatus')
$State.Controls.txtHomeDiskSpaceStatusValue = $window.FindName('txtHomeDiskSpaceStatusValue')
$State.Controls.ellipseHomeHyperVStatus = $window.FindName('ellipseHomeHyperVStatus')
$State.Controls.txtHomeHyperVStatusValue = $window.FindName('txtHomeHyperVStatusValue')
$State.Controls.txtHomeDiscussionsStatusValue = $window.FindName('txtHomeDiscussionsStatusValue')
$State.Controls.tbDiscussion1 = $window.FindName('tbDiscussion1')
$State.Controls.linkDiscussion1 = $window.FindName('linkDiscussion1')
$State.Controls.runDiscussion1 = $window.FindName('runDiscussion1')
$State.Controls.tbDiscussion2 = $window.FindName('tbDiscussion2')
$State.Controls.linkDiscussion2 = $window.FindName('linkDiscussion2')
$State.Controls.runDiscussion2 = $window.FindName('runDiscussion2')
$State.Controls.tbDiscussion3 = $window.FindName('tbDiscussion3')
$State.Controls.linkDiscussion3 = $window.FindName('linkDiscussion3')
$State.Controls.runDiscussion3 = $window.FindName('runDiscussion3')
$State.Controls.tbDiscussion4 = $window.FindName('tbDiscussion4')
$State.Controls.linkDiscussion4 = $window.FindName('linkDiscussion4')
$State.Controls.runDiscussion4 = $window.FindName('runDiscussion4')
$State.Controls.tbDiscussion5 = $window.FindName('tbDiscussion5')
$State.Controls.linkDiscussion5 = $window.FindName('linkDiscussion5')
$State.Controls.runDiscussion5 = $window.FindName('runDiscussion5')
$State.Controls.tbDiscussionsLink = $window.FindName('tbDiscussionsLink')
$State.Controls.linkDiscussions = $window.FindName('linkDiscussions')
# Settings page
$State.Controls.cmbThemeMode = $window.FindName('cmbThemeMode')
# Shared page shell
$State.Controls.txtPageTitle = $window.FindName('txtPageTitle')
# Navigation controls
$State.Controls.lstNavigation = $window.FindName('lstNavigation')
$State.Controls.lstNavSettings = $window.FindName('lstNavSettings')
$State.Controls.lstLogOutput = $window.FindName('lstLogOutput') $State.Controls.lstLogOutput = $window.FindName('lstLogOutput')
# Content pages (for navigation visibility toggling)
$State.Controls.navigationPages = @(
$window.FindName('pageHome'),
$window.FindName('pageHyperV'),
$window.FindName('pageWindows'),
$window.FindName('pageUpdates'),
$window.FindName('pageApplications'),
$window.FindName('pageOffice'),
$window.FindName('pageDrivers'),
$window.FindName('pageBuild'),
$window.FindName('pageMonitor')
)
$State.Controls.pageSettings = $window.FindName('pageSettings')
# Initialize and bind the log data collection # Initialize and bind the log data collection
$State.Data.logData = New-Object System.Collections.ObjectModel.ObservableCollection[string] $State.Data.logData = New-Object System.Collections.ObjectModel.ObservableCollection[string]
$State.Controls.lstLogOutput.ItemsSource = $State.Data.logData $State.Controls.lstLogOutput.ItemsSource = $State.Data.logData
@@ -204,19 +371,11 @@ function Initialize-VMSwitchData {
$State.Controls.cmbVMSwitchName.Items.Add('Other') | Out-Null $State.Controls.cmbVMSwitchName.Items.Add('Other') | Out-Null
if ($State.Controls.cmbVMSwitchName.Items.Count -gt 1) { if ($State.Controls.cmbVMSwitchName.Items.Count -gt 1) {
$State.Controls.cmbVMSwitchName.SelectedIndex = 0 $State.Controls.cmbVMSwitchName.SelectedIndex = 0
$firstSwitch = $State.Controls.cmbVMSwitchName.SelectedItem
if ($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' $State.Controls.txtCustomVMSwitchName.Visibility = 'Collapsed'
} }
else { else {
$State.Controls.cmbVMSwitchName.SelectedItem = 'Other' $State.Controls.cmbVMSwitchName.SelectedItem = 'Other'
$State.Controls.txtCustomVMSwitchName.Visibility = 'Visible' $State.Controls.txtCustomVMSwitchName.Visibility = 'Visible'
$State.Controls.txtVMHostIPAddress.Text = $State.Defaults.generalDefaults.VMHostIPAddress # Use default
} }
} }
@@ -232,8 +391,6 @@ function Initialize-UIDefaults {
$State.Controls.txtFFUDevPath.Text = $State.FFUDevelopmentPath $State.Controls.txtFFUDevPath.Text = $State.FFUDevelopmentPath
$State.Controls.txtCustomFFUNameTemplate.Text = $State.Defaults.generalDefaults.CustomFFUNameTemplate $State.Controls.txtCustomFFUNameTemplate.Text = $State.Defaults.generalDefaults.CustomFFUNameTemplate
$State.Controls.txtFFUCaptureLocation.Text = $State.Defaults.generalDefaults.FFUCaptureLocation $State.Controls.txtFFUCaptureLocation.Text = $State.Defaults.generalDefaults.FFUCaptureLocation
$State.Controls.txtShareName.Text = $State.Defaults.generalDefaults.ShareName
$State.Controls.txtUsername.Text = $State.Defaults.generalDefaults.Username
$State.Controls.txtThreads.Text = $State.Defaults.generalDefaults.Threads $State.Controls.txtThreads.Text = $State.Defaults.generalDefaults.Threads
$State.Controls.cmbBitsPriority.SelectedItem = $State.Defaults.generalDefaults.BitsPriority $State.Controls.cmbBitsPriority.SelectedItem = $State.Defaults.generalDefaults.BitsPriority
$State.Controls.txtMaxUSBDrives.Text = $State.Defaults.generalDefaults.MaxUSBDrives $State.Controls.txtMaxUSBDrives.Text = $State.Defaults.generalDefaults.MaxUSBDrives
@@ -243,7 +400,8 @@ function Initialize-UIDefaults {
$State.Controls.chkOptimize.IsChecked = $State.Defaults.generalDefaults.Optimize $State.Controls.chkOptimize.IsChecked = $State.Defaults.generalDefaults.Optimize
$State.Controls.chkAllowVHDXCaching.IsChecked = $State.Defaults.generalDefaults.AllowVHDXCaching $State.Controls.chkAllowVHDXCaching.IsChecked = $State.Defaults.generalDefaults.AllowVHDXCaching
$State.Controls.chkInjectUnattend.IsChecked = $State.Defaults.generalDefaults.InjectUnattend $State.Controls.chkInjectUnattend.IsChecked = $State.Defaults.generalDefaults.InjectUnattend
$State.Controls.chkCreateCaptureMedia.IsChecked = $State.Defaults.generalDefaults.CreateCaptureMedia $State.Controls.txtUnattendX64FilePath.Text = $State.Defaults.generalDefaults.UnattendX64FilePath
$State.Controls.txtUnattendArm64FilePath.Text = $State.Defaults.generalDefaults.UnattendArm64FilePath
$State.Controls.chkCreateDeploymentMedia.IsChecked = $State.Defaults.generalDefaults.CreateDeploymentMedia $State.Controls.chkCreateDeploymentMedia.IsChecked = $State.Defaults.generalDefaults.CreateDeploymentMedia
$State.Controls.chkAllowExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.AllowExternalHardDiskMedia $State.Controls.chkAllowExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.AllowExternalHardDiskMedia
$State.Controls.chkPromptExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.PromptExternalHardDiskMedia $State.Controls.chkPromptExternalHardDiskMedia.IsChecked = $State.Defaults.generalDefaults.PromptExternalHardDiskMedia
@@ -251,15 +409,29 @@ function Initialize-UIDefaults {
$State.Controls.chkCopyAutopilot.IsChecked = $State.Defaults.generalDefaults.CopyAutopilot $State.Controls.chkCopyAutopilot.IsChecked = $State.Defaults.generalDefaults.CopyAutopilot
$State.Controls.chkCopyUnattend.IsChecked = $State.Defaults.generalDefaults.CopyUnattend $State.Controls.chkCopyUnattend.IsChecked = $State.Defaults.generalDefaults.CopyUnattend
$State.Controls.chkCopyPPKG.IsChecked = $State.Defaults.generalDefaults.CopyPPKG $State.Controls.chkCopyPPKG.IsChecked = $State.Defaults.generalDefaults.CopyPPKG
$defaultDeviceNamingMode = if ($State.Defaults.generalDefaults.DeviceNamingMode -in @('None', 'Prompt', 'Template', 'Prefixes', 'SerialComputerNames')) {
$State.Defaults.generalDefaults.DeviceNamingMode
}
else {
'None'
}
Set-DeviceNamingModeState -State $State -DisplayMode $defaultDeviceNamingMode -LoadedMode $null
$State.Controls.txtDeviceNameTemplate.Text = $State.Defaults.generalDefaults.DeviceNameTemplate
$State.Controls.txtDeviceNamePrefixesPath.Text = $State.Defaults.generalDefaults.DeviceNamePrefixesPath
$State.Controls.txtDeviceNamePrefixes.Text = ($State.Defaults.generalDefaults.DeviceNamePrefixes -join [System.Environment]::NewLine)
$State.Controls.txtDeviceNameSerialComputerNamesPath.Text = $State.Defaults.generalDefaults.DeviceNameSerialComputerNamesPath
$State.Controls.txtDeviceNameSerialComputerNames.Text = ($State.Defaults.generalDefaults.DeviceNameSerialComputerNames -join [System.Environment]::NewLine)
Import-DeviceNamePrefixesFromConfiguredPath -State $State
Import-SerialComputerNamesFromConfiguredPath -State $State
Update-DeviceNamingControls -State $State
$State.Controls.chkCleanupAppsISO.IsChecked = $State.Defaults.generalDefaults.CleanupAppsISO $State.Controls.chkCleanupAppsISO.IsChecked = $State.Defaults.generalDefaults.CleanupAppsISO
$State.Controls.chkCleanupCaptureISO.IsChecked = $State.Defaults.generalDefaults.CleanupCaptureISO
$State.Controls.chkCleanupDeployISO.IsChecked = $State.Defaults.generalDefaults.CleanupDeployISO $State.Controls.chkCleanupDeployISO.IsChecked = $State.Defaults.generalDefaults.CleanupDeployISO
$State.Controls.chkCleanupDrivers.IsChecked = $State.Defaults.generalDefaults.CleanupDrivers $State.Controls.chkCleanupDrivers.IsChecked = $State.Defaults.generalDefaults.CleanupDrivers
$State.Controls.chkRemoveFFU.IsChecked = $State.Defaults.generalDefaults.RemoveFFU $State.Controls.chkRemoveFFU.IsChecked = $State.Defaults.generalDefaults.RemoveFFU
$State.Controls.chkRemoveApps.IsChecked = $State.Defaults.generalDefaults.RemoveApps $State.Controls.chkRemoveApps.IsChecked = $State.Defaults.generalDefaults.RemoveApps
$State.Controls.chkRemoveUpdates.IsChecked = $State.Defaults.generalDefaults.RemoveUpdates $State.Controls.chkRemoveUpdates.IsChecked = $State.Defaults.generalDefaults.RemoveUpdates
$State.Controls.chkRemoveDownloadedESD.IsChecked = $State.Defaults.generalDefaults.RemoveDownloadedESD
$State.Controls.chkVerbose.IsChecked = $State.Defaults.generalDefaults.Verbose $State.Controls.chkVerbose.IsChecked = $State.Defaults.generalDefaults.Verbose
$State.Controls.usbSection.Visibility = if ($State.Controls.chkBuildUSBDriveEnable.IsChecked) { 'Visible' } else { 'Collapsed' }
$State.Controls.usbSelectionPanel.Visibility = if ($State.Controls.chkSelectSpecificUSBDrives.IsChecked) { 'Visible' } else { 'Collapsed' } $State.Controls.usbSelectionPanel.Visibility = if ($State.Controls.chkSelectSpecificUSBDrives.IsChecked) { 'Visible' } else { 'Collapsed' }
$State.Controls.chkSelectSpecificUSBDrives.IsEnabled = $State.Controls.chkBuildUSBDriveEnable.IsChecked $State.Controls.chkSelectSpecificUSBDrives.IsEnabled = $State.Controls.chkBuildUSBDriveEnable.IsChecked
$State.Controls.chkPromptExternalHardDiskMedia.IsEnabled = $State.Controls.chkAllowExternalHardDiskMedia.IsChecked $State.Controls.chkPromptExternalHardDiskMedia.IsEnabled = $State.Controls.chkAllowExternalHardDiskMedia.IsChecked
@@ -268,16 +440,26 @@ function Initialize-UIDefaults {
Update-BitsPrioritySetting -State $State Update-BitsPrioritySetting -State $State
# Hyper-V Settings defaults from General Defaults # Hyper-V Settings defaults from General Defaults
$State.Controls.chkEnableVMNetworking.IsChecked = $State.Defaults.generalDefaults.EnableVMNetworking
Initialize-VMSwitchData -State $State Initialize-VMSwitchData -State $State
$State.Controls.spVMNetworkingSettings.IsEnabled = $true -eq $State.Controls.chkEnableVMNetworking.IsChecked
$State.Controls.txtDiskSize.Text = $State.Defaults.generalDefaults.DiskSizeGB $State.Controls.txtDiskSize.Text = $State.Defaults.generalDefaults.DiskSizeGB
$State.Controls.txtMemory.Text = $State.Defaults.generalDefaults.MemoryGB $State.Controls.txtMemory.Text = $State.Defaults.generalDefaults.MemoryGB
$State.Controls.txtProcessors.Text = $State.Defaults.generalDefaults.Processors $State.Controls.txtProcessors.Text = $State.Defaults.generalDefaults.Processors
$State.Controls.txtVMLocation.Text = $State.Defaults.generalDefaults.VMLocation $State.Controls.txtVMLocation.Text = $State.Defaults.generalDefaults.VMLocation
$State.Controls.txtVMNamePrefix.Text = $State.Defaults.generalDefaults.VMNamePrefix $State.Controls.txtVMNamePrefix.Text = $State.Defaults.generalDefaults.VMNamePrefix
$State.Controls.cmbSystemPartitionDriveLetter.SelectedItem = ($State.Controls.cmbSystemPartitionDriveLetter.Items | Where-Object { $_.Content -eq $State.Defaults.generalDefaults.SystemPartitionDriveLetter })
$State.Controls.cmbWindowsPartitionDriveLetter.SelectedItem = ($State.Controls.cmbWindowsPartitionDriveLetter.Items | Where-Object { $_.Content -eq $State.Defaults.generalDefaults.WindowsPartitionDriveLetter })
$State.Controls.cmbRecoveryPartitionDriveLetter.SelectedItem = ($State.Controls.cmbRecoveryPartitionDriveLetter.Items | Where-Object { $_.Content -eq $State.Defaults.generalDefaults.RecoveryPartitionDriveLetter })
$State.Controls.cmbLogicalSectorSize.SelectedItem = ($State.Controls.cmbLogicalSectorSize.Items | Where-Object { $_.Content -eq $State.Defaults.generalDefaults.LogicalSectorSize.ToString() }) $State.Controls.cmbLogicalSectorSize.SelectedItem = ($State.Controls.cmbLogicalSectorSize.Items | Where-Object { $_.Content -eq $State.Defaults.generalDefaults.LogicalSectorSize.ToString() })
# Populate Windows Release, Version, and SKU comboboxes # Populate Windows Release, Version, and SKU comboboxes
Get-WindowsSettingsCombos -isoPath $State.Defaults.windowsSettingsDefaults.DefaultISOPath -State $State # Initialize Windows settings combos based on media source mode
$initIsoPath = $State.Defaults.windowsSettingsDefaults.DefaultISOPath
if ($null -ne $State.Controls.rbProvideISO -and -not $State.Controls.rbProvideISO.IsChecked) {
$initIsoPath = ''
}
Get-WindowsSettingsCombos -isoPath $initIsoPath -State $State
# Windows Settings tab defaults # Windows Settings tab defaults
$State.Controls.cmbWindowsLang.ItemsSource = $State.Defaults.windowsSettingsDefaults.AllowedLanguages $State.Controls.cmbWindowsLang.ItemsSource = $State.Defaults.windowsSettingsDefaults.AllowedLanguages
@@ -303,6 +485,7 @@ function Initialize-UIDefaults {
$State.Controls.chkInstallApps.IsChecked = $State.Defaults.generalDefaults.InstallApps $State.Controls.chkInstallApps.IsChecked = $State.Defaults.generalDefaults.InstallApps
$State.Controls.txtApplicationPath.Text = $State.Defaults.generalDefaults.ApplicationPath $State.Controls.txtApplicationPath.Text = $State.Defaults.generalDefaults.ApplicationPath
$State.Controls.txtAppListJsonPath.Text = $State.Defaults.generalDefaults.AppListJsonPath $State.Controls.txtAppListJsonPath.Text = $State.Defaults.generalDefaults.AppListJsonPath
$State.Controls.txtUserAppListPath.Text = $State.Defaults.generalDefaults.UserAppListPath
$State.Controls.chkInstallWingetApps.IsChecked = $State.Defaults.generalDefaults.InstallWingetApps $State.Controls.chkInstallWingetApps.IsChecked = $State.Defaults.generalDefaults.InstallWingetApps
$State.Controls.chkBringYourOwnApps.IsChecked = $State.Defaults.generalDefaults.BringYourOwnApps $State.Controls.chkBringYourOwnApps.IsChecked = $State.Defaults.generalDefaults.BringYourOwnApps
@@ -343,31 +526,81 @@ function Initialize-UIDefaults {
# Set initial state for InstallApps checkbox based on updates # Set initial state for InstallApps checkbox based on updates
Update-InstallAppsState -State $State Update-InstallAppsState -State $State
# Set default theme mode and disable if Fluent is not supported
if ($null -ne $State.Controls.cmbThemeMode) {
$State.Controls.cmbThemeMode.SelectedItem = "System"
if (-not $State.Flags.isFluentSupported) {
$State.Controls.cmbThemeMode.IsEnabled = $false
$State.Controls.cmbThemeMode.Tag = "Fluent theme requires PowerShell 7.5+ (.NET 9+). Best experience on PowerShell 7.6+ (.NET 10)."
}
}
# Set default navigation selection to Home and initialize the shared page title
if ($null -ne $State.Controls.lstNavigation) {
$State.Controls.lstNavigation.SelectedIndex = 0
# Keep the shell header aligned with the selected navigation item on first render
if ($null -ne $State.Controls.txtPageTitle) {
$selectedNavigationItem = $State.Controls.lstNavigation.SelectedItem
if ($null -ne $selectedNavigationItem -and -not [string]::IsNullOrWhiteSpace([string]$selectedNavigationItem.Tag)) {
$State.Controls.txtPageTitle.Text = [string]$selectedNavigationItem.Tag
}
else {
$State.Controls.txtPageTitle.Text = 'Home'
}
}
}
# Set initial state for Office panel visibility # Set initial state for Office panel visibility
Update-OfficePanelVisibility -State $State Update-OfficePanelVisibility -State $State
# Set initial state for Application panel visibility # Set initial state for Application panel visibility
Update-ApplicationPanelVisibility -State $State Update-ApplicationPanelVisibility -State $State
# Set initial state for BYO Apps copy button # Set initial state for BYO Apps copy button
Update-CopyButtonState -State $State Update-CopyButtonState -State $State
}
# Apply accent color to primary action button only (per Windows design guidance)
if ($State.Flags.isFluentSupported) {
try {
$State.Controls.btnRun = $State.Window.FindName('btnRun')
if ($null -ne $State.Controls.btnRun) {
# Use SetResourceReference for live accent color updates when user changes Windows theme
$State.Controls.btnRun.SetResourceReference(
[System.Windows.Controls.Control]::BackgroundProperty,
[System.Windows.SystemColors]::AccentColorBrushKey
)
$State.Controls.btnRun.Foreground = [System.Windows.Media.Brushes]::White
}
}
catch {
WriteLog "Could not apply accent color to Build FFU button: $($_.Exception.Message)"
}
}
}
function Initialize-DynamicUIElements { function Initialize-DynamicUIElements {
param([PSCustomObject]$State) param([PSCustomObject]$State)
WriteLog "Initializing dynamic UI elements (Grids, Columns)..." WriteLog "Initializing dynamic UI elements (Grids, Columns)..."
# Get the Fluent base style for ListViewItem in GridView mode
# Must use GridViewItemContainerStyleKey (not the generic ListViewItem type key) because the
# generic Fluent ListViewItem style has a template without GridViewRowPresenter, which breaks
# column-based rendering and causes items to display their ToString() representation.
$listViewItemBaseStyle = $State.Window.TryFindResource([System.Windows.Controls.GridView]::GridViewItemContainerStyleKey)
# Driver Models ListView setup # Driver Models ListView setup
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell # Set ListViewItem style to stretch content horizontally so cell templates fill the cell
$itemStyleDriverModels = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem]) $itemStyleDriverModels = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
if ($null -ne $listViewItemBaseStyle) { $itemStyleDriverModels.BasedOn = $listViewItemBaseStyle }
$itemStyleDriverModels.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch))) $itemStyleDriverModels.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstDriverModels.ItemContainerStyle = $itemStyleDriverModels $State.Controls.lstDriverModels.ItemContainerStyle = $itemStyleDriverModels
$driverModelsGridView = New-Object System.Windows.Controls.GridView $driverModelsGridView = New-Object System.Windows.Controls.GridView
$State.Controls.lstDriverModels.View = $driverModelsGridView # Assign GridView to ListView first $State.Controls.lstDriverModels.View = $driverModelsGridView # Assign GridView to ListView first
# Add the selectable column using the new function # Add the selectable column and scope header select-all to visible filtered rows.
Add-SelectableGridViewColumn -ListView $State.Controls.lstDriverModels -State $State -HeaderCheckBoxKeyName "chkSelectAllDriverModels" -ColumnWidth 70 Add-SelectableGridViewColumn -ListView $State.Controls.lstDriverModels -State $State -HeaderCheckBoxKeyName "chkSelectAllDriverModels" -ColumnWidth 70 -HeaderSelectionAffectsVisibleItemsOnly
# Add other sortable columns with left-aligned headers # Add other sortable columns with left-aligned headers
Add-SortableColumn -gridView $driverModelsGridView -header "Make" -binding "Make" -width 100 -headerHorizontalAlignment Left Add-SortableColumn -gridView $driverModelsGridView -header "Make" -binding "Make" -width 100 -headerHorizontalAlignment Left
@@ -389,12 +622,16 @@ function Initialize-DynamicUIElements {
} }
) )
# Keep driver model columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstDriverModels -FixedColumnIndexes @(0)
# Winget Search ListView setup # Winget Search ListView setup
$wingetGridView = New-Object System.Windows.Controls.GridView $wingetGridView = New-Object System.Windows.Controls.GridView
$State.Controls.lstWingetResults.View = $wingetGridView # Assign GridView to ListView first $State.Controls.lstWingetResults.View = $wingetGridView # Assign GridView to ListView first
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell # Set ListViewItem style to stretch content horizontally so cell templates fill the cell
$itemStyleWingetResults = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem]) $itemStyleWingetResults = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
if ($null -ne $listViewItemBaseStyle) { $itemStyleWingetResults.BasedOn = $listViewItemBaseStyle }
$itemStyleWingetResults.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch))) $itemStyleWingetResults.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstWingetResults.ItemContainerStyle = $itemStyleWingetResults $State.Controls.lstWingetResults.ItemContainerStyle = $itemStyleWingetResults
@@ -442,9 +679,11 @@ function Initialize-DynamicUIElements {
$binding.Mode = [System.Windows.Data.BindingMode]::TwoWay $binding.Mode = [System.Windows.Data.BindingMode]::TwoWay
$comboBoxFactory.SetBinding([System.Windows.Controls.ComboBox]::TextProperty, $binding) $comboBoxFactory.SetBinding([System.Windows.Controls.ComboBox]::TextProperty, $binding)
# Create a style to disable the ComboBox for 'msstore' source # Create a style to disable the ComboBox for 'msstore' source, inheriting the Fluent base style
$comboBoxStyle = New-Object System.Windows.Style $comboBoxStyle = New-Object System.Windows.Style
$comboBoxStyle.TargetType = [System.Windows.Controls.ComboBox] $comboBoxStyle.TargetType = [System.Windows.Controls.ComboBox]
$comboBoxBaseStyle = $State.Window.TryFindResource([System.Windows.Controls.ComboBox])
if ($null -ne $comboBoxBaseStyle) { $comboBoxStyle.BasedOn = $comboBoxBaseStyle }
$dataTrigger = New-Object System.Windows.DataTrigger $dataTrigger = New-Object System.Windows.DataTrigger
$dataTrigger.Binding = New-Object System.Windows.Data.Binding("Source") $dataTrigger.Binding = New-Object System.Windows.Data.Binding("Source")
@@ -545,12 +784,16 @@ function Initialize-DynamicUIElements {
} }
) )
# Keep Winget result columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults -FixedColumnIndexes @(0)
# BYO Applications ListView setup # BYO Applications ListView setup
$byoAppsGridView = New-Object System.Windows.Controls.GridView $byoAppsGridView = New-Object System.Windows.Controls.GridView
$State.Controls.lstApplications.View = $byoAppsGridView $State.Controls.lstApplications.View = $byoAppsGridView
# Set ListViewItem style to stretch content horizontally # Set ListViewItem style to stretch content horizontally
$itemStyleBYOApps = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem]) $itemStyleBYOApps = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
if ($null -ne $listViewItemBaseStyle) { $itemStyleBYOApps.BasedOn = $listViewItemBaseStyle }
$itemStyleBYOApps.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch))) $itemStyleBYOApps.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstApplications.ItemContainerStyle = $itemStyleBYOApps $State.Controls.lstApplications.ItemContainerStyle = $itemStyleBYOApps
@@ -567,12 +810,16 @@ function Initialize-DynamicUIElements {
Add-SortableColumn -gridView $byoAppsGridView -header "Ignore Exit Codes" -binding "IgnoreExitCodes" -width 120 -headerHorizontalAlignment Left Add-SortableColumn -gridView $byoAppsGridView -header "Ignore Exit Codes" -binding "IgnoreExitCodes" -width 120 -headerHorizontalAlignment Left
Add-SortableColumn -gridView $byoAppsGridView -header "Copy Status" -binding "CopyStatus" -width 150 -headerHorizontalAlignment Left Add-SortableColumn -gridView $byoAppsGridView -header "Copy Status" -binding "CopyStatus" -width 150 -headerHorizontalAlignment Left
# Keep BYO application columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstApplications -FixedColumnIndexes @(0)
# Apps Script Variables ListView setup # Apps Script Variables ListView setup
# Bind ItemsSource to the data list # Bind ItemsSource to the data list
$State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray() $State.Controls.lstAppsScriptVariables.ItemsSource = $State.Data.appsScriptVariablesDataList.ToArray()
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell # Set ListViewItem style to stretch content horizontally so cell templates fill the cell
$itemStyleAppsScriptVars = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem]) $itemStyleAppsScriptVars = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
if ($null -ne $listViewItemBaseStyle) { $itemStyleAppsScriptVars.BasedOn = $listViewItemBaseStyle }
$itemStyleAppsScriptVars.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch))) $itemStyleAppsScriptVars.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstAppsScriptVariables.ItemContainerStyle = $itemStyleAppsScriptVars $State.Controls.lstAppsScriptVariables.ItemContainerStyle = $itemStyleAppsScriptVars
@@ -619,6 +866,9 @@ function Initialize-DynamicUIElements {
} }
} }
) )
# Keep apps script variable columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstAppsScriptVariables -FixedColumnIndexes @(0)
} }
else { else {
WriteLog "Warning: lstAppsScriptVariables.View is not a GridView. Selectable column not added, and sorting cannot be enabled." WriteLog "Warning: lstAppsScriptVariables.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
@@ -635,6 +885,7 @@ function Initialize-DynamicUIElements {
# USB Drives ListView setup # USB Drives ListView setup
# Set ListViewItem style to stretch content horizontally so cell templates fill the cell # Set ListViewItem style to stretch content horizontally so cell templates fill the cell
$itemStyleUSBDrives = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem]) $itemStyleUSBDrives = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem])
if ($null -ne $listViewItemBaseStyle) { $itemStyleUSBDrives.BasedOn = $listViewItemBaseStyle }
$itemStyleUSBDrives.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch))) $itemStyleUSBDrives.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstUSBDrives.ItemContainerStyle = $itemStyleUSBDrives $State.Controls.lstUSBDrives.ItemContainerStyle = $itemStyleUSBDrives
@@ -691,6 +942,9 @@ function Initialize-DynamicUIElements {
} }
} }
) )
# Keep USB drive columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstUSBDrives -FixedColumnIndexes @(0)
} }
else { else {
WriteLog "Warning: lstUSBDrives.View is not a GridView. Selectable column not added, and sorting cannot be enabled." WriteLog "Warning: lstUSBDrives.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
@@ -698,6 +952,7 @@ function Initialize-DynamicUIElements {
# Additional FFUs ListView setup # Additional FFUs ListView setup
$itemStyleAdditionalFFUs = New-Object System.Windows.Style([System.Windows.Controls.ListViewItem]) $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))) $itemStyleAdditionalFFUs.Setters.Add((New-Object System.Windows.Setter([System.Windows.Controls.ListViewItem]::HorizontalContentAlignmentProperty, [System.Windows.HorizontalAlignment]::Stretch)))
$State.Controls.lstAdditionalFFUs.ItemContainerStyle = $itemStyleAdditionalFFUs $State.Controls.lstAdditionalFFUs.ItemContainerStyle = $itemStyleAdditionalFFUs
@@ -736,6 +991,9 @@ function Initialize-DynamicUIElements {
} }
} }
) )
# Keep additional FFU columns sized to the current visible content.
Enable-ListViewColumnAutoResize -ListView $State.Controls.lstAdditionalFFUs -FixedColumnIndexes @(0)
} }
else { else {
WriteLog "Warning: lstAdditionalFFUs.View is not a GridView. Selectable column not added, and sorting cannot be enabled." WriteLog "Warning: lstAdditionalFFUs.View is not a GridView. Selectable column not added, and sorting cannot be enabled."
+300 -17
View File
@@ -20,6 +20,7 @@ function Update-ListViewPriorities {
} }
} }
$ListView.Items.Refresh() $ListView.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $ListView
} }
# Function to move selected item to the top # Function to move selected item to the top
@@ -133,6 +134,7 @@ function Update-ListViewItemStatus {
if ($null -ne $itemToUpdate) { if ($null -ne $itemToUpdate) {
$itemToUpdate.$StatusProperty = $StatusValue $itemToUpdate.$StatusProperty = $StatusValue
$ListView.Items.Refresh() # Refresh the view to show the change $ListView.Items.Refresh() # Refresh the view to show the change
Request-ListViewColumnAutoResize -ListView $ListView
} }
else { else {
# Log if item not found (for debugging) # Log if item not found (for debugging)
@@ -329,7 +331,8 @@ function Add-SelectableGridViewColumn {
[string]$HeaderCheckBoxKeyName, [string]$HeaderCheckBoxKeyName,
[Parameter(Mandatory)] [Parameter(Mandatory)]
[double]$ColumnWidth, [double]$ColumnWidth,
[string]$IsSelectedPropertyName = "IsSelected" [string]$IsSelectedPropertyName = "IsSelected",
[switch]$HeaderSelectionAffectsVisibleItemsOnly
) )
# Ensure the ListView has a GridView # Ensure the ListView has a GridView
@@ -342,11 +345,13 @@ function Add-SelectableGridViewColumn {
# Create the "Select All" CheckBox for the header # Create the "Select All" CheckBox for the header
$headerCheckBox = New-Object System.Windows.Controls.CheckBox $headerCheckBox = New-Object System.Windows.Controls.CheckBox
$headerCheckBox.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center $headerCheckBox.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Center
$headerCheckBox.VerticalAlignment = [System.Windows.VerticalAlignment]::Center
# MODIFICATION: Store the actual ListView object in the header's Tag # Store header metadata, including whether select-all should only affect visible rows.
$headerTagObject = [PSCustomObject]@{ $headerTagObject = [PSCustomObject]@{
PropertyName = $IsSelectedPropertyName PropertyName = $IsSelectedPropertyName
ListViewControl = $ListView ListViewControl = $ListView
HeaderSelectionAffectsVisibleItemsOnly = [bool]$HeaderSelectionAffectsVisibleItemsOnly
} }
$headerCheckBox.Tag = $headerTagObject $headerCheckBox.Tag = $headerTagObject
@@ -356,8 +361,24 @@ function Add-SelectableGridViewColumn {
$localPropertyName = $tagData.PropertyName $localPropertyName = $tagData.PropertyName
$actualListView = $tagData.ListViewControl $actualListView = $tagData.ListViewControl
$collectionToUpdate = if ($null -ne $actualListView.ItemsSource) { $actualListView.ItemsSource } else { $actualListView.Items } # Select either visible view items only (filtered scope) or the full backing list.
if ($null -ne $collectionToUpdate) { $collectionToUpdate = @()
if ($tagData.HeaderSelectionAffectsVisibleItemsOnly -and $null -ne $actualListView.ItemsSource) {
$collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($actualListView.ItemsSource)
if ($null -ne $collectionView) {
foreach ($visibleItem in $collectionView) {
$collectionToUpdate += $visibleItem
}
}
}
elseif ($null -ne $actualListView.ItemsSource) {
$collectionToUpdate = @($actualListView.ItemsSource)
}
elseif ($actualListView.HasItems) {
$collectionToUpdate = @($actualListView.Items)
}
if ($collectionToUpdate.Count -gt 0) {
foreach ($item in $collectionToUpdate) { $item.$($localPropertyName) = $true } foreach ($item in $collectionToUpdate) { $item.$($localPropertyName) = $true }
$actualListView.Items.Refresh() $actualListView.Items.Refresh()
} }
@@ -370,19 +391,57 @@ function Add-SelectableGridViewColumn {
$localPropertyName = $tagData.PropertyName $localPropertyName = $tagData.PropertyName
$actualListView = $tagData.ListViewControl $actualListView = $tagData.ListViewControl
$collectionToUpdate = if ($null -ne $actualListView.ItemsSource) { $actualListView.ItemsSource } else { $actualListView.Items } # Clear either visible view items only (filtered scope) or the full backing list.
if ($null -ne $collectionToUpdate) { $collectionToUpdate = @()
if ($tagData.HeaderSelectionAffectsVisibleItemsOnly -and $null -ne $actualListView.ItemsSource) {
$collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($actualListView.ItemsSource)
if ($null -ne $collectionView) {
foreach ($visibleItem in $collectionView) {
$collectionToUpdate += $visibleItem
}
}
}
elseif ($null -ne $actualListView.ItemsSource) {
$collectionToUpdate = @($actualListView.ItemsSource)
}
elseif ($actualListView.HasItems) {
$collectionToUpdate = @($actualListView.Items)
}
if ($collectionToUpdate.Count -gt 0) {
foreach ($item in $collectionToUpdate) { $item.$($localPropertyName) = $false } foreach ($item in $collectionToUpdate) { $item.$($localPropertyName) = $false }
$actualListView.Items.Refresh() $actualListView.Items.Refresh()
} }
} }
}) })
# Wrap the header checkbox in a stretched container so it centers the same way as row cells.
# Apply a small left inset to mirror the Fluent ListViewItem content padding used by data rows.
$headerBorder = New-Object System.Windows.Controls.Border
$headerBorder.Padding = New-Object System.Windows.Thickness(12, 0, 0, 0)
$headerBorder.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Stretch
$headerBorder.VerticalAlignment = [System.Windows.VerticalAlignment]::Stretch
$headerGrid = New-Object System.Windows.Controls.Grid
$headerGrid.HorizontalAlignment = [System.Windows.HorizontalAlignment]::Stretch
$headerGrid.VerticalAlignment = [System.Windows.VerticalAlignment]::Stretch
$headerGrid.Children.Add($headerCheckBox) | Out-Null
$headerBorder.Child = $headerGrid
# Use an explicit GridViewColumnHeader so we can remove the default header padding
# and control the checkbox alignment explicitly.
$selectableHeader = New-Object System.Windows.Controls.GridViewColumnHeader
$selectableHeader.HorizontalContentAlignment = [System.Windows.HorizontalAlignment]::Stretch
$selectableHeader.VerticalContentAlignment = [System.Windows.VerticalAlignment]::Stretch
$selectableHeader.Padding = New-Object System.Windows.Thickness(0)
$selectableHeader.Margin = New-Object System.Windows.Thickness(0)
$selectableHeader.Content = $headerBorder
$State.Controls[$HeaderCheckBoxKeyName] = $headerCheckBox $State.Controls[$HeaderCheckBoxKeyName] = $headerCheckBox
WriteLog "Add-SelectableGridViewColumn: Stored header checkbox in State.Controls with key '$HeaderCheckBoxKeyName'." WriteLog "Add-SelectableGridViewColumn: Stored header checkbox in State.Controls with key '$HeaderCheckBoxKeyName'."
$selectableColumn = New-Object System.Windows.Controls.GridViewColumn $selectableColumn = New-Object System.Windows.Controls.GridViewColumn
$selectableColumn.Header = $headerCheckBox $selectableColumn.Header = $selectableHeader
$selectableColumn.Width = $ColumnWidth $selectableColumn.Width = $ColumnWidth
$cellTemplate = New-Object System.Windows.DataTemplate $cellTemplate = New-Object System.Windows.DataTemplate
@@ -437,6 +496,209 @@ function Add-SelectableGridViewColumn {
WriteLog "Add-SelectableGridViewColumn: Successfully added selectable column to '$($ListView.Name)'." WriteLog "Add-SelectableGridViewColumn: Successfully added selectable column to '$($ListView.Name)'."
} }
# Function to request a deferred GridView column auto-size pass
function Request-ListViewColumnAutoResize {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[System.Windows.Controls.ListView]$ListView
)
# Skip startup calls until the visual tree has finished loading.
if (-not $ListView.IsLoaded) {
return
}
# Ensure the ListView has registered auto-resize metadata before scheduling work.
$autoResizeStateKey = 'FFU.ListViewColumnAutoResizeState'
if (-not $ListView.Resources.Contains($autoResizeStateKey)) {
return
}
if (-not ($ListView.View -is [System.Windows.Controls.GridView])) {
return
}
$autoResizeState = $ListView.Resources[$autoResizeStateKey]
if ($autoResizeState.ResizePending) {
return
}
$autoResizeState.ResizePending = $true
$previousErrorActionPreference = $ErrorActionPreference
try {
$ErrorActionPreference = 'Stop'
$gridView = [System.Windows.Controls.GridView]$ListView.View
$fixedColumnIndexes = @($autoResizeState.FixedColumnIndexes)
$visibleItems = [System.Collections.Generic.List[object]]::new()
if ($null -ne $ListView.ItemsSource) {
$collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($ListView.ItemsSource)
if ($null -ne $collectionView) {
foreach ($visibleItem in $collectionView) {
$visibleItems.Add($visibleItem)
}
}
}
else {
foreach ($visibleItem in $ListView.Items) {
$visibleItems.Add($visibleItem)
}
}
$ListView.UpdateLayout()
$columnIndex = 0
foreach ($column in $gridView.Columns) {
if ($fixedColumnIndexes -contains $columnIndex) {
$columnIndex++
continue
}
if ($null -eq $column) {
$columnIndex++
continue
}
$headerText = ""
$propertyName = $null
if ($null -ne $column.DisplayMemberBinding -and $null -ne $column.DisplayMemberBinding.Path) {
$propertyName = [string]$column.DisplayMemberBinding.Path.Path
}
if ($column.Header -is [System.Windows.Controls.GridViewColumnHeader]) {
if (-not [string]::IsNullOrWhiteSpace([string]$column.Header.Content)) {
$headerText = [string]$column.Header.Content
}
if ([string]::IsNullOrWhiteSpace($propertyName) -and -not [string]::IsNullOrWhiteSpace([string]$column.Header.Tag)) {
$propertyName = [string]$column.Header.Tag
}
}
elseif (-not [string]::IsNullOrWhiteSpace([string]$column.Header)) {
$headerText = [string]$column.Header
}
if ([string]::IsNullOrWhiteSpace($headerText)) {
$headerText = $propertyName
}
$headerMeasureBlock = New-Object System.Windows.Controls.TextBlock
$headerMeasureBlock.Text = if ([string]::IsNullOrWhiteSpace($headerText)) { ' ' } else { $headerText }
$headerMeasureBlock.FontFamily = $ListView.FontFamily
$headerMeasureBlock.FontSize = $ListView.FontSize
$headerMeasureBlock.FontStyle = $ListView.FontStyle
$headerMeasureBlock.FontWeight = $ListView.FontWeight
$headerMeasureBlock.FontStretch = $ListView.FontStretch
$headerMeasureBlock.Measure([System.Windows.Size]::new([double]::PositiveInfinity, [double]::PositiveInfinity))
$calculatedWidth = [math]::Ceiling($headerMeasureBlock.DesiredSize.Width + 36)
foreach ($item in $visibleItems) {
if ($null -eq $item -or [string]::IsNullOrWhiteSpace($propertyName)) {
continue
}
$itemProperty = $null
if ($null -ne $item.PSObject -and $null -ne $item.PSObject.Properties) {
$matchedProperties = $item.PSObject.Properties.Match($propertyName)
if ($null -ne $matchedProperties -and $matchedProperties.Count -gt 0) {
$itemProperty = $matchedProperties | Select-Object -First 1
}
}
if ($null -eq $itemProperty) {
continue
}
$itemText = [string]$itemProperty.Value
$extraWidth = 28
switch ($propertyName) {
'Architecture' {
$extraWidth = 52
}
'AdditionalExitCodes' {
$extraWidth = 44
}
'IgnoreNonZeroExitCodes' {
$itemText = ' '
$extraWidth = 48
}
'IgnoreExitCodes' {
$extraWidth = 28
}
}
$itemMeasureBlock = New-Object System.Windows.Controls.TextBlock
$itemMeasureBlock.Text = if ([string]::IsNullOrWhiteSpace($itemText)) { ' ' } else { $itemText }
$itemMeasureBlock.FontFamily = $ListView.FontFamily
$itemMeasureBlock.FontSize = $ListView.FontSize
$itemMeasureBlock.FontStyle = $ListView.FontStyle
$itemMeasureBlock.FontWeight = $ListView.FontWeight
$itemMeasureBlock.FontStretch = $ListView.FontStretch
$itemMeasureBlock.Measure([System.Windows.Size]::new([double]::PositiveInfinity, [double]::PositiveInfinity))
$itemWidth = [math]::Ceiling($itemMeasureBlock.DesiredSize.Width + $extraWidth)
if ($itemWidth -gt $calculatedWidth) {
$calculatedWidth = $itemWidth
}
}
if ($propertyName -eq 'IgnoreNonZeroExitCodes') {
$calculatedWidth = [math]::Max($calculatedWidth, 120)
}
$column.Width = [math]::Max($calculatedWidth, 40)
$columnIndex++
}
}
catch {
WriteLog "Request-ListViewColumnAutoResize: Failed for '$($ListView.Name)': $($_.Exception.Message)"
if (-not [string]::IsNullOrWhiteSpace($_.InvocationInfo.PositionMessage)) {
WriteLog $_.InvocationInfo.PositionMessage
}
if (-not [string]::IsNullOrWhiteSpace($_.ScriptStackTrace)) {
WriteLog $_.ScriptStackTrace
}
}
finally {
$ErrorActionPreference = $previousErrorActionPreference
$autoResizeState.ResizePending = $false
}
}
# Function to enable reusable auto-resizing for GridView-backed ListViews
function Enable-ListViewColumnAutoResize {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[System.Windows.Controls.ListView]$ListView,
[int[]]$FixedColumnIndexes = @()
)
$autoResizeStateKey = 'FFU.ListViewColumnAutoResizeState'
# Only GridView-backed lists can participate in column auto-sizing.
if (-not ($ListView.View -is [System.Windows.Controls.GridView])) {
WriteLog "Enable-ListViewColumnAutoResize: ListView '$($ListView.Name)' is not using a GridView. Skipping registration."
return
}
if ($ListView.Resources.Contains($autoResizeStateKey)) {
return
}
$autoResizeState = [PSCustomObject]@{
FixedColumnIndexes = @($FixedColumnIndexes)
ResizePending = $false
}
$ListView.Resources[$autoResizeStateKey] = $autoResizeState
}
# Function to update the IsChecked state of a "Select All" header CheckBox # Function to update the IsChecked state of a "Select All" header CheckBox
function Update-SelectAllHeaderCheckBoxState { function Update-SelectAllHeaderCheckBoxState {
param( param(
@@ -446,24 +708,38 @@ function Update-SelectAllHeaderCheckBoxState {
[System.Windows.Controls.CheckBox]$HeaderCheckBox [System.Windows.Controls.CheckBox]$HeaderCheckBox
) )
$collectionToInspect = $null # Determine whether this header should evaluate only visible (filtered) rows.
if ($null -ne $ListView.ItemsSource) { $inspectVisibleItemsOnly = $false
if ($null -ne $HeaderCheckBox.Tag -and $null -ne $HeaderCheckBox.Tag.PSObject.Properties['HeaderSelectionAffectsVisibleItemsOnly']) {
$inspectVisibleItemsOnly = [bool]$HeaderCheckBox.Tag.HeaderSelectionAffectsVisibleItemsOnly
}
# Build the collection to inspect based on scope (visible view vs full source).
$collectionToInspect = @()
if ($inspectVisibleItemsOnly -and $null -ne $ListView.ItemsSource) {
$collectionView = [System.Windows.Data.CollectionViewSource]::GetDefaultView($ListView.ItemsSource)
if ($null -ne $collectionView) {
foreach ($visibleItem in $collectionView) {
$collectionToInspect += $visibleItem
}
}
}
elseif ($null -ne $ListView.ItemsSource) {
$collectionToInspect = @($ListView.ItemsSource) $collectionToInspect = @($ListView.ItemsSource)
} }
elseif ($ListView.HasItems) { elseif ($ListView.HasItems) {
# Check if Items collection has items and ItemsSource is null
$collectionToInspect = @($ListView.Items) $collectionToInspect = @($ListView.Items)
} }
# If no items to inspect (either ItemsSource was null and Items was empty, or ItemsSource was empty) # If no items are available in the selected scope, force unchecked.
if ($null -eq $collectionToInspect -or $collectionToInspect.Count -eq 0) { if ($collectionToInspect.Count -eq 0) {
$HeaderCheckBox.IsChecked = $false $HeaderCheckBox.IsChecked = $false
return return
} }
$selectedCount = ($collectionToInspect | Where-Object { $_.IsSelected }).Count $selectedCount = ($collectionToInspect | Where-Object { $_.IsSelected }).Count
WriteLog "Update-SelectAllHeaderCheckBoxState: Selected count is $selectedCount for ListView '$($ListView.Name)'." WriteLog "Update-SelectAllHeaderCheckBoxState: Selected count is $selectedCount for ListView '$($ListView.Name)'."
$totalItemCount = $collectionToInspect.Count # Get the total count from the collection being inspected $totalItemCount = $collectionToInspect.Count
WriteLog "Update-SelectAllHeaderCheckBoxState: Total item count is $totalItemCount for ListView '$($ListView.Name)'." WriteLog "Update-SelectAllHeaderCheckBoxState: Total item count is $totalItemCount for ListView '$($ListView.Name)'."
if ($totalItemCount -eq 0) { if ($totalItemCount -eq 0) {
@@ -502,6 +778,7 @@ function Invoke-ListViewItemToggle {
# Toggle the IsSelected property # Toggle the IsSelected property
$selectedItem.IsSelected = -not $selectedItem.IsSelected $selectedItem.IsSelected = -not $selectedItem.IsSelected
$ListView.Items.Refresh() $ListView.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $ListView
# Update the 'Select All' header checkbox state # Update the 'Select All' header checkbox state
$headerChk = $State.Controls[$HeaderCheckBoxKeyName] $headerChk = $State.Controls[$HeaderCheckBoxKeyName]
@@ -597,7 +874,7 @@ function Invoke-ListViewSort {
$val = $_.$property $val = $_.$property
if ($null -eq $val) { '' } else { $val } if ($null -eq $val) { '' } else { $val }
} }
Ascending = $State.Flags.lastSortAscending Ascending = $State.Flags.lastSortAscending
} }
$sortCriteria = [System.Collections.Generic.List[hashtable]]::new() $sortCriteria = [System.Collections.Generic.List[hashtable]]::new()
@@ -644,7 +921,7 @@ function Invoke-ListViewSort {
$val = Invoke-Command -ScriptBlock $expressionScriptBlock -ArgumentList $_ $val = Invoke-Command -ScriptBlock $expressionScriptBlock -ArgumentList $_
if ($null -eq $val) { '' } else { $val } if ($null -eq $val) { '' } else { $val }
} }
Ascending = $true Ascending = $true
} }
$sortCriteria.Add($secondarySortDefinition) $sortCriteria.Add($secondarySortDefinition)
} }
@@ -672,6 +949,8 @@ function Invoke-ListViewSort {
$newView.Filter = $existingFilter $newView.Filter = $existingFilter
} }
} }
Request-ListViewColumnAutoResize -ListView $listView
} }
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -894,6 +1173,9 @@ function Invoke-BrowseAction {
if (-not [string]::IsNullOrWhiteSpace($InitialDirectory)) { if (-not [string]::IsNullOrWhiteSpace($InitialDirectory)) {
$dialog.InitialDirectory = $InitialDirectory $dialog.InitialDirectory = $InitialDirectory
} }
if (-not [string]::IsNullOrWhiteSpace($FileName)) {
$dialog.FileName = $FileName
}
if ($dialog.ShowDialog()) { if ($dialog.ShowDialog()) {
return $dialog.FileName return $dialog.FileName
} }
@@ -972,6 +1254,7 @@ function Clear-ListViewContent {
} }
$ListViewControl.Items.Refresh() $ListViewControl.Items.Refresh()
Request-ListViewColumnAutoResize -ListView $ListViewControl
# Clear any specified textboxes # Clear any specified textboxes
if ($null -ne $TextBoxesToClear) { if ($null -ne $TextBoxesToClear) {
@@ -59,6 +59,7 @@ function Search-WingetApps {
# Update the ListView's ItemsSource using the passed-in State object # Update the ListView's ItemsSource using the passed-in State object
$State.Controls.lstWingetResults.ItemsSource = $finalAppList.ToArray() $State.Controls.lstWingetResults.ItemsSource = $finalAppList.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
# Update status text # Update status text
$statusText = "" $statusText = ""
@@ -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 = New-Object System.Windows.Forms.SaveFileDialog
$sfd.Filter = "JSON files (*.json)|*.json" $sfd.Filter = "JSON files (*.json)|*.json"
$sfd.Title = "Save App List" $sfd.Title = "Save Winget App List"
# Correctly get the path from the UI control via the State object $sfd.InitialDirectory = $initialDirectory
$sfd.InitialDirectory = $State.Controls.txtApplicationPath.Text $sfd.FileName = $fileName
$sfd.FileName = "AppList.json"
if ($sfd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { if ($sfd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
$appList | ConvertTo-Json -Depth 10 | Set-Content $sfd.FileName -Encoding UTF8 $appList | ConvertTo-Json -Depth 10 | Set-Content $sfd.FileName -Encoding UTF8
[System.Windows.MessageBox]::Show("App list saved successfully.", "Success", "OK", "Information") $State.Controls.txtAppListJsonPath.Text = $sfd.FileName
[System.Windows.MessageBox]::Show("Winget app list saved successfully.", "Success", "OK", "Information")
} }
} }
catch { catch {
[System.Windows.MessageBox]::Show("Error saving app list: $_", "Error", "OK", "Error") [System.Windows.MessageBox]::Show("Error saving Winget app list: $_", "Error", "OK", "Error")
} }
} }
@@ -132,11 +141,17 @@ function Import-WingetList {
[psobject]$State [psobject]$State
) )
try { try {
# Default the import dialog to the configured Winget app list path.
$currentPath = $State.Controls.txtAppListJsonPath.Text
$initialDirectory = if (-not [string]::IsNullOrWhiteSpace($currentPath)) { Split-Path -Path $currentPath -Parent } else { $State.Controls.txtApplicationPath.Text }
if ([string]::IsNullOrWhiteSpace($initialDirectory) -or -not (Test-Path -Path $initialDirectory -PathType Container)) {
$initialDirectory = $State.Controls.txtApplicationPath.Text
}
$ofd = New-Object System.Windows.Forms.OpenFileDialog $ofd = New-Object System.Windows.Forms.OpenFileDialog
$ofd.Filter = "JSON files (*.json)|*.json" $ofd.Filter = "JSON files (*.json)|*.json"
$ofd.Title = "Import App List" $ofd.Title = "Import Winget App List"
# Correctly get the path from the UI control via the State object $ofd.InitialDirectory = $initialDirectory
$ofd.InitialDirectory = $State.Controls.txtApplicationPath.Text
if ($ofd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { if ($ofd.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
$importedAppsData = Get-Content $ofd.FileName -Raw | ConvertFrom-Json $importedAppsData = Get-Content $ofd.FileName -Raw | ConvertFrom-Json
@@ -144,16 +159,16 @@ function Import-WingetList {
$newAppListForItemsSource = [System.Collections.Generic.List[object]]::new() $newAppListForItemsSource = [System.Collections.Generic.List[object]]::new()
if ($null -ne $importedAppsData.apps) { if ($null -ne $importedAppsData.apps) {
# Get default architecture from the UI for fallback # Get default architecture from the UI for fallback.
$defaultArch = $State.Controls.cmbWindowsArch.SelectedItem $defaultArch = $State.Controls.cmbWindowsArch.SelectedItem
foreach ($appInfo in $importedAppsData.apps) { foreach ($appInfo in $importedAppsData.apps) {
$arch = if ($appInfo.source -eq 'msstore') { 'NA' } else { if ($appInfo.PSObject.Properties['architecture']) { $appInfo.architecture } else { $defaultArch } } $arch = if ($appInfo.source -eq 'msstore') { 'NA' } else { if ($appInfo.PSObject.Properties['architecture']) { $appInfo.architecture } else { $defaultArch } }
$newAppListForItemsSource.Add([PSCustomObject]@{ $newAppListForItemsSource.Add([PSCustomObject]@{
IsSelected = $true # Imported apps are marked as selected IsSelected = $true
Name = $appInfo.name Name = $appInfo.name
Id = $appInfo.id Id = $appInfo.id
Version = "" # Will be populated when searching or if data exists Version = ""
Source = $appInfo.source Source = $appInfo.source
Architecture = $arch Architecture = $arch
AdditionalExitCodes = if ($appInfo.PSObject.Properties['AdditionalExitCodes']) { $appInfo.AdditionalExitCodes } else { "" } AdditionalExitCodes = if ($appInfo.PSObject.Properties['AdditionalExitCodes']) { $appInfo.AdditionalExitCodes } else { "" }
@@ -164,12 +179,14 @@ function Import-WingetList {
} }
$State.Controls.lstWingetResults.ItemsSource = $newAppListForItemsSource.ToArray() $State.Controls.lstWingetResults.ItemsSource = $newAppListForItemsSource.ToArray()
Request-ListViewColumnAutoResize -ListView $State.Controls.lstWingetResults
$State.Controls.txtAppListJsonPath.Text = $ofd.FileName
[System.Windows.MessageBox]::Show("App list imported successfully.", "Success", "OK", "Information") [System.Windows.MessageBox]::Show("Winget app list imported successfully.", "Success", "OK", "Information")
} }
} }
catch { catch {
[System.Windows.MessageBox]::Show("Error importing app list: $_", "Error", "OK", "Error") [System.Windows.MessageBox]::Show("Error importing Winget app list: $_", "Error", "OK", "Error")
} }
} }
@@ -216,44 +233,6 @@ function Search-WingetPackagesPublic {
} }
} }
function Test-WingetCLI {
[CmdletBinding()]
param()
$minVersion = [version]"1.8.1911"
# Check Winget CLI
$wingetCmd = Get-Command -Name winget -ErrorAction SilentlyContinue
if (-not $wingetCmd) {
return @{
Version = "Not installed"
Status = "Not installed - Install from Microsoft Store"
}
}
# Get and check version
$wingetVersion = & winget.exe --version
if ($wingetVersion -match 'v?(\d+\.\d+.\d+)') {
$version = [version]$matches[1]
if ($version -lt $minVersion) {
return @{
Version = $version.ToString()
Status = "Update required - Install from Microsoft Store"
}
}
return @{
Version = $version.ToString()
Status = $version.ToString()
}
}
return @{
Version = "Unknown"
Status = "Version check failed"
}
}
function Install-WingetComponents { function Install-WingetComponents {
[CmdletBinding()] [CmdletBinding()]
param( param(
@@ -322,19 +301,22 @@ function Confirm-WingetInstallationUI {
try { try {
# Initial Check # Initial Check
WriteLog "Confirm-WingetInstallationUI: Starting checks..." WriteLog "Confirm-WingetInstallationUI: Starting checks..."
$cliStatus = Test-WingetCLI $wingetStatus = Get-WinGetComponentStatus -MinimumVersion $minVersion
$module = Get-InstalledModule -Name Microsoft.WinGet.Client -ErrorAction SilentlyContinue
$result.CliVersion = $cliStatus.Version $result.CliVersion = $wingetStatus.WinGetVersion
$result.ModuleVersion = if ($null -ne $module) { $module.Version.ToString() } else { "Not installed" } $result.ModuleVersion = $wingetStatus.ModuleVersion
# Use callback for initial status display # Use callback for initial status display
& $UiUpdateCallback $result.CliVersion $result.ModuleVersion & $UiUpdateCallback $result.CliVersion $result.ModuleVersion
# Determine if install/update is needed # Determine if install/update is needed
$needsCliUpdate = $cliStatus.Status -notmatch '^\d+\.\d+\.\d+$' -or ([version]$cliStatus.Version -lt $minVersion) $needsCliUpdate = $wingetStatus.WinGetNeedsUpdate
$needsModuleUpdate = ($null -eq $module) -or ([version]$module.Version -lt $minVersion) $needsModuleUpdate = $wingetStatus.ModuleNeedsUpdate
$result.NeedsUpdate = $needsCliUpdate -or $needsModuleUpdate $result.NeedsUpdate = $wingetStatus.NeedsUpdate
if (-not [string]::IsNullOrWhiteSpace($wingetStatus.ErrorMessage)) {
WriteLog "Confirm-WingetInstallationUI: WinGet status error - $($wingetStatus.ErrorMessage)"
}
if ($result.NeedsUpdate) { if ($result.NeedsUpdate) {
WriteLog "Confirm-WingetInstallationUI: Update needed. CLI Needs Update: $needsCliUpdate, Module Needs Update: $needsModuleUpdate" WriteLog "Confirm-WingetInstallationUI: Update needed. CLI Needs Update: $needsCliUpdate, Module Needs Update: $needsModuleUpdate"
@@ -344,21 +326,29 @@ function Confirm-WingetInstallationUI {
& $UiUpdateCallback $result.CliVersion "Installing/Updating..." & $UiUpdateCallback $result.CliVersion "Installing/Updating..."
# Attempt to install/update Winget CLI and module # Attempt to install/update Winget CLI and module
$installedModule = Install-WingetComponents -UiUpdateCallback $UiUpdateCallback Install-WingetComponents -UiUpdateCallback $UiUpdateCallback | Out-Null
# Re-check status after attempt # Re-check status after attempt
WriteLog "Confirm-WingetInstallationUI: Re-checking status after update attempt..." WriteLog "Confirm-WingetInstallationUI: Re-checking status after update attempt..."
$cliStatus = Test-WingetCLI $wingetStatus = Get-WinGetComponentStatus -MinimumVersion $minVersion
$result.CliVersion = $cliStatus.Version $result.CliVersion = $wingetStatus.WinGetVersion
$result.ModuleVersion = if ($null -ne $installedModule) { $installedModule.Version } else { "Install Failed" } $result.ModuleVersion = $wingetStatus.ModuleVersion
# Use callback for final status display after update attempt # Use callback for final status display after update attempt
& $UiUpdateCallback $result.CliVersion $result.ModuleVersion & $UiUpdateCallback $result.CliVersion $result.ModuleVersion
# Check if update was successful # Check if update was successful
$cliOk = $cliStatus.Status -match '^\d+\.\d+\.\d+$' -and ([version]$cliStatus.Version -ge $minVersion) $cliOk = $wingetStatus.WinGetInstalled -and -not $wingetStatus.WinGetNeedsUpdate
$moduleOk = ($null -ne $installedModule) -and ([version]$installedModule.Version -ge $minVersion) $moduleOk = $wingetStatus.ModuleInstalled -and -not $wingetStatus.ModuleNeedsUpdate
$result.Success = $cliOk -and $moduleOk $result.Success = $cliOk -and $moduleOk -and [string]::IsNullOrWhiteSpace($wingetStatus.ErrorMessage)
$result.Message = if ($result.Success) { "Winget components installed/updated successfully." } else { "Winget component installation/update failed or is incomplete." } $result.Message = if ($result.Success) {
"Winget components installed/updated successfully."
}
elseif (-not [string]::IsNullOrWhiteSpace($wingetStatus.ErrorMessage)) {
"Winget component installation/update failed: $($wingetStatus.ErrorMessage)"
}
else {
"Winget component installation/update failed or is incomplete."
}
WriteLog "Confirm-WingetInstallationUI: Update attempt finished. Success: $($result.Success). Message: $($result.Message)" WriteLog "Confirm-WingetInstallationUI: Update attempt finished. Success: $($result.Success). Message: $($result.Message)"
} }
else { else {
+816 -6
View File
@@ -106,16 +106,20 @@ function Get-GeneralDefaults {
$peDriversPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "PEDrivers" $peDriversPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "PEDrivers"
$vmLocationPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "VM" $vmLocationPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "VM"
$ffuCapturePath = Join-Path -Path $FFUDevelopmentPath -ChildPath "FFU" $ffuCapturePath = Join-Path -Path $FFUDevelopmentPath -ChildPath "FFU"
$unattendPath = Join-Path -Path $FFUDevelopmentPath -ChildPath "unattend"
$officePath = Join-Path -Path $appsPath -ChildPath "Office" $officePath = Join-Path -Path $appsPath -ChildPath "Office"
$appListJsonPath = Join-Path -Path $appsPath -ChildPath "AppList.json" $appListJsonPath = Join-Path -Path $appsPath -ChildPath "AppList.json"
$userAppListPath = Join-Path -Path $appsPath -ChildPath "UserAppList.json"
$driversJsonPath = Join-Path -Path $driversPath -ChildPath "Drivers.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]@{ return [PSCustomObject]@{
# Build Tab Defaults # Build Tab Defaults
CustomFFUNameTemplate = "{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}" CustomFFUNameTemplate = "{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}"
FFUCaptureLocation = $ffuCapturePath FFUCaptureLocation = $ffuCapturePath
ShareName = "FFUCaptureShare"
Username = "ffu_user"
Threads = 5 Threads = 5
BitsPriority = 'Normal' BitsPriority = 'Normal'
MaxUSBDrives = 5 MaxUSBDrives = 5
@@ -123,7 +127,6 @@ function Get-GeneralDefaults {
CompactOS = $true CompactOS = $true
Optimize = $true Optimize = $true
AllowVHDXCaching = $false AllowVHDXCaching = $false
CreateCaptureMedia = $true
CreateDeploymentMedia = $true CreateDeploymentMedia = $true
Verbose = $false Verbose = $false
AllowExternalHardDiskMedia = $false AllowExternalHardDiskMedia = $false
@@ -134,20 +137,31 @@ function Get-GeneralDefaults {
CopyUnattend = $false CopyUnattend = $false
CopyPPKG = $false CopyPPKG = $false
InjectUnattend = $false InjectUnattend = $false
UnattendX64FilePath = $unattendX64FilePath
UnattendArm64FilePath = $unattendArm64FilePath
DeviceNamingMode = 'None'
DeviceNameTemplate = ''
DeviceNamePrefixesPath = $deviceNamePrefixesPath
DeviceNamePrefixes = @()
DeviceNameSerialComputerNamesPath = $deviceNameSerialComputerNamesPath
DeviceNameSerialComputerNames = @()
CleanupAppsISO = $true CleanupAppsISO = $true
CleanupCaptureISO = $true
CleanupDeployISO = $true CleanupDeployISO = $true
CleanupDrivers = $false CleanupDrivers = $false
RemoveFFU = $false RemoveFFU = $false
RemoveApps = $false RemoveApps = $false
RemoveUpdates = $false RemoveUpdates = $false
RemoveDownloadedESD = $true
# Hyper-V Settings Defaults # Hyper-V Settings Defaults
VMHostIPAddress = "" EnableVMNetworking = $false
DiskSizeGB = 50 DiskSizeGB = 50
MemoryGB = 4 MemoryGB = 4
Processors = 4 Processors = 4
VMLocation = $vmLocationPath VMLocation = $vmLocationPath
VMNamePrefix = "_FFU" VMNamePrefix = "_FFU"
SystemPartitionDriveLetter = 'S'
WindowsPartitionDriveLetter = 'W'
RecoveryPartitionDriveLetter = 'R'
LogicalSectorSize = 512 LogicalSectorSize = 512
# Updates Tab Defaults # Updates Tab Defaults
UpdateLatestCU = $true UpdateLatestCU = $true
@@ -162,6 +176,7 @@ function Get-GeneralDefaults {
InstallApps = $false InstallApps = $false
ApplicationPath = $appsPath ApplicationPath = $appsPath
AppListJsonPath = $appListJsonPath AppListJsonPath = $appListJsonPath
UserAppListPath = $userAppListPath
InstallWingetApps = $false InstallWingetApps = $false
BringYourOwnApps = $false BringYourOwnApps = $false
# M365 Apps/Office Tab Defaults # M365 Apps/Office Tab Defaults
@@ -265,6 +280,7 @@ function Update-AdditionalFFUList {
foreach ($it in $items) { $listView.Items.Add($it) | Out-Null } foreach ($it in $items) { $listView.Items.Add($it) | Out-Null }
WriteLog "Additional FFUs: Found $($listView.Items.Count) FFU files in $ffuFolder." WriteLog "Additional FFUs: Found $($listView.Items.Count) FFU files in $ffuFolder."
} }
Request-ListViewColumnAutoResize -ListView $listView
$headerChk = $State.Controls.chkSelectAllAdditionalFFUs $headerChk = $State.Controls.chkSelectAllAdditionalFFUs
if ($null -ne $headerChk) { if ($null -ne $headerChk) {
Update-SelectAllHeaderCheckBoxState -ListView $listView -HeaderCheckBox $headerChk Update-SelectAllHeaderCheckBoxState -ListView $listView -HeaderCheckBox $headerChk
@@ -304,6 +320,7 @@ function Update-ApplicationPanelVisibility {
$subOptionVisibility = if ($installAppsChecked) { 'Visible' } else { 'Collapsed' } $subOptionVisibility = if ($installAppsChecked) { 'Visible' } else { 'Collapsed' }
$State.Controls.applicationPathPanel.Visibility = $subOptionVisibility $State.Controls.applicationPathPanel.Visibility = $subOptionVisibility
$State.Controls.appListJsonPathPanel.Visibility = $subOptionVisibility $State.Controls.appListJsonPathPanel.Visibility = $subOptionVisibility
$State.Controls.userAppListPathPanel.Visibility = $subOptionVisibility
$State.Controls.chkInstallWingetApps.Visibility = $subOptionVisibility $State.Controls.chkInstallWingetApps.Visibility = $subOptionVisibility
$State.Controls.chkBringYourOwnApps.Visibility = $subOptionVisibility $State.Controls.chkBringYourOwnApps.Visibility = $subOptionVisibility
$State.Controls.chkDefineAppsScriptVariables.Visibility = $subOptionVisibility $State.Controls.chkDefineAppsScriptVariables.Visibility = $subOptionVisibility
@@ -320,6 +337,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 to manage the state of the main "Install Apps" checkbox based on selections in Updates/Office
function Update-InstallAppsState { function Update-InstallAppsState {
param([PSCustomObject]$State) param([PSCustomObject]$State)
@@ -327,11 +361,16 @@ function Update-InstallAppsState {
$installAppsChk = $State.Controls.chkInstallApps $installAppsChk = $State.Controls.chkInstallApps
$installOfficeChk = $State.Controls.chkInstallOffice $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 # Determine if any checkbox that forces "Install Apps" is checked
$anyUpdateChecked = $State.Controls.chkUpdateLatestDefender.IsChecked -or ` $anyUpdateChecked = $State.Controls.chkUpdateLatestDefender.IsChecked -or `
$State.Controls.chkUpdateEdge.IsChecked -or ` $State.Controls.chkUpdateEdge.IsChecked -or `
$State.Controls.chkUpdateOneDrive.IsChecked -or ` $State.Controls.chkUpdateOneDrive.IsChecked -or `
$State.Controls.chkUpdateLatestMSRT.IsChecked $State.Controls.chkUpdateLatestMSRT.IsChecked -or `
$isLtscCuChecked
$isForced = $anyUpdateChecked -or $installOfficeChk.IsChecked $isForced = $anyUpdateChecked -or $installOfficeChk.IsChecked
@@ -445,6 +484,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 # 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
+216 -133
View File
@@ -41,6 +41,59 @@ function WriteLog($LogText) {
Add-Content -path $LogFile -value "$((Get-Date).ToString()) $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) { function Set-DiskpartAnswerFiles($DiskpartFile, $DiskID) {
(Get-Content $DiskpartFile).Replace('disk 0', "disk $DiskID") | Set-Content -Path $DiskpartFile (Get-Content $DiskpartFile).Replace('disk 0', "disk $DiskID") | Set-Content -Path $DiskpartFile
} }
@@ -64,6 +117,68 @@ function Set-Computername($computername) {
return $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 { function Invoke-Process {
[CmdletBinding(SupportsShouldProcess)] [CmdletBinding(SupportsShouldProcess)]
param param
@@ -835,7 +950,7 @@ $LogFileName = 'ScriptLog.txt'
$USBDrive = Get-USBDrive $USBDrive = Get-USBDrive
New-item -Path $USBDrive -Name $LogFileName -ItemType "file" -Force | Out-Null New-item -Path $USBDrive -Name $LogFileName -ItemType "file" -Force | Out-Null
$LogFile = $USBDrive + $LogFilename $LogFile = $USBDrive + $LogFilename
$version = '2602.1Preview' $version = '2604.1'
WriteLog 'Begin Logging' WriteLog 'Begin Logging'
WriteLog "Script version: $version" WriteLog "Script version: $version"
@@ -891,21 +1006,7 @@ else {
} }
$displayList | Format-Table -AutoSize -Property Disk, 'Size (GB)', Sector, 'Bus Type', Model $displayList | Format-Table -AutoSize -Property Disk, 'Size (GB)', Sector, 'Bus Type', Model
do { $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
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)
$selectedDisk = $diskDriveCandidates | Where-Object { $_.Index -eq $diskSelection } $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)" WriteLog "Disk selection: DiskNumber=$($selectedDisk.Index), Model=$($selectedDisk.Model), SizeGB=$([math]::Round(($selectedDisk.Size / 1GB), 2)), BusType=$($selectedDisk.InterfaceType)"
@@ -949,18 +1050,8 @@ If ($FFUCount -gt 1) {
$array += New-Object PSObject -Property $Properties $array += New-Object PSObject -Property $Properties
} }
$array | Format-Table -AutoSize -Property Number, FFUFile | Out-Host $array | Format-Table -AutoSize -Property Number, FFUFile | Out-Host
do { $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
try { $FFUSelected = $FFUSelected - 1
$var = $true
[int]$FFUSelected = Read-Host 'Enter the FFU number to install'
$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 $FFUFileToInstall = $array[$FFUSelected].FFUFile
WriteLog "$FFUFileToInstall was selected" WriteLog "$FFUFileToInstall was selected"
@@ -1001,13 +1092,24 @@ if (Test-Path -Path $PPKGFolder) {
#FindUnattend #FindUnattend
$UnattendFolder = $USBDrive + "unattend\" $UnattendFolder = $USBDrive + "unattend\"
$UnattendFilePath = $UnattendFolder + "unattend.xml" $UnattendSourceFilePath = $UnattendFolder + "unattend.xml"
$UnattendWorkingFilePath = Join-Path -Path $env:TEMP -ChildPath 'unattend.xml'
$UnattendPrefixPath = $UnattendFolder + "prefixes.txt" $UnattendPrefixPath = $UnattendFolder + "prefixes.txt"
$UnattendComputerNamePath = $UnattendFolder + "SerialComputerNames.csv" $UnattendComputerNamePath = $UnattendFolder + "SerialComputerNames.csv"
If (Test-Path -Path $UnattendFilePath) { If (Test-Path -Path $UnattendSourceFilePath) {
$UnattendFile = Get-ChildItem -Path $UnattendFilePath $UnattendSourceFile = Get-ChildItem -Path $UnattendSourceFilePath
If ($UnattendFile) { If ($UnattendSourceFile) {
$Unattend = $true try {
WriteLog "Copying source unattend file $($UnattendSourceFile.FullName) to temporary working file $UnattendWorkingFilePath"
Copy-Item -Path $UnattendSourceFile.FullName -Destination $UnattendWorkingFilePath -Force -ErrorAction Stop
$UnattendFile = Get-ChildItem -Path $UnattendWorkingFilePath -ErrorAction Stop
WriteLog "Using temporary unattend working file $($UnattendFile.FullName)"
$Unattend = $true
}
catch {
WriteLog "Copying source unattend file to temporary working file failed with error: $_"
Stop-Script -Message "Copying source unattend file to temporary working file failed with error: $_"
}
} }
} }
If (Test-Path -Path $UnattendPrefixPath) { If (Test-Path -Path $UnattendPrefixPath) {
@@ -1023,13 +1125,26 @@ If (Test-Path -Path $UnattendComputerNamePath) {
} }
} }
#Ask for device name if unattend exists $UnattendConfiguredComputerName = $null
If ($Unattend -or $UnattendPrefix -or $UnattendComputerName) { $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' Write-SectionHeader 'Device Name Selection'
if ($Unattend -and $UnattendPrefix) { if ($Unattend -and $UnattendPrefix) {
Writelog 'Unattend file found with prefixes.txt. Getting prefixes.' Writelog 'Unattend file found with prefixes.txt. Getting prefixes.'
$UnattendPrefixes = @(Get-content $UnattendPrefixFile) $UnattendPrefixes = @(Get-content $UnattendPrefixFile)
$UnattendPrefixCount = $UnattendPrefixes.Count $UnattendPrefixCount = $UnattendPrefixes.Count
$skipPrefixSelection = $false
$PrefixToUse = $null
If ($UnattendPrefixCount -gt 1) { If ($UnattendPrefixCount -gt 1) {
WriteLog "Found $UnattendPrefixCount Prefixes" WriteLog "Found $UnattendPrefixCount Prefixes"
$array = @() $array = @()
@@ -1038,20 +1153,18 @@ If ($Unattend -or $UnattendPrefix -or $UnattendComputerName) {
$array += New-Object PSObject -Property $Properties $array += New-Object PSObject -Property $Properties
} }
$array | Format-Table -AutoSize -Property Number, DeviceNamePrefix $array | Format-Table -AutoSize -Property Number, DeviceNamePrefix
do { $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
try { if ($prefixSelection -eq 0) {
$var = $true $skipPrefixSelection = $true
[int]$PrefixSelected = Read-Host 'Enter the prefix number to use for the device name' WriteLog 'User chose to skip device name prefix selection. Existing unattend computer name will remain unchanged.'
$PrefixSelected = $PrefixSelected - 1 Write-Host "`nDevice name prefix selection was skipped. The existing unattend computer name will remain unchanged."
} }
catch { else {
Write-Host 'Input was not in correct format. Please enter a valid prefix number' $PrefixSelected = $prefixSelection - 1
$var = $false $PrefixToUse = $array[$PrefixSelected].DeviceNamePrefix
} WriteLog "$PrefixToUse was selected"
} until (($PrefixSelected -le $UnattendPrefixCount - 1) -and $var) Write-Host "`n$PrefixToUse was selected as device name prefix"
$PrefixToUse = $array[$PrefixSelected].DeviceNamePrefix }
WriteLog "$PrefixToUse was selected"
Write-Host "`n$PrefixToUse was selected as device name prefix"
} }
elseif ($UnattendPrefixCount -eq 1) { elseif ($UnattendPrefixCount -eq 1) {
WriteLog "Found $UnattendPrefixCount Prefix" WriteLog "Found $UnattendPrefixCount Prefix"
@@ -1060,17 +1173,10 @@ If ($Unattend -or $UnattendPrefix -or $UnattendComputerName) {
WriteLog "Will use $PrefixToUse as device name prefix" WriteLog "Will use $PrefixToUse as device name prefix"
Write-Host "Will use $PrefixToUse as device name prefix" 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() $serial = (Get-CimInstance -ClassName win32_bios).SerialNumber.Trim()
#Combine prefix with serial $computername = Set-ConfiguredComputerName($PrefixToUse + $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-Computername($computername)
Writelog "Computer name will be set to $computername"
Write-Host "Computer name will be set to $computername"
} }
elseif ($Unattend -and $UnattendComputerName) { elseif ($Unattend -and $UnattendComputerName) {
Writelog 'Unattend file found with SerialComputerNames.csv. Getting name for current computer.' Writelog 'Unattend file found with SerialComputerNames.csv. Getting name for current computer.'
@@ -1080,32 +1186,31 @@ If ($Unattend -or $UnattendPrefix -or $UnattendComputerName) {
$SCName = $SerialComputerNames | Where-Object { $_.SerialNumber -eq $SerialNumber } $SCName = $SerialComputerNames | Where-Object { $_.SerialNumber -eq $SerialNumber }
If ($SCName) { If ($SCName) {
[string]$computername = $SCName.ComputerName [string]$computername = Set-ConfiguredComputerName($SCName.ComputerName)
$computername = Set-Computername($computername)
Writelog "Computer name will be set to $computername"
Write-Host "Computer name will be set to $computername"
} }
else { else {
Writelog 'No matching serial number found in SerialComputerNames.csv. Setting random computer name to complete setup.' Writelog 'No matching serial number found in SerialComputerNames.csv. Setting random computer name to complete setup.'
Write-Host 'No matching serial number found in SerialComputerNames.csv. Setting random computer name to complete setup.' Write-Host 'No matching serial number found in SerialComputerNames.csv. Setting random computer name to complete setup.'
[string]$computername = ("FFU-" + ( -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 11 | ForEach-Object { [char]$_ }))) [string]$computername = Set-ConfiguredComputerName(("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"
} }
} }
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' 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.' Write-Host 'Unattend file found but no prefixes.txt. Please enter a device name.'
[string]$computername = Read-Host 'Enter device name' [string]$computername = Set-ConfiguredComputerName((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"
} }
else { else {
WriteLog 'Device naming assets detected without unattend.xml. Skipping device naming prompts.' 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 { else {
WriteLog 'No unattend folder found. Device name will be set via PPKG, AP JSON, or default OS name.' WriteLog 'No unattend folder found. Device name will be set via PPKG, AP JSON, or default OS name.'
} }
@@ -1114,17 +1219,7 @@ else {
If ($autopilot -eq $true -and $PPKG -eq $true) { If ($autopilot -eq $true -and $PPKG -eq $true) {
WriteLog 'Both PPKG and Autopilot json files found' WriteLog 'Both PPKG and Autopilot json files found'
Write-Host 'Both Autopilot JSON files and Provisioning packages were found.' Write-Host 'Both Autopilot JSON files and Provisioning packages were found.'
do { $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
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)
If ($APorPPKG -eq 1) { If ($APorPPKG -eq 1) {
$PPKG = $false $PPKG = $false
} }
@@ -1143,22 +1238,20 @@ If ($APFilesCount -gt 1 -and $autopilot -eq $true) {
$array += New-Object PSObject -Property $Properties $array += New-Object PSObject -Property $Properties
} }
$array | Format-Table -AutoSize -Property Number, APFileName $array | Format-Table -AutoSize -Property Number, APFileName
do { $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
try {
$var = $true
[int]$APFileSelected = Read-Host 'Enter the AP json file number to install'
$APFileSelected = $APFileSelected - 1
}
catch { if ($APFileSelection -eq 0) {
Write-Host 'Input was not in correct format. Please enter a valid AP json file number' $APFileToInstall = $null
$var = $false $APFileName = $null
} WriteLog 'User chose to skip Autopilot JSON selection.'
} until (($APFileSelected -le $APFilesCount - 1) -and $var) Write-Host "`nAutopilot JSON selection was skipped."
}
$APFileToInstall = $array[$APFileSelected].APFile else {
$APFileName = $array[$APFileSelected].APFileName $APFileSelected = $APFileSelection - 1
WriteLog "$APFileToInstall was selected" $APFileToInstall = $array[$APFileSelected].APFile
$APFileName = $array[$APFileSelected].APFileName
WriteLog "$APFileToInstall was selected"
}
} }
elseif ($APFilesCount -eq 1 -and $autopilot -eq $true) { elseif ($APFilesCount -eq 1 -and $autopilot -eq $true) {
WriteLog "Found $APFilesCount AP File" WriteLog "Found $APFilesCount AP File"
@@ -1181,22 +1274,19 @@ If ($PPKGFilesCount -gt 1 -and $PPKG -eq $true) {
$array += New-Object PSObject -Property $Properties $array += New-Object PSObject -Property $Properties
} }
$array | Format-Table -AutoSize -Property Number, PPKGFileName $array | Format-Table -AutoSize -Property Number, PPKGFileName
do { $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
try {
$var = $true
[int]$PPKGFileSelected = Read-Host 'Enter the PPKG file number to install'
$PPKGFileSelected = $PPKGFileSelected - 1
}
catch { if ($PPKGFileSelection -eq 0) {
Write-Host 'Input was not in correct format. Please enter a valid PPKG file number' $PPKGFileToInstall = $null
$var = $false WriteLog 'User chose to skip Provisioning Package selection.'
} Write-Host "`nProvisioning Package selection was skipped."
} until (($PPKGFileSelected -le $PPKGFilesCount - 1) -and $var) }
else {
$PPKGFileToInstall = $array[$PPKGFileSelected].PPKGFile $PPKGFileSelected = $PPKGFileSelection - 1
WriteLog "$PPKGFileToInstall was selected" $PPKGFileToInstall = $array[$PPKGFileSelected].PPKGFile
Write-Host "`n$PPKGFileToInstall will be used" WriteLog "$PPKGFileToInstall was selected"
Write-Host "`n$PPKGFileToInstall will be used"
}
} }
elseif ($PPKGFilesCount -eq 1 -and $PPKG -eq $true) { elseif ($PPKGFilesCount -eq 1 -and $PPKG -eq $true) {
Write-SectionHeader -Title 'Provisioning Package Selection' Write-SectionHeader -Title 'Provisioning Package Selection'
@@ -1312,7 +1402,7 @@ if ($null -eq $DriverSourcePath) {
if ([string]::IsNullOrWhiteSpace($relativeSegment)) { if ([string]::IsNullOrWhiteSpace($relativeSegment)) {
return Split-Path -Path $normalizedPath -Leaf return Split-Path -Path $normalizedPath -Leaf
} }
return $relativePath = $relativeSegment return $relativeSegment
} }
return $normalizedPath return $normalizedPath
} }
@@ -1391,21 +1481,13 @@ if ($null -eq $DriverSourcePath) {
$DriverSelected = -1 $DriverSelected = -1
$skipDriverInstall = $false $skipDriverInstall = $false
do { $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
try { if ($userSelection -eq 0) {
$var = $true $skipDriverInstall = $true
[int]$userSelection = Read-Host 'Enter the number of the driver source to install (0 to skip)' }
if ($userSelection -eq 0) { else {
$skipDriverInstall = $true $DriverSelected = $userSelection - 1
break }
}
$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) { if ($skipDriverInstall) {
$DriverSourcePath = $null $DriverSourcePath = $null
@@ -1568,8 +1650,9 @@ If ($PPKGFileToInstall) {
} }
} }
#Set DeviceName #Set DeviceName
If ($computername) { If ($Unattend) {
Write-SectionHeader -Title 'Applying Computer Name and Unattend Configuration' $unattendSectionTitle = if ($computername) { 'Applying Computer Name and Unattend Configuration' } else { 'Applying Unattend Configuration' }
Write-SectionHeader -Title $unattendSectionTitle
try { try {
$PantherDir = 'w:\windows\panther' $PantherDir = 'w:\windows\panther'
If (Test-Path -Path $PantherDir) { If (Test-Path -Path $PantherDir) {
@@ -1590,8 +1673,8 @@ If ($computername) {
} }
} }
catch { catch {
WriteLog "Copying Unattend.xml to name device failed" WriteLog 'Copying Unattend.xml to Panther failed'
Stop-Script -Message "Copying Unattend.xml to name device failed with error: $_" Stop-Script -Message "Copying Unattend.xml to Panther failed with error: $_"
} }
} }
Binary file not shown.
@@ -0,0 +1,4 @@
SerialNumber,ComputerName
ABC12345,CORP-001
DEF67890,KIOSK-010
XYZ24680,STORE-015
1 SerialNumber ComputerName
2 ABC12345 CORP-001
3 DEF67890 KIOSK-010
4 XYZ24680 STORE-015
@@ -3,7 +3,7 @@
<settings pass="specialize"> <settings pass="specialize">
<!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it --> <!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it -->
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ComputerName>MYCOMPUTER</ComputerName><!--Leave Default will be renamed--> <ComputerName>*</ComputerName><!--Leave Default will be renamed-->
<TimeZone>Eastern Standard Time</TimeZone><!--Add Your Local TimeZone--> <TimeZone>Eastern Standard Time</TimeZone><!--Add Your Local TimeZone-->
</component> </component>
<!-- Place additional Components Elements and Settings below here: --> <!-- Place additional Components Elements and Settings below here: -->
+1 -1
View File
@@ -3,7 +3,7 @@
<settings pass="specialize"> <settings pass="specialize">
<!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it --> <!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it -->
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="arm64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="arm64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ComputerName>MyComputer</ComputerName> <ComputerName>*</ComputerName>
</component> </component>
<!--Place addtional Components Elements and settings below here. --> <!--Place addtional Components Elements and settings below here. -->
</settings> </settings>
+1 -1
View File
@@ -3,7 +3,7 @@
<settings pass="specialize"> <settings pass="specialize">
<!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it --> <!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it -->
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ComputerName>MyComputer</ComputerName> <ComputerName>*</ComputerName>
</component> </component>
<!--Place addtional Components Elements and settings below here. --> <!--Place addtional Components Elements and settings below here. -->
</settings> </settings>
+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. The Full-Flash update (FFU) process can automatically download the latest release of Windows 11, the updates mentioned above, and creates a USB drive that can be used to quickly reimage a machine.
# Updates
2509.1 has been released to preview! This is a major update that brings a new user interface to preview.
Docs are coming, but will take a bit to write them. The youtube video is a must watch for a complete demo on how to use the UI and the changes made to apps (InstallAppsAndSysprep.cmd is gone) and drivers. I'll be recording a more formalized deep dive with slides that go a bit deeper into how things work, but the UI walkthrough should get most people going.
# Getting Started # Getting Started
- Download the latest [release](https://github.com/rbalsleyMSFT/FFU/releases) If you're new to FFU Builder or new to the FFU Builder UI version, check out the [Quick Start Guide](https://rbalsleymsft.github.io/FFU/quickstart.html).
- Extract the FFUDevelopment folder from the ZIP file (recommend to C:\FFUDevelopment)
- Watch the Youtube video (updated docs for the UI coming soon)
## YouTube Detailed Walkthrough This will be the fastest way to create your first FFU. There's a new [FFU Builder Quickstart Youtube video](https://youtu.be/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 [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.
[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
+1 -1
View File
@@ -9,7 +9,7 @@ parent: UI Overview
--- ---
# M365 Apps/Office # M365 Apps/Office
![1760378889283](image/appsscriptvariablescopy/1760378889283.png) ![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: FFU Builder uses the Office Deployment Toolkit (ODT) to install Office. In the `.\FFUDevelopment\Apps\Office` folder you'll find two files:
+3 -1
View File
@@ -4,10 +4,12 @@ remote_theme: just-the-docs/just-the-docs@v0.10.1
plugins: plugins:
- jekyll-remote-theme - jekyll-remote-theme
- jekyll-seo-tag - jekyll-seo-tag
- jekyll-sitemap
search_enabled: true 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 # Because youll publish as a project site at /FFU
baseurl: "/FFU" baseurl: "/FFU"
+103 -10
View File
@@ -1,3 +1,9 @@
{% 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 --> <!-- docs/_includes/head_custom.html -->
<style> <style>
/* Layout: remove Just-the-Docs "centered narrow" constraints on wide screens */ /* Layout: remove Just-the-Docs "centered narrow" constraints on wide screens */
@@ -57,6 +63,13 @@
line-height: 1.65; 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 h1,
.main-content h2, .main-content h2,
.main-content h3 { .main-content h3 {
@@ -75,7 +88,7 @@
word-break: break-word; word-break: break-word;
} }
.main-content :not(pre) > code { .main-content :not(pre)>code {
white-space: normal; white-space: normal;
overflow-wrap: anywhere; overflow-wrap: anywhere;
word-break: break-word; word-break: break-word;
@@ -132,8 +145,16 @@
margin-right: 0; margin-right: 0;
justify-self: stretch; justify-self: stretch;
/* Safety net: if anything still overflows, don't let it render under the TOC */ /* Keep heading permalink icons visible */
overflow-x: hidden; /* (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 */ /* TOC always stays in the right column */
@@ -184,16 +205,88 @@
text-decoration: underline; text-decoration: underline;
} }
.page-toc__link.is-active { .page-toc__link.is-active {
font-weight: 600; font-weight: 600;
color: #1a1a1a; color: #1a1a1a;
border-left-color: #2563eb; border-left-color: #2563eb;
} }
} }
</style>
/* 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 %}"> <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/vendor/medium-zoom.min.js' | relative_url }}" defer></script>
<script src="{{ '/assets/js/image-zoom.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/page-toc.js' | relative_url }}" defer></script>
<script src="{{ '/assets/js/external-links.js' | relative_url }}" defer></script>
+1 -1
View File
@@ -10,7 +10,7 @@ has_toc: false
--- ---
# Applications # Applications
![1759881364454](image/updatescopy/1759881364454.png) ![1776378755854](image/applications/1776378755854.png)
Applications can be installed in three different ways: Applications can be installed in three different ways:
+5 -9
View File
@@ -10,7 +10,7 @@ grand_parent: UI Overview
--- ---
# Apps Script Variables # Apps Script Variables
![1760135511234](image/appsscriptvariables/1760135511234.png) ![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. 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.
@@ -45,7 +45,7 @@ In the VM, the `Orchestrator.ps1` file will call `Invoke-AppsScript.ps1` if `App
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. 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": [], "AdditionalFFUFiles": [],
"AllowExternalHardDiskMedia": false, "AllowExternalHardDiskMedia": false,
"AllowVHDXCaching": false, "AllowVHDXCaching": false,
@@ -57,7 +57,6 @@ This allows for you to create a dynamic task sequence via a PowerShell script wi
}, },
"BuildUSBDrive": false, "BuildUSBDrive": false,
"CleanupAppsISO": true, "CleanupAppsISO": true,
"CleanupCaptureISO": true,
"CleanupDeployISO": true, "CleanupDeployISO": true,
"CleanupDrivers": false, "CleanupDrivers": false,
"CompactOS": true, "CompactOS": true,
@@ -69,7 +68,6 @@ This allows for you to create a dynamic task sequence via a PowerShell script wi
"CopyPEDrivers": false, "CopyPEDrivers": false,
"CopyPPKG": false, "CopyPPKG": false,
"CopyUnattend": false, "CopyUnattend": false,
"CreateCaptureMedia": true,
"CreateDeploymentMedia": true, "CreateDeploymentMedia": true,
"CustomFFUNameTemplate": "{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}", "CustomFFUNameTemplate": "{WindowsRelease}_{WindowsVersion}_{SKU}_{yyyy}-{MM}-{dd}_{HH}{mm}",
"Disksize": 53687091200, "Disksize": 53687091200,
@@ -98,10 +96,11 @@ This allows for you to create a dynamic task sequence via a PowerShell script wi
"Processors": 4, "Processors": 4,
"ProductKey": "", "ProductKey": "",
"PromptExternalHardDiskMedia": true, "PromptExternalHardDiskMedia": true,
"RecoveryPartitionDriveLetter": "R",
"RemoveApps": false, "RemoveApps": false,
"RemoveFFU": false, "RemoveFFU": false,
"RemoveUpdates": false, "RemoveUpdates": false,
"ShareName": "FFUCaptureShare", "SystemPartitionDriveLetter": "S",
"Threads": 5, "Threads": 5,
"UpdateADK": true, "UpdateADK": true,
"UpdateEdge": true, "UpdateEdge": true,
@@ -115,12 +114,11 @@ This allows for you to create a dynamic task sequence via a PowerShell script wi
"USBDriveList": {}, "USBDriveList": {},
"UseDriversAsPEDrivers": false, "UseDriversAsPEDrivers": false,
"UserAppListPath": "C:\\FFUDevelopment\\Apps\\UserAppList.json", "UserAppListPath": "C:\\FFUDevelopment\\Apps\\UserAppList.json",
"Username": "ffu_user",
"Verbose": false, "Verbose": false,
"VMHostIPAddress": "192.168.1.169",
"VMLocation": "C:\\FFUDevelopment\\VM", "VMLocation": "C:\\FFUDevelopment\\VM",
"VMSwitchName": "External", "VMSwitchName": "External",
"WindowsArch": "x64", "WindowsArch": "x64",
"WindowsPartitionDriveLetter": "W",
"WindowsLang": "en-us", "WindowsLang": "en-us",
"WindowsRelease": 11, "WindowsRelease": 11,
"WindowsSKU": "Pro", "WindowsSKU": "Pro",
@@ -133,6 +131,4 @@ Example command line to run with vmwaretools set to false and foo set to foo. Th
`.\BuildFFUVM.ps1 -configFile 'C:\FFUDevelopment\config\FFUConfig.json' -appsScriptVariables @{foo='foo'; vmwaretools='false'}` `.\BuildFFUVM.ps1 -configFile 'C:\FFUDevelopment\config\FFUConfig.json' -appsScriptVariables @{foo='foo'; vmwaretools='false'}`
{% include page_nav.html %} {% 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();
})();
+165 -48
View File
@@ -1,6 +1,10 @@
(function () { (function () {
'use strict'; 'use strict';
var scrollSpyDispose = null;
var resizeReinitTimerId = null;
var inlineMaxVisibleItems = 4;
function IsRightTocEnabled() { function IsRightTocEnabled() {
var meta = document.querySelector('meta[name="ffu-right-toc"]'); var meta = document.querySelector('meta[name="ffu-right-toc"]');
if (meta && meta.content && meta.content.toLowerCase() === 'false') { if (meta && meta.content && meta.content.toLowerCase() === 'false') {
@@ -18,6 +22,47 @@
} }
} }
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) { function GetHeadings(container) {
var headings = container.querySelectorAll('h2, h3'); var headings = container.querySelectorAll('h2, h3');
var results = []; var results = [];
@@ -49,9 +94,13 @@
return results; return results;
} }
function BuildToc(headings) { 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'); var nav = document.createElement('nav');
nav.className = 'page-toc'; nav.className = 'page-toc' + (isInline ? ' page-toc--inline' : '');
nav.setAttribute('aria-label', 'On this page'); nav.setAttribute('aria-label', 'On this page');
var title = document.createElement('div'); var title = document.createElement('div');
@@ -61,6 +110,7 @@
var list = document.createElement('ul'); var list = document.createElement('ul');
list.className = 'page-toc__list'; list.className = 'page-toc__list';
list.id = 'page-toc-list';
for (var i = 0; i < headings.length; i++) { for (var i = 0; i < headings.length; i++) {
var item = headings[i]; var item = headings[i];
@@ -75,13 +125,61 @@
li.appendChild(a); li.appendChild(a);
list.appendChild(li); list.appendChild(li);
if (isInline && maxVisible > 0 && i >= maxVisible) {
li.classList.add('is-hidden');
}
} }
nav.appendChild(list); 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; return nav;
} }
function SetActiveTocLink(toc, activeId) { function SetActiveTocLink(toc, activeId, keepVisibleInPanel) {
if (!toc) { if (!toc) {
return; return;
} }
@@ -95,11 +193,13 @@
if (isActive) { if (isActive) {
link.classList.add('is-active'); link.classList.add('is-active');
/* Keep the active item visible inside the TOC panel */ if (keepVisibleInPanel) {
try { /* Keep the active item visible inside the TOC panel (desktop/right TOC only) */
link.scrollIntoView({ block: 'nearest' }); try {
} catch (e) { link.scrollIntoView({ block: 'nearest' });
link.scrollIntoView(); } catch (e) {
link.scrollIntoView();
}
} }
} else { } else {
link.classList.remove('is-active'); link.classList.remove('is-active');
@@ -109,12 +209,12 @@
function SetupScrollSpy(main, toc, headings) { function SetupScrollSpy(main, toc, headings) {
if (!main || !toc || !headings || headings.length < 1) { if (!main || !toc || !headings || headings.length < 1) {
return; return null;
} }
/* Scrollspy is desktop-only; on mobile it can cause "fighting" scroll behavior */ /* Scrollspy is desktop-only */
if (!IsDesktopViewport()) { if (!IsDesktopViewport()) {
return; return null;
} }
var headingElements = []; var headingElements = [];
@@ -126,7 +226,7 @@
} }
if (headingElements.length < 1) { if (headingElements.length < 1) {
return; return null;
} }
var activeId = null; var activeId = null;
@@ -143,7 +243,7 @@
} }
function GetCurrentHeadingId() { function GetCurrentHeadingId() {
/* If we're at the bottom, force the last heading active (Learn-like behavior) */ /* If we're at the bottom, force the last heading active */
if (IsNearBottomOfPage()) { if (IsNearBottomOfPage()) {
return headingElements[headingElements.length - 1].getAttribute('id'); return headingElements[headingElements.length - 1].getAttribute('id');
} }
@@ -177,6 +277,11 @@
function Update() { function Update() {
ticking = false; ticking = false;
/* If the viewport becomes narrow after load, avoid scroll fighting */
if (!IsDesktopViewport()) {
return;
}
if (Date.now() < lockActiveUntilMs) { if (Date.now() < lockActiveUntilMs) {
return; return;
} }
@@ -187,7 +292,7 @@
} }
activeId = currentId; activeId = currentId;
SetActiveTocLink(toc, activeId); SetActiveTocLink(toc, activeId, true);
} }
function OnScrollOrResize() { function OnScrollOrResize() {
@@ -199,11 +304,7 @@
window.requestAnimationFrame(Update); window.requestAnimationFrame(Update);
} }
window.addEventListener('scroll', OnScrollOrResize, { passive: true }); function OnTocClick(evt) {
window.addEventListener('resize', OnScrollOrResize);
/* Update immediately and also when clicking TOC links */
toc.addEventListener('click', function (evt) {
var target = evt.target; var target = evt.target;
if (!target || !target.classList || !target.classList.contains('page-toc__link')) { if (!target || !target.classList || !target.classList.contains('page-toc__link')) {
return; return;
@@ -223,29 +324,25 @@
lockActiveUntilMs = Date.now() + 800; lockActiveUntilMs = Date.now() + 800;
activeId = id; activeId = id;
SetActiveTocLink(toc, activeId); SetActiveTocLink(toc, activeId, true);
}); }
window.addEventListener('scroll', OnScrollOrResize, { passive: true });
window.addEventListener('resize', OnScrollOrResize);
toc.addEventListener('click', OnTocClick);
Update(); Update();
return function DisposeScrollSpy() {
window.removeEventListener('scroll', OnScrollOrResize);
window.removeEventListener('resize', OnScrollOrResize);
toc.removeEventListener('click', OnTocClick);
};
} }
function InitRightToc() { function InitRightToc() {
if (!IsRightTocEnabled()) { if (!IsRightTocEnabled()) {
return; RemoveExistingToc();
}
/* Desktop-only TOC: on mobile it interferes with scrolling */
if (!IsDesktopViewport()) {
var existingWrap = document.querySelector('.main-content-wrap');
if (existingWrap) {
var existingToc = existingWrap.querySelector('.page-toc');
if (existingToc) {
existingToc.remove();
}
existingWrap.classList.remove('has-page-toc');
}
return; return;
} }
@@ -256,31 +353,51 @@
var headings = GetHeadings(main); var headings = GetHeadings(main);
if (headings.length < 2) { if (headings.length < 2) {
RemoveExistingToc();
return; return;
} }
var wrap = document.querySelector('.main-content-wrap'); if (IsDesktopViewport()) {
var content = document.querySelector('.main-content'); RemoveExistingToc();
if (!wrap || !content) {
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; return;
} }
if (wrap.querySelector('.page-toc')) { /* Narrow viewports: place TOC at the top of the article (Learn-like) */
return; RemoveExistingToc();
var inlineToc = BuildToc(headings, { variant: 'inline', maxVisible: inlineMaxVisibleItems });
InsertInlineToc(main, inlineToc);
}
function OnViewportResize() {
if (null !== resizeReinitTimerId) {
window.clearTimeout(resizeReinitTimerId);
} }
wrap.classList.add('has-page-toc'); resizeReinitTimerId = window.setTimeout(InitRightToc, 150);
var toc = BuildToc(headings);
wrap.appendChild(toc);
SetupScrollSpy(main, toc, headings);
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', InitRightToc); document.addEventListener('DOMContentLoaded', function () {
InitRightToc();
window.addEventListener('resize', OnViewportResize);
});
return; return;
} }
InitRightToc(); InitRightToc();
window.addEventListener('resize', OnViewportResize);
})(); })();
+499 -402
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -10,7 +10,7 @@ grand_parent: UI Overview
--- ---
# Bring Your Own Applications # Bring Your Own Applications
![1760117497413](image/byoapps/1760117497413.png) ![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. 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.
+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 %}
+2 -2
View File
@@ -9,7 +9,7 @@ parent: UI Overview
--- ---
# Drivers # Drivers
![1760488183854](image/drivers/1760488183854.png) ![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. 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.
@@ -72,7 +72,7 @@ To find the Machine Type for Lenovo devices, check the bottom/back of the device
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 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
![FFU Builder UI showing multiple driver models selected for Dell, HP, Microsoft, and Lenovo](image/drivers/1763794307504.png "FFU Builder UI showing multiple driver models selected for Dell, HP, Microsoft, and Lenovo") ![1776379241359](image/drivers/1776379241359.png)
## Save Drivers.json ## Save Drivers.json
+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 %}
+22 -6
View File
@@ -9,17 +9,19 @@ parent: UI Overview
--- ---
# Hyper-V Settings # Hyper-V Settings
![1759533067934](image/ui_overview/1759533067934.png) ![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 ## 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. 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.
## VM Host IP Address 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.
IP address of the selected Hyper-V switch that will be used for FFU capture. The UI will auto-detected this based on the VM Switch that was selected.
If `$InstallApps` is set to `$true`, this parameter must be configured.
## Disk Size (GB) ## Disk Size (GB)
@@ -41,6 +43,20 @@ Default is `$FFUDevelopmentPath\VM`. This is the location of the VHDX that gets
Prefix for the generated VM. Default is _FFU. Prefix for the generated VM. Default is _FFU.
## System Partition Drive Letter
Drive letter used for the System partition while building the FFU VHDX. Default is `S`.
## Windows Partition Drive Letter
Drive letter used for the Windows partition while building the FFU VHDX. Default is `W`.
## Recovery Partition Drive Letter
Drive letter used for the Recovery partition while building the FFU VHDX. Default is `R`.
These settings only affect FFU creation. They do not change the hard-coded drive letters used by `ApplyFFU.ps1` during deployment.
## Logical Sector Size ## Logical Sector Size
Uint32 value of 512 or 4096. Useful for 4Kn drives or devices shipping with UFS drives. Default is 512. Uint32 value of 512 or 4096. Useful for 4Kn drives or devices shipping with UFS drives. Default is 512.
Binary file not shown.

After

Width:  |  Height:  |  Size: 166 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: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 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: 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: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 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: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+18
View File
@@ -0,0 +1,18 @@
---
title: Monitor
nav_order: 11
prev_url: /build.html
prev_label: Build
parent: UI Overview
---
# Monitor
![1776379380344](image/monitor/1776379380344.png)
The monitor tab parses the `.\FFUDevelopment\FFUDevelopment.log` file. This makes it easy to track what's happening during the FFU build process.
You can click into the monitor and select one or multiple lines (either single click, or shift+click). Doing so will prevent the log file from continuous scrolling, allowing you time to read what has transpired up to that point.
You can also copy your selection using ctrl+C. This makes it easy to copy and paste parts of the log file used for troubleshooting.
{% include page_nav.html %}
+110
View File
@@ -0,0 +1,110 @@
---
title: Parameters Reference
nav_order: 1
parent: Reference
prev_url: /reference.html
prev_label: Reference
---
# BuildFFUVM.ps1 Parameter Reference
This table lists all top-level parameters in BuildFFUVM.ps1.
<style>
.parameters-reference-table th:first-child,
.parameters-reference-table td:first-child {
white-space: nowrap;
}
</style>
| Parameter | Type | UI Control | Description |
| --- | --- | --- | --- |
| -AdditionalFFUFiles | string[] | Copy Additional FFU Files + Additional FFU Files list | Array of full file paths to existing FFU files that should also be copied to the deployment USB when -CopyAdditionalFFUFiles is set to $true. |
| -AllowExternalHardDiskMedia | bool | Allow External Hard Disk Media | When set to $true, will allow the use of media identified as External Hard Disk media via WMI class Win32_DiskDrive. Default is not defined. |
| -AllowVHDXCaching | bool | Allow VHDX Caching | When set to $true, will cache the VHDX file to the $FFUDevelopmentPath\VHDXCache folder and create a config json file that will keep track of the Windows build information, the updates installed, and the logical sector byte size information. Default is $false. |
| -AppListPath | string | AppList.json Path | Path to a JSON file containing a list of applications to install using WinGet. Default is $FFUDevelopmentPath\Apps\AppList.json. |
| -AppsScriptVariables | hashtable | Define Apps Script Variables + Apps Script Variables list | When passed a hashtable, the script will create an AppsScriptVariables.json file in the OrchestrationPath. This file will be used to pass variables to the Apps script. The hashtable should contain key-value pairs where the key is the variable name and the value is the variable value. |
| -BitsPriority | string | BITS Priority | BITS transfer priority used for downloads. Accepted values are 'Foreground', 'High', 'Normal', and 'Low'. Default is 'Normal'. |
| -BuildUSBDrive | bool | Build USB Drive | When set to $true, will partition and format a USB drive and copy the captured FFU to the drive. |
| -Cleanup | switch | Monitor cancel build action (no direct control) | Switch to run cleanup-only mode. When specified, the script performs cleanup and exits without starting a new build. |
| -CleanupAppsISO | bool | Cleanup Apps ISO | When set to $true, will remove the Apps ISO after the FFU has been captured. Default is $true. |
| -CleanupCurrentRunDownloads | bool | Monitor cancel prompt option (no direct control) | When set to $true, cleanup mode will remove downloads created during the current run and restore backed up run JSON files. Default is $false. |
| -CleanupDeployISO | bool | Cleanup Deploy ISO | When set to $true, will remove the WinPE deployment ISO after the FFU has been captured. Default is $true. |
| -CleanupDrivers | bool | Cleanup Drivers | When set to $true, will remove the drivers folder after the FFU has been captured. Default is $true. |
| -CompactOS | bool | Compact OS | When set to $true, will compact the OS when building the FFU. Default is $true. |
| -CompressDownloadedDriversToWim | bool | Compress Driver Model Folder to WIM | When set to $true, compresses downloaded drivers into a WIM file. Default is $false. |
| -ConfigFile | string | Load Config File | Path to a JSON file containing parameters to use for the script. Default is $null. |
| -CopyAdditionalFFUFiles | bool | Copy Additional FFU Files | When set to $true, enables copying additional FFU files from $FFUDevelopmentPath\FFU to the deployment USB alongside the current build output. |
| -CopyAutopilot | bool | Copy Autopilot Profile | When set to $true, will copy the $FFUDevelopmentPath\Autopilot folder to the Deployment partition of the USB drive. Default is $false. |
| -CopyDrivers | bool | Copy Drivers to USB drive | When set to $true, will copy the drivers from the $FFUDevelopmentPath\Drivers folder to the Drivers folder on the deploy partition of the USB drive. Default is $false. |
| -CopyPEDrivers | bool | Copy PE Drivers | When set to $true, enables adding WinPE drivers. By default copies drivers from $FFUDevelopmentPath\PEDrivers to the WinPE deployment media unless -UseDriversAsPEDrivers is also $true. |
| -CopyPPKG | bool | Copy Provisioning Package | When set to $true, will copy the provisioning package from the $FFUDevelopmentPath\PPKG folder to the Deployment partition of the USB drive. Default is $false. |
| -CopyUnattend | bool | Copy Unattend.xml | When set to $true, stages the selected architecture-specific unattend XML file as Unattend.xml on the Deployment partition of the USB drive. Cannot be used together with -InjectUnattend. Default is $false. |
| -CreateDeploymentMedia | bool | Create Deployment Media | When set to $true, this will create WinPE deployment media for use when deploying to a physical device. |
| -CustomFFUNameTemplate | string | Custom FFU Name Template | Sets a custom FFU output name with placeholders. Allowed placeholders are: {WindowsRelease}, {WindowsVersion}, {SKU}, {BuildDate}, {yyyy}, {MM}, {dd}, {H}, {hh}, {mm}, {tt}. |
| -DeviceNamePrefixes | string[] | Specify a list of Prefixes | Sets the prefixes used when DeviceNamingMode is Prefixes. Each entry becomes a line in prefixes.txt on the deployment media. |
| -DeviceNamePrefixesPath | string | Prefixes File Path | Path to the source prefixes file used for legacy copy or when -DeviceNamePrefixes is not supplied. Default is $FFUDevelopmentPath\Unattend\prefixes.txt. |
| -DeviceNameSerialComputerNames | string[] | Specify Serial to Device Name Mapping | Sets the CSV content used when DeviceNamingMode is SerialComputerNames. The content must include SerialNumber and ComputerName headers, and the staged file is written as SerialComputerNames.csv on the deployment media. |
| -DeviceNameSerialComputerNamesPath | string | Serial Computer Names CSV Mapping File Path | Path to the source CSV file used when DeviceNamingMode is SerialComputerNames and -DeviceNameSerialComputerNames is not supplied. Default is $FFUDevelopmentPath\Unattend\SerialComputerNames.csv. |
| -DeviceNameTemplate | string | Specify Device Name | Sets the device name used when DeviceNamingMode is Template. Supports a static name or the %serial% token when -CopyUnattend is used. |
| -DeviceNamingMode | string | Device Naming expander | Controls how device naming is handled when unattend content is copied to USB media or injected into the FFU. Accepted values are Legacy, None, Prompt, Template, Prefixes, and SerialComputerNames. The UI shows None, Prompt, Template, Prefixes, and SerialComputerNames. When device naming is left untouched in the UI, the generated config does not write DeviceNamingMode, which preserves the script default of Legacy. Prompt rewrites the staged deployment unattend to the existing manual prompt placeholder and requires -CopyUnattend. Prefixes writes prefixes.txt and requires -CopyUnattend. SerialComputerNames writes SerialComputerNames.csv and requires -CopyUnattend. |
| -Disksize | uint64 | Disk Size (GB) | Size of the virtual hard disk for the virtual machine. Default is a 50GB dynamic disk. |
| -DriversFolder | string | Drivers Folder | Path to the drivers folder. Default is $FFUDevelopmentPath\Drivers. |
| -DriversJsonPath | string | Drivers.json Path | Path to a JSON file that specifies which drivers to download. |
| -EnableVMNetworking | bool | Enable VM Networking (Experimental) | When set to $true, connects the build VM to the selected Hyper-V switch during provisioning. Default is $false because internet-connected Sysprep is experimental. |
| -ExportConfigFile | string | Save Config File | Path to a JSON file to export the parameters used for the script. |
| -FFUCaptureLocation | string | FFU Capture Location | Path to the folder where the captured FFU will be stored. Default is $FFUDevelopmentPath\FFU. |
| -FFUDevelopmentPath | string | FFU Development Path | Path to the FFU development folder. Default is $PSScriptRoot. |
| -FFUPrefix | string | VM Name Prefix | Prefix for the generated FFU file. Default is _FFU. |
| -Headers | hashtable | CLI only (no UI control) | Headers to use when downloading files. Not recommended to modify. |
| -InjectUnattend | bool | Inject Unattend.xml | When set to $true and InstallApps is also $true, stages the selected architecture-specific unattend XML file to $FFUDevelopmentPath\Apps\Unattend\Unattend.xml so sysprep can use it inside the VM. Cannot be used together with -CopyUnattend. Default is $false. |
| -InstallApps | bool | Install Applications | When set to $true, the script will create an Apps.iso file from the $FFUDevelopmentPath\Apps folder. It will also create a VM, mount the Apps.iso, install the apps, sysprep, and capture the VM. When set to $false, the FFU is created from a VHDX file, and no VM is created. |
| -InstallDrivers | bool | Install Drivers to FFU | Install device drivers from the specified $FFUDevelopmentPath\Drivers folder if set to $true. Download the drivers and put them in the Drivers folder. The script will recurse the drivers folder and add the drivers to the FFU. |
| -InstallOffice | bool | Install Office | Install Microsoft Office if set to $true. The script will download the latest ODT and Office files in the $FFUDevelopmentPath\Apps\Office folder and install Office in the FFU via VM. |
| -ISOPath | string | Windows ISO Path | Path to the Windows 10/11 ISO file. |
| -LogicalSectorSizeBytes | uint32 | Logical Sector Size | UInt32 value of 512 or 4096. Useful for 4Kn drives or devices shipping with UFS drives. Default is 512. |
| -Make | string | Make | Make of the device to download drivers. Accepted values are: 'Microsoft', 'Dell', 'HP', 'Lenovo'. |
| -MaxUSBDrives | int | Max USB Drives | Maximum number of USB drives to build in parallel. Default is 5. Set to 0 to process all discovered drives (or all selected drives when USBDriveList or selection is used). Actual throttle will never exceed the number of drives discovered. |
| -MediaType | string | Media Type | String value of either 'business' or 'consumer'. This is used to identify which media type to download. Default is 'consumer'. |
| -Memory | uint64 | Memory (GB) | Amount of memory to allocate for the virtual machine. Recommended to use 8GB if possible, especially for Windows 11. Default is 4GB. |
| -Model | string | Driver Models list | Model of the device to download drivers. This is required if Make is set. |
| -OfficeConfigXMLFile | string | Office Configuration XML File | Path to a custom Office configuration XML file to use for installation. |
| -Optimize | bool | Optimize | When set to $true, will optimize the FFU file. Default is $true. |
| -OptionalFeatures | string | Optional Features | Provide a semicolon-separated list of Windows optional features you want to include in the FFU (e.g., netfx3;TFTP). |
| -OrchestrationPath | string | Application Path (derived Orchestration path) | Path to the orchestration folder containing scripts that run inside the VM. Default is $FFUDevelopmentPath\Apps\Orchestration. |
| -PEDriversFolder | string | PE Drivers Folder | Path to the folder containing drivers to be injected into the WinPE deployment media. Default is $FFUDevelopmentPath\PEDrivers. |
| -Processors | int | Processors | Number of virtual processors for the virtual machine. Recommended to use at least 4. |
| -ProductKey | string | Product Key | Product key for the Windows edition specified in WindowsSKU. This will overwrite whatever SKU is entered for WindowsSKU. Recommended to use if you want to use a MAK or KMS key to activate Enterprise or Education. If using VL media instead of consumer media, you'll want to enter a MAK or KMS key here. |
| -PromptExternalHardDiskMedia | bool | Prompt for External Hard Disk Media | When set to $true, will prompt the user to confirm the use of media identified as External Hard Disk media via WMI class Win32_DiskDrive. Default is $true. |
| -RecoveryPartitionDriveLetter | string | Recovery Partition Drive Letter | Drive letter used for the Recovery partition while building the FFU VHDX. Default is R. |
| -RemoveApps | bool | Remove Apps Folder Content | When set to $true, will remove the application content in the Apps folder after the FFU has been captured. Default is $true. |
| -RemoveDownloadedESD | bool | Remove Downloaded ESD file(s) | When set to $true, downloaded Windows ESD files are automatically deleted after they have been applied. Default is $true. |
| -RemoveFFU | bool | Remove FFU | When set to $true, will remove the FFU file from the $FFUDevelopmentPath\FFU folder after it has been copied to the USB drive. Default is $false. |
| -RemoveUpdates | bool | Remove Downloaded Update Files | When set to $true, will remove the downloaded CU, MSRT, Defender, Edge, OneDrive, and .NET files downloaded. Default is $true. |
| -SystemPartitionDriveLetter | string | System Partition Drive Letter | Drive letter used for the System partition while building the FFU VHDX. Default is S. |
| -Threads | int | Threads | Controls the throttle applied to parallel tasks inside the script. Default is 5, matching the UI Threads field, and applies to driver downloads invoked through Invoke-ParallelProcessing. |
| -UnattendArm64FilePath | string | arm64 Unattend File Path | Path to the arm64 unattend XML source file used by Copy Unattend.xml and Inject Unattend.xml. Default is $FFUDevelopmentPath\Unattend\unattend_arm64.xml. |
| -UnattendX64FilePath | string | x64 Unattend File Path | Path to the x64 unattend XML source file used by Copy Unattend.xml and Inject Unattend.xml. Default is $FFUDevelopmentPath\Unattend\unattend_x64.xml. |
| -UpdateADK | bool | Update ADK | When set to $true, the script will check for and install the latest Windows ADK and WinPE add-on if they are not already installed or up-to-date. Default is $true. |
| -UpdateEdge | bool | Update Edge | When set to $true, will download and install the latest Microsoft Edge. Default is $false. |
| -UpdateLatestCU | bool | Update Latest Cumulative Update | When set to $true, will download and install the latest cumulative update. Default is $false. |
| -UpdateLatestDefender | bool | Update Defender | When set to $true, will download and install the latest Windows Defender definitions and Defender platform update. Default is $false. |
| -UpdateLatestMicrocode | bool | Update Latest Microcode (for LTSC/Server 2016/2019) | When set to $true, will download and install the latest microcode updates for applicable Windows releases (e.g., Windows Server 2016/2019, Windows 10 LTSC 2016/2019) into the FFU. Default is $false. |
| -UpdateLatestMSRT | bool | Update Microsoft Software Removal Tool (MSRT) | When set to $true, will download and install the latest Windows Malicious Software Removal Tool. Default is $false. |
| -UpdateLatestNet | bool | Update .NET | When set to $true, will download and install the latest .NET Framework. Default is $false. |
| -UpdateOneDrive | bool | Update OneDrive (Per-Machine) | When set to $true, will download and install the latest OneDrive and install it as a per-machine installation instead of per-user. Default is $false. |
| -UpdatePreviewCU | bool | Update Preview Cumulative Update | When set to $true, will download and install the latest Preview cumulative update. Default is $false. |
| -USBDriveList | hashtable | USB Drives list | A hashtable containing USB drives from win32_diskdrive where:<br>- Key: USB drive model name (partial match supported)<br>- Value: USB drive UniqueId string, or an array of UniqueIds (to support selecting multiple drives with the same model)<br><br>Examples:<br>@{ "SanDisk Ultra" = "1234567890" }<br>@{ "SanDisk Ultra" = @("1234567890", "ABCDEFG"); "Kingston DataTraveler" = "0987654321" } |
| -UseDriversAsPEDrivers | bool | Use Drivers Folder as PE Drivers Source | When set to $true (and -CopyPEDrivers is also $true), bypasses the contents of $FFUDevelopmentPath\PEDrivers and instead builds the WinPE driver set dynamically from the $DriversFolder path, copying only the required WinPE drivers. Has no effect if -CopyPEDrivers is not specified. Default is $false. |
| -UserAgent | string | CLI only (no UI control) | User agent string to use when downloading files. |
| -UserAppListPath | string | Application Path (derived UserAppList.json) | Path to a JSON file containing a list of user-defined applications to install. Default is $FFUDevelopmentPath\Apps\UserAppList.json. |
| -VMLocation | string | VM Location | Default is $FFUDevelopmentPath\VM. This is the location of the VHDX that gets created where Windows will be installed to. |
| -VMSwitchName | string | VM Switch Name + Custom VM Switch Name (when Other selected) | Name of the Hyper-V virtual switch used when -EnableVMNetworking is set to $true. |
| -WindowsArch | string | Windows Architecture | String value of 'x86', 'x64', or 'arm64'. This is used to identify which architecture of Windows to download. Default is 'x64'. |
| -WindowsPartitionDriveLetter | string | Windows Partition Drive Letter | Drive letter used for the Windows partition while building the FFU VHDX. Default is W. |
| -WindowsLang | string | Windows Language | String value in language-region format (e.g., 'en-us'). This is used to identify which language of media to download. Default is 'en-us'. |
| -WindowsRelease | int | Windows Release | Integer value of 10, 11, 2016, 2019, 2021, 2022, 2024, or 2025. This is used to identify which Windows client/LTSC/server release to use. Default is 11. |
| -WindowsSKU | string | Windows SKU | Edition/SKU to install. Accepted values are: 'Home', 'Home N', 'Home Single Language', 'Education', 'Education N', 'Pro', 'Pro N', 'Pro Education', 'Pro Education N', 'Pro for Workstations', 'Pro N for Workstations', 'Enterprise', 'Enterprise N', 'Enterprise 2016 LTSB', 'Enterprise N 2016 LTSB', 'Enterprise LTSC', 'Enterprise N LTSC', 'IoT Enterprise LTSC', 'IoT Enterprise N LTSC', 'Standard', 'Standard (Desktop Experience)', 'Datacenter', 'Datacenter (Desktop Experience)'. |
| -WindowsVersion | string | Windows Version | String value of the Windows version to download. This is used to identify which version of Windows to download. Default is '25h2'. |
{: .parameters-reference-table }
{% include page_nav.html %}
+11 -5
View File
@@ -26,14 +26,20 @@ Follow the guide linked below to install Hyper-V on Windows client or Server
## Install PowerShell 7 ## Install PowerShell 7
PowerShell 7 is required as of releases 2507+ onward. PowerShell 7.6+ is required as of releases 2507+ onward.
[Installing PowerShell on Windows - PowerShell \| Microsoft Learn [Installing PowerShell on Windows - PowerShell \| Microsoft Learn
](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows) ](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows)
Recommended to use winget to install Recommended to use winget to install
`winget install --id Microsoft.PowerShell --source winget` `winget install --id Microsoft.PowerShell --source winget --installer-type wix`
{: .note-title}
> Note
>
> As of PowerShell 7.6, the default winget installer uses the MSIX version, which is the store version of PowerShell 7.6. Adding `--installer-type wix` will install the MSI version.
If you can't use winget, [download the MSI](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows#installing-the-msi-package) If you can't use winget, [download the MSI](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows#installing-the-msi-package)
@@ -47,7 +53,7 @@ Once Hyper-V has been enabled and you have rebooted, create either an external o
If you haven't [downloaded the latest release yet, do so](https://github.com/rbalsleyMSFT/FFU/releases) If you haven't [downloaded the latest release yet, do so](https://github.com/rbalsleyMSFT/FFU/releases)
Once downloaded, extract the zip file to `C:\FFUDevelopment`. You can use another location, just be sure set your FFUDevelopmentPath to the new location (e.g. `D:\FFUDevelopment`). Once downloaded, open the zip file and copy the content of the FFUDevelopment folder to `C:\FFUDevelopment`. You can use another location, just be sure set your FFUDevelopmentPath to the new location (e.g. `D:\FFUDevelopment`).
After extraction, you most likely will need to unblock the files as they'll be tagged with the mark of the web. In PowerShell run: After extraction, you most likely will need to unblock the files as they'll be tagged with the mark of the web. In PowerShell run:
@@ -57,10 +63,10 @@ Replace `C:\FFUDevelopment` with the path you extracted the files to.
## Running BuildFFUVM_UI.ps1 ## Running BuildFFUVM_UI.ps1
Either run Terminal as Admin, making sure to select PowerShell, not Windows PowerShell, or PowerShell 7.5+ as Admin and run `C:\FFUDevelopment\BuildFFUVM_UI.ps1` Either run Terminal as Admin, making sure to select PowerShell, not Windows PowerShell, or PowerShell 7.6+ as Admin and run `C:\FFUDevelopment\BuildFFUVM_UI.ps1`
If all went well, you should see the FFU Builder UI If all went well, you should see the FFU Builder UI
![1759527337644](image/Prerequisites/1759527337644.png) ![1776376971272](image/prerequisites/1776376971272.png)
{% include page_nav.html %} {% include page_nav.html %}
+143 -14
View File
@@ -25,6 +25,24 @@ After following this guide, you will have a USB drive with an FFU that contains
* Drivers (Optional) * Drivers (Optional)
* In some cases drivers aren't necessary and you can get away with Windows Update providing drivers. We'll go over how to add drivers via the UI for Microsoft, HP, Lenovo, or Dell devices * In some cases drivers aren't necessary and you can get away with Windows Update providing drivers. We'll go over how to add drivers via the UI for Microsoft, HP, Lenovo, or Dell devices
## Video Walkthrough
{: .note-title}
> Note
>
> The below video was recorded prior to the Fluent UI refresh. Some things will look a bit different until a new quick start video is recorded.
<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;">
<iframe
src="https://www.youtube-nocookie.com/embed/38sUc3M5Yls"
title="YouTube video player"
style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen>
</iframe>
</div>
## Prerequisites ## Prerequisites
Follow the [prerequisites](/FFU/prerequisites.html) documentation before getting started. Follow the [prerequisites](/FFU/prerequisites.html) documentation before getting started.
@@ -33,7 +51,9 @@ Follow the [prerequisites](/FFU/prerequisites.html) documentation before getting
Click the Hyper-V Settings tab Click the Hyper-V Settings tab
You should be able to keep these settings at the defaults. For VM Switch Name and VM Host IP Address, you'll want to make sure the switch you created in the prerequisites section is listed. FFU Builder should automatically figure out the IP address of the swtich for you. You should be able to keep these settings at the defaults. **Enable VM Networking (Experimental)** is off by default and should stay off unless you specifically want to test an internet-connected VM during provisioning.
If you turn on **Enable VM Networking (Experimental)**, make sure the switch you created in the prerequisites section is listed under **VM Switch Name**. If the build does not create a VM, this setting has no effect.
One setting you might need to set is the Logical Sector Size. 512 is the default and that's what most physical disks use today. However 4kn drives and UFS drives use 4k sector sizes, which would require you to select 4096. For now, select 512. If you have issues during deployment, there is error logging that will tell you if you need to set to 4096 on your device. One setting you might need to set is the Logical Sector Size. 512 is the default and that's what most physical disks use today. However 4kn drives and UFS drives use 4k sector sizes, which would require you to select 4096. For now, select 512. If you have issues during deployment, there is error logging that will tell you if you need to set to 4096 on your device.
@@ -41,7 +61,7 @@ One setting you might need to set is the Logical Sector Size. 512 is the default
Click the Windows Settings tab Click the Windows Settings tab
If you keep ISO Path blank, FFU Builder will download the ESD file that the Windows Media Creation Tool uses. Most people should leave this blank since the Media Creation Tool media ESD file is now kept up to date as of Windows 11 25H2 (it's usually updated 2-3 days after patch Tuesday). This reduces the need to service the media and saves time and disk space. Keep Download Windows ESD selected. FFU Builder will download the ESD file that the Windows Media Creation Tool uses. This is the recommended approach since the Media Creation Tool media ESD file is now kept up to date as of Windows 11 25H2 (it's usually updated 2-3 days after patch Tuesday). This reduces the need to service the media and saves time and disk space.
Change the Windows language to the one of your choosing. Change the Windows language to the one of your choosing.
@@ -154,7 +174,7 @@ Check **Copy Drivers to USB drive** (even though we're doing a single model in t
Your view should look like this: Your view should look like this:
![Drivers tab UI with HP 850 G8 selected and Copy Drivers to USB drive selected](image/quickstart/1769212208722.png "Drivers tab UI") ![1776377145296](image/quickstart/1776377145296.png)
At this point, you can either Save the Drivers.json file or click Download Selected. Clicking Save Drivers.json will save the Driver model information to the Drivers.json file which will be used at build time to download the drivers. Clicking Download Selected will download the drivers right now. This can be useful for testing without having to go through an entire build, or good for airgapped environments where you can download what you need from the internet on one network, and bring that over to the airgapped network. At this point, you can either Save the Drivers.json file or click Download Selected. Clicking Save Drivers.json will save the Driver model information to the Drivers.json file which will be used at build time to download the drivers. Clicking Download Selected will download the drivers right now. This can be useful for testing without having to go through an entire build, or good for airgapped environments where you can download what you need from the internet on one network, and bring that over to the airgapped network.
@@ -172,18 +192,46 @@ Another safety measure is **Select Specific USB Drives**. When you check **Selec
**Device Naming** **Device Naming**
Device naming can be done from PE. The way this works is by leveraging an unattend.xml file to either take input from the user at imaging time or read a list of prefix values and append the serial number of the device. There are some major benefits to doing this: Use the **Device Naming** expander on the Build page to decide whether `ComputerName` should be set during deployment. 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. 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. 2. Reduces the need for multiple provisioning packages or autopilot profiles. This means you can use a single PPKG or autopilot profile.
**Prompt for Device Name** **No Device Name**
If you want to be prompted for the device name, simply check **Copy Unattend.xml.** This tells the build script to copy the appropriate architecture unattend_arch.xml file from the `C:\FFUDevelopment\Unattend` folder to the `.\unattend` folder on the deploy partition of the USB drive. This is the default option. The unattend file is still applied, but Windows generates a random computer name.
**Specifying Multiple Name Prefixes** **Specify Device Name**
If you have multiple device name prefixes for different locations or device use cases, or even a single prefix, you can specify a prefixes.txt file in the `C:\FFUDevelopment\unattend` folder. If the prefixes.txt file is detected and a single prefix is listed, the device will just use that prefix and append the serial number of the device. If there are multiple prefixes listed in the prefixes.txt file, you will be prompted to select which prefix you want to name the device and the serial number will be appended to that prefix. If you want a dash in the name, include the dash in the prefix (e.g. if ABCD- is in the prefixes.txt file, the device name will be ABCD-SerialNumber). Use this option when you want a static device name or a template such as `Comp-%serial%`.
- 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 or import an existing prefixes file. The source file can use any name because the UI tracks the prefixes path separately. If there is one prefix, deployment uses it automatically. If there are multiple prefixes, the technician is prompted to select one and the serial number is appended to that prefix.
{: .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-*`.
**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 CSV and applies the matching computer name.
- 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.
{: .note-title}
> Note
>
> If `prefixes.txt` and `SerialComputerNames.csv` are both present on the same deployment media, `ApplyFFU.ps1` checks `prefixes.txt` first. FFU Builder stages only the naming file for the selected device-naming mode.
{: .warning-title} {: .warning-title}
@@ -191,13 +239,23 @@ If you have multiple device name prefixes for different locations or device use
> >
> 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. > 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.
For the purposes of this quickstart, we'll use **Prompt for Device Name**
**Post Build Cleanup** **Post Build Cleanup**
Leave the Post Build Cleanup section at the defaults Leave the Post Build Cleanup section at the defaults
Your Build tab should look something like this: Your Build tab should look something like this:
![1769218100003](image/quickstart/1769218100003.png) ![1776377420583](image/quickstart/1776377420583.png)
![1776377442979](image/quickstart/1776377442979.png)
![1776377516879](image/quickstart/1776377516879.png)
![1776377592432](image/quickstart/1776377592432.png)
![1776377622826](image/quickstart/1776377622826.png)
Click **Build FFU** Click **Build FFU**
@@ -205,8 +263,6 @@ Depending on your internet speed, speed of your build machine, etc. this will ta
## Monitor ## Monitor
![1769218278316](image/quickstart/1769218278316.png)
The monitor tab parses the `C:\FFUDevelopment\FFUDevelopment.log` file. If you'd like to use CMTrace or another tool to monitor the log, feel free. The monitor tab has some similar functionality to CMTrace. If you click off the last line of the log in the monitor tab it will stay on that line, allowing you to read what you have selected instead of the log autoscrolling. You can also copy one or multiple lines by selecting a line and shift+clicking the last line you want to select and hitting ctrl+c to copy the lines. The monitor tab parses the `C:\FFUDevelopment\FFUDevelopment.log` file. If you'd like to use CMTrace or another tool to monitor the log, feel free. The monitor tab has some similar functionality to CMTrace. If you click off the last line of the log in the monitor tab it will stay on that line, allowing you to read what you have selected instead of the log autoscrolling. You can also copy one or multiple lines by selecting a line and shift+clicking the last line you want to select and hitting ctrl+c to copy the lines.
Now sit back, relax, and watch FFU Builder do its magic. You should see a VM pop up after everything is downloaded and Windows has been installed to the VHDX file (it may not if Hyper-V manager wasn't previously open). If you don't see a VM pop up, you can open up Hyper-V manager and look for a VM with a name that starts with **_FFU-.** Now sit back, relax, and watch FFU Builder do its magic. You should see a VM pop up after everything is downloaded and Windows has been installed to the VHDX file (it may not if Hyper-V manager wasn't previously open). If you don't see a VM pop up, you can open up Hyper-V manager and look for a VM with a name that starts with **_FFU-.**
@@ -258,18 +314,91 @@ And the Unattend folder should have an unattend.xml file with the following cont
<settings pass="specialize"> <settings pass="specialize">
<!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it --> <!--<ComputerName> must be in the first Component Element "Microsoft-Windows-Shell-Setup" . Do not change the order or remove it -->
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ComputerName>MyComputer</ComputerName> <ComputerName>*</ComputerName>
</component> </component>
<!--Place addtional Components Elements and settings below here. --> <!--Place addtional Components Elements and settings below here. -->
</settings> </settings>
</unattend> </unattend>
``` ```
Keep `*` if you want Windows to generate a random device name by default.
If you want the technician to be prompted for the device name during deployment, select **Prompt for Device Name** in the Build tab and enable **Copy Unattend.xml**. FFU Builder will rewrite only the staged deployment copy of `Unattend.xml` for that workflow.
Now you're ready to deploy the FFU to your device. Now you're ready to deploy the FFU to your device.
## Deployment ## Deploying to a physical machine
Deployment should be fairly straight forward: boot off the USB device, get prompted for a device name, and the deployment of the FFU and drivers should happen automatically. Deployment should be fairly straight forward: boot off the USB device and the deployment of the FFU and drivers should happen automatically. If you selected **Prompt for Device Name** or another supported device naming option, that naming step will happen during deployment.
## Deploying to a Hyper-V Virtual Machine
You can test FFU deployment without a physical device by using a Hyper-V virtual machine. Instead of a USB drive, the VM uses two virtual hard disks: one as the target where Windows will be installed, and a second VHDX that mirrors the Deploy partition of your USB drive.
### VM Hard Disk Requirements
Your Hyper-V VM must have two hard disks attached:
- **Disk 0** — The target disk where the FFU will be applied (equivalent to the physical device being imaged)
- **Disk 1** — A VHDX containing your FFU and any other deployment files (equivalent to the Deploy partition on your USB drive)
The VM also needs to boot from the WinPE ISO created during the FFU build (`C:\FFUDevelopment\WinPE_FFU_Deploy_x64.iso`), which acts as the Boot partition on your USB drive.
### Creating the Deploy VHDX
1. Open **Disk Management** (right-click Start > **Disk Management**, or run `diskmgmt.msc`)
2. Click **Action** > **Create VHD**
3. Choose a location and file name for the VHDX (e.g. `C:\VMs\deploy.vhdx`)
4. Set the size large enough to hold your FFU file (at minimum a few gigabytes larger than the FFU)
5. Select **VHDX** for the format and **Dynamically expanding** for the type
6. Click **OK** — the new disk appears in Disk Management
7. Right-click the new disk and select **Initialize Disk**, then choose **GPT**
8. Right-click the unallocated space and select **New Simple Volume**
9. Follow the wizard to format the volume as **NTFS**
10. When prompted for the **Volume label**, enter **Deploy**
{: .note-title}
> Note
>
> The deployment script identifies the deploy partition by its volume label. The label must be **Deploy** (case-insensitive). If you have an existing VHDX whose volume isn't labeled correctly, open File Explorer, right-click the drive, select **Properties**, and update the label to **Deploy**.
### Populating the Deploy VHDX
With the VHDX mounted and labeled, copy your deployment files onto it:
1. Copy your FFU file from `C:\FFUDevelopment\FFU` to the Deploy volume
2. Optionally copy any of the following to match the layout of your USB drive's Deploy partition:
- **Drivers** folder — for automatic driver injection during deployment
- **Unattend** folder — containing `unattend.xml`
- **Autopilot** folder — your Autopilot JSON file
- **PPKG** folder — your provisioning package
{: .note-title}
> Note
>
> Copying a large FFU file onto a freshly created dynamic VHDX may take several minutes. The VHDX file needs to expand on disk to accommodate the data as it is copied.
Once populated, the Deploy VHDX contains exactly what a USB drive's Deploy partition would contain, and the deployment script treats it identically.
### Configuring and Starting the VM
1. In **Hyper-V Manager**, open the settings for your VM
2. Under **SCSI Controller** (Generation 2) or **IDE Controller** (Generation 1), add your deploy VHDX as a second hard disk
3. Add your WinPE ISO (`C:\FFUDevelopment\WinPE_FFU_Deploy_x64.iso`) as a DVD drive
{: .note-title}
> Note
>
> For ARM64 builds, the ISO will be named `WinPE_FFU_Deploy_arm64.iso`.
4. Set the boot order so the VM boots from the DVD drive first
5. Start the VM — WinPE will boot, locate the Deploy volume by its **Deploy** label, and apply the FFU to Disk 0 automatically
Deployment proceeds identically to a physical device. Drivers, unattend, and any other files present in the Deploy VHDX are applied just as they would be from the Deploy partition of a USB drive.
If you have any questions or run into any issues, [open a discussion in the Github repo](https://github.com/rbalsleyMSFT/FFU/discussions). If you have any questions or run into any issues, [open a discussion in the Github repo](https://github.com/rbalsleyMSFT/FFU/discussions).
+19
View File
@@ -0,0 +1,19 @@
---
title: Reference
nav_order: 5
prev_url: /helper_scripts.html
prev_label: Helper Scripts
next_url: /parameters_reference.html
next_label: Parameters Reference
has_children: true
has_toc: false
---
# Reference
This section contains deep-dive documentation that explains how FFU Builder works behind the UI.
## Available reference guides
- [BuildFFUVM Parameter Reference](/FFU/parameters_reference.html)
{% include page_nav.html %}
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://rbalsleymsft.github.io/FFU/sitemap.xml
+17
View File
@@ -0,0 +1,17 @@
---
layout: null
---
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{% assign sortedPages = site.pages | sort: "url" %}
{% for page in sortedPages %}
{% if page.sitemap != false and page.url != "/404.html" %}
{% if page.url == "/" or page.url contains ".html" %}
<url>
<loc>{{ site.url }}{{ site.baseurl }}{{ page.url | replace: "index.html", "" }}</loc>
<lastmod>{{ site.time | date_to_xmlschema }}</lastmod>
</url>
{% endif %}
{% endif %}
{% endfor %}
</urlset>
+2 -2
View File
@@ -9,6 +9,6 @@ has_toc: false
--- ---
# UI Overview # UI Overview
![1759527337644](image/Prerequisites/1759527337644.png) ![1776378210391](image/ui_overview/1776378210391.png)
The user interface has 9 distinct tabs for easy navigation. The user interface has 9 distinct pages for easy navigation.
+1 -1
View File
@@ -9,7 +9,7 @@ parent: UI Overview
--- ---
# Updates # Updates
![1759878277807](image/updates/1759878277807.png) ![1776378727768](image/updates/1776378727768.png)
## Update Latest Cumulative Update ## Update Latest Cumulative Update
+76
View File
@@ -0,0 +1,76 @@
---
title: USB Imaging Tool Creator
nav_order: 2
prev_url: /create_pemedia.html
prev_label: Create PE Media
parent: Helper Scripts
---
# USB Imaging Tool Creator
`USBImagingToolCreator.ps1` is a standalone helper for creating one or more deployment USB drives from a deploy ISO, FFU files, and optional drivers. This is best used when you want to provide remote technicians the FFU file(s) you've built and optionally a drivers folder that contains the drivers for the models they will need to manage. They can also provide their own drivers (using Drivers\Make\Model format (e.g Drivers\Dell\Optiplex 7060 (085D))
## How the script works
- `-DeployISOPath` is required and should point to the deploy ISO file.
- The script uses the parent folder of that ISO as its working root.
- FFU files are copied from `<ISO parent>\FFU` (all `.ffu` files, recursive).
- Drivers are copied from `<ISO parent>\Drivers` (recursive) when present.
- If drivers are not found, the script creates an empty `Drivers` folder on each deploy partition.
- `-DisableAutoPlay` is optional and temporarily disables AutoPlay for the current user during media creation. This is useful in situations where you see File Explorer pop ups as it's building the USB drive.
## Network share workflow (admin/technician)
For a shared workflow, stage one folder on a share with the deploy ISO and content that technicians should copy to USB drives.
If you do not already have a deployment ISO in the staging location, create one first using [Create PE Media](/FFU/create_pemedia.html). This lets admins quickly generate the deploy ISO and then stage it for technicians using `USBImagingToolCreator.ps1`.
Example layout:
```text
\\Server\FFUStaging\
WinPE_FFU_Deploy.iso
FFU\
<image files>.ffu
Drivers\
<optional driver content>
```
Run from an elevated PowerShell session:
```powershell
.\USBImagingToolCreator.ps1 -DeployISOPath "\\Server\FFUStaging\WinPE_FFU_Deploy.iso" -DisableAutoPlay
```
The script passes `-DeployISOPath` directly to `Mount-DiskImage`, so use a path the local Windows host can mount.
## Example folder structure
In this example a folder named USBCreator was made and the Drivers and FFU folders as well as the WinPE_FFU_Deploy_x64.iso and USBImagingToolCreator.ps1 files were copied from the FFUDevelopment folder to this new folder.
![1771882385742](image/usb_imaging_tool_creator/1771882385742.png)
## What happens when you run it
1. Detects disks with media type **Removable media** or **External hard disk media**.
2. Prompts for a single drive selection or an all-drives selection.
3. Stops `mmc` and `diskpart` processes to reduce drive lock issues.
4. Erases each selected disk and rebuilds it as MBR with:
- `Boot` partition (2 GB, FAT32, active)
- `Deploy` partition (remaining space, NTFS)
5. Mounts the deploy ISO and copies all ISO content to every `Boot` partition.
6. Copies FFU content to every `Deploy` partition.
7. Copies driver content into `Deploy\Drivers` (or creates an empty `Drivers` folder).
8. Dismounts the ISO and reports completion.
{: .warning-title}
> Warning
>
> Selected disks are fully erased (`Clear-Disk -RemoveData -RemoveOEM`), so verify drive selection carefully, especially when using the all-drives option.
## Logging and progress
- Progress is shown in the PowerShell progress UI.
- `Script.log` is written in the same folder as the deploy ISO (the working root folder).
{% include page_nav.html %}
+15 -5
View File
@@ -9,11 +9,21 @@ parent: UI Overview
--- ---
# Windows Settings # Windows Settings
![1759535556990](image/ui_overview/1759535556990.png) ![1776378316739](image/windows_settings/1776378316739.png)
## Windows ISO Path ## Windows Media Source
Path to Windows 10/11 ISO file. If left blank, FFU Builder will download the latest version of Windows 10 or 11 from the Media Creation Tool. ### Download Windows ESD
Download Windows ESD will download the ESD file provided from the Windows Media Creation Tool
### Provide Windows ISO
You can provide your own Windows ISO (Client or Server). Good for scenarios where you want to deploy Enterprise or Education SKUs and have a product key you want to use (MAK or KMS).
#### Windows ISO Path
Path to Windows ISO file.
{: .tip-title} {: .tip-title}
@@ -21,11 +31,11 @@ Path to Windows 10/11 ISO file. If left blank, FFU Builder will download the lat
> >
> Should I provide my own ISO, or let FFU Builder download the media > Should I provide my own ISO, or let FFU Builder download the media
> >
> It's recommended to use the latest updated ISO from Visual Studio Downloads, or the Media Creation tool. See the Media Type section below for a better understanding of business and consumer media and how it impacts Subscription Activation. > It's recommended to use the latest ESD in most scenarios. It gets updated a couple of days after Patch Tuesday (so roughly the 2nd Thursday or Friday). See the Media Type section below for a better understanding of business and consumer media and how it impacts Subscription Activation.
## Windows Release ## Windows Release
Integer value of 10 or 11. This is used to identify which release of Windows to download. Default is 11. Can be 10, 11, different Server values, or LTSB/LTSC.
## Windows Version ## Windows Version
+4 -4
View File
@@ -10,7 +10,7 @@ grand_parent: UI Overview
--- ---
# Install Winget Applications # Install Winget Applications
![FFU Builder UI - Applications tab with Install Winget Applications checked searching for windows app](image/applicationscopy/1759882085098.png "FFU Builder UI - Applications tab with Install Winget Applications checked searching for windows app") ![1776378791094](image/winget/1776378791094.png)
## Check Winget Status ## Check Winget Status
@@ -18,19 +18,19 @@ Installing Winget applications requires that both the winget CLI and Microsoft.W
Click **Check Winget Status** to validate the versions of both the CLI and PowerShell module. If older than the minimum required version, will be updated to the latest version. Click **Check Winget Status** to validate the versions of both the CLI and PowerShell module. If older than the minimum required version, will be updated to the latest version.
![Check Winget Status button](image/winget/1759883187930.png "Check Winget Status button") ![1776378813453](image/winget/1776378813453.png)
After validating Winget status, you'll be able to search winget for applications. The larger the result set, the longer it will take for the list view to be populated. For example, if searching for **win**, the UI might appear to hang while it searches for apps with a name or id of **win** due to 669 results being returned and processed. Instead, if you search for **windows app**, 13 results are returned within a few seconds. After validating Winget status, you'll be able to search winget for applications. The larger the result set, the longer it will take for the list view to be populated. For example, if searching for **win**, the UI might appear to hang while it searches for apps with a name or id of **win** due to 669 results being returned and processed. Instead, if you search for **windows app**, 13 results are returned within a few seconds.
The UI allows for multi-selection of applications The UI allows for multi-selection of applications
![Winget search list view with Windows App, VLC media player, and Snagit 2025 selected](image/winget/1759884200465.png "Winget search list view with Windows App, VLC media player, and Snagit 2025 selected") ![1776378860799](image/winget/1776378860799.png)
You can also change the architecture, add additional exit codes, or ignore exit codes completely. You can also change the architecture, add additional exit codes, or ignore exit codes completely.
## Architecture ## Architecture
![Architecture options that can be selected for winget applications](image/winget/1759884446099.png "Architecture options that can be selected for winget applications") ![1776378878837](image/winget/1776378878837.png)
FFU Builder supports x86, x64, arm64, and x86/x64 (both) for applications in the winget source repository. For apps in the msstore source repository, the architecture cannot be changed. In most cases, x64 will be what you want, however in some cases the combo of x86 and x64 will be necessary. This might be due to runtimes (.NET, Visual C++) where an application is expecting both x86 and x64 runtimes. FFU Builder supports x86, x64, arm64, and x86/x64 (both) for applications in the winget source repository. For apps in the msstore source repository, the architecture cannot be changed. In most cases, x64 will be what you want, however in some cases the combo of x86 and x64 will be necessary. This might be due to runtimes (.NET, Visual C++) where an application is expecting both x86 and x64 runtimes.